Build native apps with YAML and Lua. One codebase, every Apple platform + Android.
No JSX, no bridge, no bundler — describe your UI in YAML, write your logic in Lua, and Melody renders it as real native UI. SwiftUI on Apple platforms, Jetpack Compose on Android.
app:
name: MyApp
theme:
primary: "#6366f1"
screens:
- id: home
path: /
title: Home
state:
count: 0
body:
- component: text
text: "{{ 'Tapped ' .. state.count .. ' times' }}"
style:
fontSize: 24
fontWeight: bold
- component: button
label: Tap me
onTap: |
state.count = state.count + 1
melody.log("count is now " .. state.count)That's a full screen. Change the YAML, hot reload, see it instantly.
I wanted to build apps fast without fighting tooling. React Native is great but it's a lot of moving parts. SwiftUI is great but iteration is slow. Melody sits in between — native performance with the speed of a scripting language.
- YAML for layout. Declarative, readable, diffable.
- Lua for logic. Tiny, fast, embeddable. No npm, no bundler.
- Truly native under the hood. SwiftUI on iOS/iPadOS/macOS/tvOS/visionOS, Jetpack Compose on Android. Not a web view, not a canvas — real platform components.
Web, Windows, and Linux support is planned.
Full walkthrough: Getting Started Guide | Tutorial: Build a Notes App
brew install josejuanqm/tap/melody
# or
git clone https://github.com/josejuanqm/melody.git
cd melody
swift build -c release
cp .build/release/melody /usr/local/bin/melody create MyApp
cd MyApp
open MyApp.xcodeprojThis scaffolds everything — YAML, Xcode project, Android boilerplate, asset directories. Hit Run in Xcode and you're live.
melody devEdit your YAML, save, and the app updates instantly over WebSocket. Works on the simulator, a physical device, or a macOS preview window.
melody dev --platform ios --simulator "iPhone 16 Pro"
melody dev --platform macos
melody dev --platform ios --devicePress r + Enter to force a reload.
Full reference: Components Guide
23+ built-in components that map to native views on each platform — SwiftUI on Apple, Jetpack Compose on Android:
| Component | What it does |
|---|---|
text |
Labels, headings, dynamic expressions |
button |
Tappable actions with labels and icons |
stack |
HStack / VStack / ZStack via direction |
image |
Remote URLs or SF Symbols |
input |
Text fields, secure fields, text areas |
toggle |
Boolean switches |
picker |
Segmented, wheel, or menu selection |
datepicker |
Compact, graphical, or wheel date pickers |
slider |
Range selection |
stepper |
Increment / decrement |
list |
Dynamic scrollable lists with Lua render functions |
grid |
Adaptive or fixed column grids |
form |
Native grouped form layouts |
section |
Headers, footers, grouped content |
chart |
Bar, line, area, point, sector (pie) charts |
menu |
Dropdown menus |
link |
Open URLs in system browser |
disclosure |
Expandable sections |
progress |
Determinate / indeterminate progress |
spacer |
Flexible space |
divider |
Visual separator |
scroll |
Scrollable containers |
state_provider |
Scoped local state for self-contained widgets |
Every component supports style, visible, disabled, onTap, onHover, and contextMenu.
All styling goes in the style block — layout, typography, colors, borders, shadows, animations:
- component: stack
direction: vertical
style:
backgroundColor: "theme.surface"
borderRadius: 16
padding: 16
spacing: 12
shadow: { radius: 4, y: 2 }
animation: spring
children:
- component: text
text: "{{ state.title }}"
style:
fontSize: 20
fontWeight: semibold
color: "theme.textPrimary"
- component: text
text: "{{ state.subtitle }}"
style:
fontSize: 15
color: "theme.textSecondary"Style supports fontSize, fontWeight, fontDesign, color, backgroundColor, padding (all sides or per-side), margin, width, height, minWidth, maxWidth, borderRadius, borderWidth, borderColor, opacity, scale, rotation, shadow, alignment, spacing, aspectRatio, contentMode, and animation.
Use "theme.colorName" anywhere you'd put a hex — it resolves from your theme automatically.
The list and grid components use Lua render functions to generate items:
- component: list
items: "state.todos"
style:
spacing: 8
padding: 16
render: |
local item = state._current_item
return {
component = "stack",
direction = "horizontal",
style = { spacing = 12, padding = 12, backgroundColor = theme.surface, borderRadius = 12 },
children = {
{ component = "image", systemImage = item.done and "checkmark.circle.fill" or "circle",
style = { width = 24, height = 24, color = item.done and theme.success or theme.textTertiary } },
{ component = "text", text = item.title or "", style = { fontSize = 16, color = theme.textPrimary } },
{ component = "spacer" }
}
}Native Swift Charts — bar, line, area, point, sector (pie/donut):
- component: chart
items: "state.revenue"
marks:
- type: bar
xKey: month
yKey: amount
style:
height: 250
# Donut chart
- component: chart
items: "state.categories"
marks:
- type: sector
angleKey: count
groupKey: name
innerRadius: 0.6
angularInset: 2Native Form + Section for settings screens. Use wrapper: form on the screen or nest a form component:
screens:
- id: settings
path: /settings
title: Settings
wrapper: form
state:
darkMode: false
language: "en"
body:
- component: section
label: Preferences
footer: Changes are saved automatically
children:
- component: toggle
label: Dark Mode
stateKey: darkMode
- component: picker
label: Language
stateKey: language
options:
- { label: English, value: en }
- { label: Spanish, value: es }
- component: section
label: About
children:
- component: link
label: Privacy Policy
url: "https://example.com/privacy"Deep dive: Core Concepts
State is reactive. Assign to state.key in Lua and the UI updates automatically. Only the components that reference that key re-render — fine-grained reactivity, no diffing.
state:
user: null
loading: true
onMount: |
local res = melody.fetch("https://api.example.com/me")
if res.ok then
state.user = res.data
end
state.loading = falseState types: strings, numbers, booleans, null, arrays, and tables. Initialize everything with sensible defaults.
melody.navigate("/profile/123") -- push screen
melody.navigate("/detail", { id = 42 }) -- push with props
melody.goBack() -- pop
melody.replace("/home") -- replace entire stack
melody.switchTab("search") -- switch tabPath params are accessed via params.key (always strings — use tonumber() for numbers).
local res = melody.fetch(url, {
method = "POST",
headers = { ["Authorization"] = "Bearer " .. token },
body = { title = "New item", done = false }
})
-- body tables are auto-serialized to JSON
-- res = { ok = bool, status = number, data = any, headers = {}, cookies = {} }
-- concurrent requests
local results = melody.fetchAll({
{ url = "/api/user" },
{ url = "/api/posts", method = "GET" }
})Fetch is non-blocking — it uses coroutines under the hood, so your Lua code reads linearly but doesn't freeze the UI.
melody.alert("Confirm", "Delete this item?", {
{ title = "Cancel", style = "cancel" },
{ title = "Delete", style = "destructive", onTap = "deleteItem()" }
})
melody.sheet("/edit-profile", { detent = "medium" })
melody.sheet("/onboarding", { style = "fullscreen" })
melody.dismiss() -- close current sheetmelody.storeSave("token", res.data.token) -- persists to disk
melody.storeSet("temp", value) -- session only
local token = melody.storeGet("token") -- reads from cache then diskCross-screen communication via pub/sub:
-- screen A
melody.emit("cartUpdated", { count = #state.items })
-- screen B
melody.on("cartUpdated", function(data)
state.cartCount = data.count
end)local id = melody.setInterval(function()
state.elapsed = state.elapsed + 1
end, 1000)
melody.clearInterval(id)local ws = melody.wsConnect("wss://api.example.com/ws")
ws:on("open", function() melody.log("connected") end)
ws:on("message", function(msg) state.messages = msg end)
ws:send({ type = "subscribe", channel = "updates" }) -- tables auto-serialize to JSON
ws:close()melody.log("debug info") -- console + dev overlay
melody.copyToClipboard(state.code) -- system clipboard
melody.setTitle("New Title") -- update nav title dynamically
melody.trustHost("localhost") -- trust self-signed SSL
melody.clearCookies() -- for logout flowsUse {{ }} in any string property to evaluate Lua inline:
- component: text
text: "{{ 'Hello, ' .. state.name }}"
visible: "{{ state.isLoggedIn }}"
style:
color: "theme.textPrimary"
expressions:
opacity: "state.visible and 1 or 0"Static strings don't need inner quotes — text: "Hello World" just works. Only quote when mixing with expressions: text: "'Count: ' .. state.count".
Define once, use anywhere. Inline in app.yaml or as *.component.yaml files:
components:
UserCard:
props:
name: ""
avatar: ""
role: ""
body:
- component: stack
direction: horizontal
style:
spacing: 12
padding: 16
backgroundColor: "theme.surface"
borderRadius: 16
children:
- component: image
src: "{{ props.avatar }}"
style: { width: 48, height: 48, borderRadius: 24, contentMode: fill }
- component: stack
direction: vertical
style: { spacing: 2 }
children:
- component: text
text: "{{ props.name }}"
style: { fontSize: 17, fontWeight: semibold, color: "theme.textPrimary" }
- component: text
text: "{{ props.role }}"
style: { fontSize: 13, color: "theme.textSecondary" }Then use it like any other component:
- component: UserCard
props:
name: "{{ state.user.name }}"
avatar: "{{ state.user.avatarUrl }}"
role: "{{ state.user.role }}"Full reference: Navigation Guide
Path-based with dynamic route params:
screens:
- id: home
path: /
body:
- component: button
label: View Profile
onTap: "melody.navigate('/profile/123')"
- id: profile
path: /profile/:id
onMount: |
local res = melody.fetch("https://api.example.com/user/" .. params.id)
if res.ok then state.user = res.data end
body:
- component: text
text: "{{ state.user.name }}"screens:
- id: main
path: /
tabStyle: sidebarAdaptable # sidebar on iPad/Mac, tab bar on iPhone
tabs:
- id: home
title: Home
icon: house.fill
screen: /home
- id: search
title: Search
icon: magnifyingglass
screen: /search
- id: profile
title: Profile
icon: person.fill
screen: /profileEach tab gets its own navigation stack. melody.navigate() pushes within the current tab, melody.replace() resets the whole app (useful for auth flows).
Full reference: Theming Guide
Define your palette once. Reference it everywhere with "theme.colorName".
app:
name: MyApp
theme:
primary: "#6366f1"
secondary: "#a855f7"
background: "#f2f2f7"
colors:
surface: "#ffffff"
surfaceElevated: "#f9f9f9"
border: "#e5e5ea"
textPrimary: "#000000"
textSecondary: "#8e8e93"
textTertiary: "#aeaeb2"
success: "#34c759"
error: "#ff3b30"
dark:
background: "#000000"
colors:
surface: "#1c1c1e"
surfaceElevated: "#2c2c2e"
border: "#38383a"
textPrimary: "#ffffff"
textSecondary: "#8e8e93"
textTertiary: "#636366"The dark and light blocks are overrides — they merge on top of the base colors depending on the system appearance. Set colorScheme: dark or colorScheme: light to force a mode.
Theme colors work everywhere: YAML styles (color: "theme.textPrimary"), Lua expressions (theme.primary), and render functions (style = { color = theme.success }).
Full reference: Plugins Guide
Extend Melody with native plugins. A plugin is a git repo that contains platform-specific source files and a plugin.yaml manifest. Plugins register functions that become callable from Lua under their own namespace.
Declare plugins in your app.yaml:
app:
name: MyApp
plugins:
keychain: https://github.com/example/melody-plugin-keychain.git
analytics: https://github.com/example/melody-plugin-analytics.gitThen run:
melody plugins installThis clones each plugin repo, copies the platform sources into your Xcode and Android projects, and generates the plugin registry automatically. Run it again to pull updates.
A plugin repo needs a plugin.yaml manifest at the root:
name: keychain
version: 1.0.0
description: Secure keychain/keystore access
ios:
sources:
- iOS/KeychainPlugin.swift
frameworks:
- Security
android:
sources:
- android/KeychainPlugin.kt
lua:
- lua/keychain.luaThe ios.sources and android.sources paths point to native implementations. Optional lua files get bundled as a prelude that runs before any screen loads — useful for helper functions.
Plugin repo structure:
melody-plugin-keychain/
plugin.yaml
iOS/
KeychainPlugin.swift
android/
KeychainPlugin.kt
lua/
keychain.lua # optional Lua helpers
import Runtime
class KeychainPlugin: MelodyPlugin {
var name = "keychain"
func register(vm: LuaVM) {
vm.registerPluginFunction(namespace: "keychain", name: "get") { args in
let key = args.first?.stringValue ?? ""
// ... keychain lookup
return .string(value)
}
vm.registerPluginFunction(namespace: "keychain", name: "set") { args in
// ... keychain write
return .bool(true)
}
}
}class KeychainPlugin : MelodyPlugin {
override val name = "keychain"
override fun register(vm: LuaVM) {
vm.registerPluginFunction("keychain", "get") { args ->
val key = args.firstOrNull()?.stringValue ?: ""
// ... keystore lookup
LuaValue.String(value)
}
vm.registerPluginFunction("keychain", "set") { args ->
// ... keystore write
LuaValue.Bool(true)
}
}
}Same Lua API on both sides:
local token = keychain.get("auth_token")
keychain.set("auth_token", newToken)| Command | What it does |
|---|---|
melody create <name> |
Scaffold a new project with Xcode + Android boilerplate |
melody dev |
Start dev server with hot reload over WebSocket |
melody build |
Bundle app for distribution |
melody validate |
Check your YAML for errors |
my-app/
app.yaml # App config, theme, screens
app.lua # Shared Lua helpers (optional, loaded via app.lua field)
screens/ # Screen files (auto-loaded *.yaml)
components/ # Reusable components (*.component.yaml)
assets/ # Images and static files
icon.png # App icon (1024x1024, optional)
MyApp.xcodeproj/ # Generated Xcode project
android/ # Generated Android project
| Platform | Runtime | Min Version |
|---|---|---|
| iOS | SwiftUI | 17+ |
| iPadOS | SwiftUI | 17+ |
| macOS | SwiftUI | 14+ |
| tvOS | SwiftUI | 26+ |
| visionOS | SwiftUI | 2+ |
| Android | Jetpack Compose | API 26+ |
Requires Swift 6.2+ and Xcode 26+ for Apple platforms.
Add Melody as a Swift Package:
dependencies: [
.package(url: "https://github.com/aspect-build/melody.git", from: "0.1.0"),
]Then add Core and Runtime to your target:
.target(
name: "YourApp",
dependencies: [
.product(name: "Core", package: "Melody"),
.product(name: "Runtime", package: "Melody"),
]
)MIT