Server-driven UI
Syntara as a JSON contract: a schema per component, a validator, and one web renderer.
Some apps don’t build their screens into the app. The server sends each screen as JSON and the app draws it, so a new layout reaches people without an app release. For those apps, a design system also has to exist as a schema: which components a server may send, with which props, and what a client does with anything it doesn’t know.
@syntara/sdui is that schema for Syntara. It’s generated from the same meta.json files as these docs, so the contract and the components can’t drift apart. It comes with a validator for the server and one reference renderer for the web. There is no native renderer (ADR-019).
Try it
The editor holds the document a server would send, and the screen is what <SyntaraScreen> draws from it. Change the tenant in the toolbar: the screen takes on the new brand, and the JSON stays the same. Then use Break it to see what the validator and the renderer do with a bad document.
Savings account ending 4821
Good morning, Priya
- Available balance
- ₹1,84,250
- Spent this month
- ₹32,480
- Reward points
- 2,140
Groceries
Today, 09:12 · UPI
−₹1,240
Salary credited
1 Oct · NEFT
+₹96,500
Electricity bill
29 Sep · Autopay
−₹2,318
Validator
Strict, on the server · validateScreen
Valid
Renderer
Tolerant, on the client · onIssue
Drew the screen
Host
The app decides what to do · onAction
The validator is strict because it runs on the server, before a screen ships. The renderer is tolerant, because a server may already speak a newer version than the app on someone’s phone. So an unknown component with a fallback fails validation, but a client still draws its fallback. A missing accessible name or an unsafe link fails both, and the client shows its own fallback screen instead.
Direction follows the language of the copy. Each example says "locale": "en-IN", so its screen runs left to right even when the client around it is right to left. English copy in a right-to-left layout would be reordered by the browser: a minus sign or a final full stop moves to the other end. Delete the locale line from the JSON and the screen follows the client instead: switch to right to left and the layout mirrors. A server that knows its reader sends Arabic copy with an Arabic locale.
'use client';
import { ThemeScope } from '@syntara/react';
import { SyntaraScreen } from '@syntara/sdui/react';
<ThemeScope theme="vela" scheme="dark" locale="en-IN">
<SyntaraScreen
document={json}
onAction={(action, { nodeType, nodeId }) => route(action)}
onIssue={(issue) => log(issue.code, issue.path, issue.message)}
fallback={<UpdateTheApp />}
/>
</ThemeScope>The rules
Each rule is written out in full in the package README.
- Data, not code. A node is a type, props, text, slots and an action. Functions, styles, class names and markup never cross the wire. A Button gets an
actioninstead ofonPress, and strings are always drawn as text. Props come from meta. - Two kinds of action.
navigateto anhttps://URL or an app path, or aneventwith a name and a flat payload for the host to handle. Anything else, includingjavascript:, is rejected. Actions. - No brand in a screen. A document has no
theme,tenant,brand,schemeordensity. The client that draws it decides those, so one document works in every tenant. No brand in a screen. - Accessibility is in the schema. A Button needs text or an
ariaLabel. A Link needs text. A ProgressBar or Meter needs a label. Badge, Tag and Alert need words, so status is never colour alone. A document that breaks one of these is invalid. Accessibility is part of the contract. - Versions.
schemaVersionis semver, and a client supports one major. Minor versions only add. Removing or renaming anything is a major. Versions. - Unknowns. A client ignores unknown props and fields, drops unknown values so the default applies, and draws a node’s
fallbackfor an unknown type. It never draws raw JSON. Unknowns. - Deprecations. A prop or value that was deprecated before this major of the schema never enters it. One deprecated inside a major stays until the next major and is reported when used. That’s why Button’s
variant: "danger"isn’t in the schema. How a deprecation reaches the schema, and Governance.
No node takes these props:
| Prop | Why not |
|---|---|
className | Class names are the web client's styling hook, and a screen can't know them. Use variant, tone and size, which map to tokens. |
style | Raw CSS would bypass the tokens. Use variant, tone and size; layout gaps are token names on Stack and Inline. |
key | React's list key. The node's id does that job on the wire. |
ref | A handle to a DOM element on the client. Nothing on the wire can hold one. |
What’s in the slice
Schema 1.0.0 has 26 node types: 22 drawn by 15 of the 53 Syntara components, and 4 of the schema’s own for layout and text. By maturity: 21 beta and 5 alpha. A node from a component takes the component’s maturity; the schema’s own nodes are alpha. Icons are referenced by name, and 243 icons from @syntara/icons are on the list.
| Node | Drawn by | Maturity | Accessibility rules |
|---|---|---|---|
Button | Button | beta | button-namebutton-icon-size-name |
Link | Link | beta | link-text |
Badge | Badge | beta | badge-text |
Tag | Tag | beta | tag-text |
Alert | Alert | beta | alert-text |
Card | Card | beta | None |
CardHeader | Card | beta | None |
CardTitle | Card | beta | card-title-text |
CardDescription | Card | beta | None |
CardAction | Card | beta | None |
CardContent | Card | beta | None |
CardFooter | Card | beta | None |
StatTile | Stat Tile | beta | stat-tile-text |
StatTileGroup | Stat Tile | beta | None |
Avatar | Avatar | beta | avatar-name |
Separator | Separator | alpha | None |
ProgressBar | Progress Bar | beta | progress-name |
Meter | Meter | beta | meter-name |
EmptyState | Empty State | beta | empty-state-title |
Eyebrow | Eyebrow | beta | eyebrow-text |
Amount | Amount | beta | None |
IconTile | Icon Tile | beta | icon-tile-image-alt |
Stack | The schema | alpha | None |
Inline | The schema | alpha | None |
Text | The schema | alpha | None |
Heading | The schema | alpha | heading-text |
What’s out
Inputs, overlays, tables and charts. They hold state (a value, an open menu, a sort order) and send events back as it changes. That needs a fuller action model than navigate and event: state bindings, validation and submission. This slice doesn't define one.
| Category | Not on the wire |
|---|---|
| Layout | Theme Scope |
| Actions | Toggle Group |
| Inputs | Calendar, Checkbox, Chip, Combobox, Date Picker, File Upload, Radio Group, Search Field, Select, Slider, Switch, Text Area, Text Field |
| Overlays | Alert Dialog, Command, Dialog, Menu, Popover, Sheet, Tooltip |
| Feedback | Skeleton, Spinner, Toast |
| Display | Accordion, Kbd, Person Chip |
| Navigation | Breadcrumbs, Pagination, Sidebar, Steps, Tabs |
| Data | Area Chart, Bar Chart, Chart, Data Table, Sparkline |
38 of 53 components aren’t in the slice. From the components that are in it, these parts stay out:
CardMedia: Holds an image, video or device mock. The slice has no image or media node yet, and the glow behind it is proven for media only.AvatarGroup: Its '+N' label is a function (moreLabel) whose default is English only, so a screen in another language would announce English. Send Avatars in an Inline.
Native tokens
The token build (pnpm tokens) also writes two files per tenant: SyntaraTokens.kt for Jetpack Compose and SyntaraTokens.swift for SwiftUI. They hold the same colours, spacing, radii, type and motion as the CSS, light and dark. The build reads the colours back out of each file and checks every contrast pair on them. On this page, the same check runs again when the site is built:
| Tenant | Compose (SyntaraTokens.kt) | SwiftUI (SyntaraTokens.swift) |
|---|---|---|
| Vela | 118 of 118 pass | 118 of 118 pass |
| Harbor | 118 of 118 pass | 118 of 118 pass |
| Qamar | 118 of 118 pass | 118 of 118 pass |
| Care | 118 of 118 pass | 118 of 118 pass |
| Haat | 118 of 118 pass | 118 of 118 pass |
| House (this site) | 118 of 118 pass | 118 of 118 pass |
What that does and doesn’t prove:
- The Swift files were type-checked with
swiftcagainst the macOS SDK. They have not been built for iOS. - The Kotlin files have not been compiled. No Kotlin compiler was installed, so tests check their structure instead.
- No native components exist. These files give an app team Syntara’s tokens, not its buttons.
The full notes, including units, touch targets and what CSS has that native doesn’t, are in README-native.md.
What this is not
Not a server-driven UI framework: no fetching, caching, state or analytics. No native renderer: <SyntaraScreen> is web only, and an Android or iOS client would have to be written against the schema. And not every component: inputs, overlays, tables and charts aren’t in the slice.