Building Components
The library's built-ins cover the common register, and theming restyles all of them at once. App components are Native markup templates: they compose built-ins, accept values through template args, accept content through slots, and live in .native component files. Component logic stays in the TypeScript core's ordinary update and derived helpers. The mechanics (template grammar, import rules, slots) are specified in Native UI; this page builds one real component end to end.
The ownership model in one line: use and theme the built-ins by default; eject a library composite when you need to own its shape; build new composites from primitives when the library has no shape for it. The last two are this page.
A component in markup
A dashboard needs the same labeled stat tile three times. That repetition is a <template> — define the subtree once at the top of the view file, give its varying parts names in args, and stamp it with <use>:
<template name="stat-card" args="label value">
<column background="surface" border-color="border" radius="lg" padding="12" gap="4" role="group" label="{label}">
<text size="sm" foreground="text_muted">{label}</text>
<text size="heading">{value}</text>
</column>
</template>
<column gap="12" padding="16">
<row gap="12">
<use template="stat-card" label="CPU" value="{cpuPercent}" />
<use template="stat-card" label="Memory" value="{memoryUsed}" />
</row>
</column>Inside the body, args bind exactly like model bindings: {label} in text, label="{label}" in attributes. At the use site an arg's value may be a literal (label="CPU"), a binding (value="{cpuPercent}"), or an expression — and the attributes must match the declared args exactly, so a typo is a check error, never a silently ignored prop. The body is built in place of the <use>: widget ids hash as if you had written it out by hand, which means converting copy-pasted markup into a template changes no identity, no retained state, no automation target.
Optional args declare a literal default with name=value (args="label value hint=" gives hint an empty-string default), and the body can branch on them — this is how the card grows an optional footnote without a second template:
<template name="stat-card" args="label value hint=">
<column background="surface" border-color="border" radius="lg" padding="12" gap="4" role="group" label="{label}">
<text size="sm" foreground="text_muted">{label}</text>
<text size="heading">{value}</text>
<if test="{hint}">
<text size="sm" foreground="text_muted">{hint}</text>
</if>
</column>
</template>Moving it to a component file
Once a second view wants the card, move the template into its own file. A file of templates and nothing else is a component file — it has no view root, and that is what makes it importable:
<!-- src/components/stat-card.native -->
<template name="stat-card" args="label value hint=">
<column background="surface" border-color="border" radius="lg" padding="12" gap="4" role="group" label="{label}">
<text size="sm" foreground="text_muted">{label}</text>
<text size="heading">{value}</text>
<if test="{hint}">
<text size="sm" foreground="text_muted">{hint}</text>
</if>
</column>
</template>The view imports it at the very top, before anything else, and uses it exactly as before:
<import src="components/stat-card.native"/>
<column gap="12" padding="16" background="background">
<row gap="12">
<use template="stat-card" label="CPU" value="{cpuPercent}" hint="last minute" />
<use template="stat-card" label="Memory" value="{memoryUsed}" />
<use template="stat-card" label="Uptime" value="{uptimeText}" />
</row>
</column>Validate as you go: native markup check src/app.native follows the import closure from disk, and a component file also checks standalone (native markup check src/components/stat-card.native). native check walks everything under src/ in one pass.
Passing children with a slot
Args carry values; a <slot/> carries markup. A template body may mark one insertion point, and the use site's children build there — in the consumer's scope, so they see the model paths and loop variables where the <use> is written:
<template name="empty-state" args="title">
<column main="center" cross="center" gap="8" padding="24" role="group" label="{title}">
<text foreground="text_muted">{title}</text>
<slot/>
</column>
</template>
<column grow="1">
<use template="empty-state" title="No results yet">
<button variant="primary" on-press="refresh">Refresh</button>
</use>
</column>This is the container-component pattern: the template owns the frame, the caller owns the content. The full rules (one slot per body, children without a slot are an error, ids hash as if inlined) are in Native UI § Components.
Put component logic in the core
Templates own structure, not state. Keep state transitions in update, expose reusable derived values as exported TypeScript helpers, and bind those values into the component. A component never runs an app callback while the view builds.
When the closed markup vocabulary does not expose a lower-level engine capability yet, there is no app-side escape element or required Zig file. Use the nearest built-in composition and track the missing grammar admission. Toolkit extensions remain an SDK implementation concern.
Theming your component
Your component themes the same way the built-ins do: through the token system, never through hardcoded values.
In markup, the style attributes (background, foreground, border-color, radius, ...) are token references — the stat card above says background="surface", not a hex value. References resolve against the app's live tokens on every rebuild, so the card follows dark mode, a theme-pack switch, and every override with zero component code. Unknown token names are check/compile errors.
State washes follow the built-in controls' behavior. Markup components customize their token references and compose the appropriate control kinds. A Zig view extension may use ElementOptions.style for a state-specific visual the token vocabulary does not expose; keep that exception local to the extension.
And the parts of your component that are built-in controls stay themed for free: the <button> inside the empty-state template wears controls.button_primary from the active theme, the text leaves read the typography rungs, the focus ring follows the token geometry. A template composes primitives, so it inherits the whole register through them — retheme the app and your components move with it. That inheritance is the reason to reach for tokens before props: a component that takes a color arg has opted out of every future theme.
Distributing components across an app
Component files live wherever you like under the markup root (the root view file's directory) — src/components/ is the convention the tooling writes to, and subdirectories work (<import src="components/cards/stat-card.native"/>). Paths are relative to the importing file, imports are transitive, and everything must stay under the markup root. Cycles are reported with the chain spelled out, and the same template name defined in two files is an error naming both sites — imports never shadow silently.
The generated TypeScript app wiring handles both engine paths:
- The runtime interpreter re-resolves imports from disk on hot reload, so
native devpicks up edits to component files, not just the root view. - Release and mobile builds embed every
.nativefile undersrc/except the root. App code does not maintain an embed registry.
Use, eject, or build
Three moves, in order of preference:
Theme it. Every built-in reads the token register, and theme packs, overrides, and full design systems reach every visual decision — colors, radii, control metrics, state washes, type. If your need is "the stepper, but in our palette," that is a token change, not a component.
Eject it. When you need to own a composite's shape — reorder its parts, change its structure, grow it a feature — native eject component <name> writes the library composite's canonical source into src/components/, and from then on it is your code: edit freely, SDK updates never touch it. Markup templates are reached through <use template="...">, never a new element name, so the built-in element keeps working at unmigrated call sites. Ejecting twice errors instead of overwriting your edits. Engine controls (buttons, text fields, tabs, ...) are deliberately not on the menu: their behavior lives in the runtime, and the way to change them is the token system, not a fork.
Build it. When the library has no shape for it, compose one from markup primitives — the stat card and empty-state above. Your component then themes, tests, and automates exactly like a built-in, because it is made of the same parts.