React renderer for HotLayout — server-driven UI (SDUI). A screen is assembled in a visual builder and published as JSON; your app draws it with one component, and the next publish changes the screen without a release.
npm install @hotlayout/reactReact 18.2+ or 19 · ESM with types · no other dependencies · Apache-2.0
import { HotlayoutScreen } from '@hotlayout/react'
import type { ThemeContract, UiDocument } from '@hotlayout/react'
export function Rewards({ layout, theme }: { layout: UiDocument; theme: ThemeContract }) {
return (
<HotlayoutScreen
document={layout} // the layout document, parsed JSON
theme={theme} // the project theme, parsed JSON; omit it to use the built-in one
data={{ 'user.firstName': 'Ada', 'user.points': 1250 }}
collections={{ items: [{ name: 'Coffee', price: 300 }] }}
locale="en"
style={{ minBlockSize: '100dvh' }}
onAction={(action, source) => {
console.log('the user asked for', action.type, action.params, 'on', source.nodeId)
}}
/>
)
}That is the whole integration: give the component a document, a theme and data, and tell it what your app does with actions.
- One component.
<HotlayoutScreen>draws the document, keeps the screen stack, tabs, forms and modal sheets, and hands your app the actions. - Server rendering. No
windowon import, no network, no globals; works in a Next.js App Router Server Component without a wrapper. - Right-to-left scripts. Direction follows the language that is shown; layout, icons and arrow keys mirror.
- Accessibility built in. Real buttons and inputs, focus management, WAI-ARIA tabs and dialogs.
- Untrusted input. The document and the theme are parsed again where they become CSS, an address or a key — see Security model.
- Nothing but React. No runtime dependencies besides
reactandreact-dom; styles are scoped to the component's own root. - One document, two platforms. The same JSON is drawn in a mobile app by
the Flutter package
hotlayout_flutter.
The builder and the product: hotlayout.com.
- Getting the document, the theme and the data
- What the renderer does on its own
- API — props, reports, other exports
- Actions and navigation — analytics, the screen-shown event, your router, pressable cards
- Screen layout — scrolling, the app bar, the bottom zone, safe areas, adaptive layout by width
- Data — templates, numbers, money and plurals; list records; writes
- Forms
- Theme · Fonts · Languages and right-to-left
- Server rendering and Next.js
- Styles and isolation
- Security model
- Accessibility
- Versions — what each schema minor added
- Placements — named places that show what the admin chose
- Updating a screen without a release
- Limitations
Where the two JSON files come from is up to you — bundle them, fetch them on the server, or load them by URL so that a republished screen reaches users without a deploy.
document — the published layout of one document. It is a static JSON
file, readable without a token:
https://firebasestorage.googleapis.com/v0/b/<bucket>/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Fdocuments%2F<documentId>%2Fpublished.json?alt=media
- The object path is
orgs/{orgId}/projects/{projectId}/documents/{documentId}/published.json, URL-encoded. - The project settings in the HotLayout admin (Delivery) show this address as a template with your bucket and ids filled in.
- If the project delivers by webhook, your backend receives the same JSON on every publish and serves it from wherever you like.
theme — the published theme of the project, one file for all its
documents, at the same place:
…/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Ftheme%2Fpublished.json?alt=media.
Keys of data and collections — the project's data dictionary: the
Data dictionary section of the project in the admin, where it can be
downloaded as data-contract.json (the webhook body carries the same list as
dataContract).
| In the dictionary | In your props |
|---|---|
paths |
the keys of data (user.firstName) |
collections[].path |
the keys of collections (items) |
collections[].item |
the prefix of the record fields inside a list (item.name → the name field of a record) |
Two notes on the example:
- The examples call the variable
layout, notdocument: inside a componentdocumentwould shadow the browser's owndocument. - Give the root a height (
style={{ minBlockSize: '100dvh' }}). The root is a flex column that takes the size you give it, the screen background reaches its bottom, and modal sheets sit on the bottom edge of its area (withscroll="page"— of the part of the root that is visible in the window, see Scrolling and the app bar).
| Screens | Keeps the screen stack. navigate (to a screen of the same document) and navigate.back work out of the box, including the AppBar back arrow. |
| Modal screens | presentation: 'modal' is drawn as a bottom sheet over the previous screen, with dialog semantics: focus moves in, Esc or a tap on the scrim goes back. |
| Local state | Tabs, accordions and choice groups switch without the host. The document only says the initial state. |
| Compatibility | Applies the contract's compatibility policy: an unknown widget draws its children, an unknown token is skipped or replaced by the field default, a document of another schema major is refused loudly. |
| Theme fallbacks | An unreadable theme never blanks the screen: the built-in theme is used and you are told through onReport. |
| Images | An asset token without a URL, and a URL that failed to load, are drawn as a neutral placeholder — never as the browser's broken-image icon. |
| Direction | Sets dir on its own root from the language the document is shown in (CLDR via Intl.Locale) and mirrors directional icons. |
| Motion | A node with motion drifts (float) or breathes (pulse) forever; it only paints, so neighbours never move. The position follows the contract's sine formula, computed by the browser from one animated angle. The system "reduce motion" setting and the server render leave the node at rest. |
| Blur and glass | A modal screen with backdropBlur blurs everything under the layer (the dimming lies over the blur); a node with style.backdropBlur blurs what is painted under its box, within its radius. Both are backdrop-filter. |
| Touch targets | Buttons look exactly as designed (a button with sm padding is 40 px tall) but are pressable in an area of at least 48 × 48 px, without shifting neighbours. |
The contract between the builder and the readers (screen-schema.json,
theme.json) is the single source of truth, so the renderer never invents
anything: spacing, colours and typography come from the theme, layout rules
come from the contract.
| Prop | Type | |
|---|---|---|
| What to draw | ||
document |
UiDocument |
Required. The layout document. Not validated against the schema — the builder did that — but never trusted either. |
theme |
ThemeContract |
{ themeContractVersion, defaultThemeId, themes } — a project's theme/published.json. Omit to use the built-in starter theme. See Theme. |
themeId |
string |
Which theme of the set to use, e.g. 'dark'. Unknown id → the set's default. |
entry |
string |
Public entry-point name. Unknown name → the initial screen. Changing entry restarts the stack. |
assets |
Record<string, string> |
URLs for asset tokens (logo, banner, …). A token without a URL is drawn as a placeholder. |
| Data | ||
data |
Record<string, unknown> |
Values for bindings, as a flat map keyed by data-contract path: { 'user.points': 1250 }. See Data. |
collections |
Record<string, object[]> |
Lists for Repeater widgets, keyed by collection name. |
onWrite |
(path, value: string | boolean | number) => void |
The user changed a data-bound input, or pressed a write action. The path is named by the document — see Writes and Forms. |
writablePaths |
string[] | (path) => boolean |
The data paths the document may write to. With it, onWrite is called for these paths only; without it, for any path of the dictionary form. |
autofill |
boolean |
Whether text fields may ask the browser for autofill hints. Default true; false sets autocomplete="off" on every field. See Forms. |
| Language and time | ||
locale |
string |
The user's locale as the platform gives it (ar-EG, pt_BR). The renderer finds the document's translation itself — see Languages. |
dir |
'ltr' | 'rtl' |
Overrides the direction derived from locale. |
timeZone |
string |
IANA zone the bound dates are written in. Defaults to the zone of whoever renders. Set it when you render on a server — see Server rendering. |
now |
number | Date |
The moment the screen is rendered at (epoch milliseconds or a Date); daysUntil and countdownPart count from it. Defaults to the clock of whoever renders, read on every render; a screen that shows a countdown part then ticks, and with your now it does not (you own the clock). Set it when you render on a server, and in tests and previews — see Sentences, numbers, money and plurals. |
labels |
{ back?: string } |
Texts that belong to the renderer: the accessible name of the back arrow. Translate it with your UI. |
| Events | ||
onAction |
(action, source) => unknown |
Actions the renderer does not perform itself — see Actions and navigation. source is { nodeId, screenId, record? }. A returned promise keeps a drag-to-confirm button busy until it settles. |
onTrack |
(event, source) => void |
The analytics event of a press, once per press and before the action — see Analytics. |
onHaptic |
(kind, source) => void |
A request for a tactile response, once per press and before onTrack and the action — see Haptic feedback. The renderer never vibrates by itself. |
onNavigate |
(event) => boolean | undefined |
Called before the renderer changes screens. Return false to cancel and navigate yourself — see Driving navigation with your router. |
onScreenChange |
(screen) => void |
The screen-shown event: once per show of a screen, the first one included — see Screen shown. |
onReport |
(report) => void |
What the renderer refused, ignored or replaced — see Reports for the list, and for what is not reported. Without a handler the reports go to console.warn. |
| Trust and failures | ||
resolveImageUrl |
(url, context) => string | null |
Your decision about every image address named by the document or by bound data. Default: absolute https: only. See Images. |
renderIncompatible |
(verdict, version) => ReactNode |
What to show instead of a document of an incompatible major. Default: a short role="alert" message. See Versions. |
renderError |
({ screenId, message }) => ReactNode |
What to show instead of a screen that threw while rendering. Default: "This screen is not available right now." The error never reaches your application — see Security model. |
| Placement on your page | ||
className, style |
Applied to the root element. Use them to size and place the screen. | |
injectStyles |
boolean |
Render the package stylesheet next to the screen. Default true. See Strict Content-Security-Policy. |
scroll |
'page' | 'screen' |
Who scrolls: your page (default) or the block you gave a height to. See Scrolling and the app bar. |
chrome |
'sticky' | 'static' |
Only with scroll="page": whether the app bar and the bottom zone stick to the viewport (default) or scroll with the page. |
stickyOffset |
number | string |
Only with scroll="page" and chrome="sticky": the top the app bar sticks at when your site has a fixed header above the block. |
stickyBottomOffset |
number | string |
The same for the bottom zone: the bottom it sticks at when your site has a fixed bar below the block. |
safeAreaTop |
number | 'env' |
The top safe-area inset. Default 'env'. See Safe areas. |
trapFocus |
boolean |
Keep Tab inside a modal layer at all times. Default false: only a dismissible layer cycles Tab, and Tab may leave a layer that waits for an action. |
safeAreaBottom |
number | 'env' |
The bottom safe-area inset under the bottom zone. Default 'env'. See Safe areas. |
initialWidthClass |
'compact' | 'medium' | 'expanded' |
The width class to draw until the root element has been measured (default 'compact'). See Adaptive layout by width. |
The stack is tied to document.documentId and entry, not to object identity:
re-parsing the same published file on every render does not throw the user
back to the first screen.
onReport receives an object with a kind. Every one of them is something the
renderer already handled; the report is for your logs.
kind |
What happened |
|---|---|
incompatible-document |
The document was not drawn: its schema major differs from the reader's, or the version is not readable. Carries documentVersion, readerVersion, reason, relation. |
newer-minor-document |
The document comes from a newer minor: it is drawn, unknown widgets and tokens are skipped by policy. |
theme-fallback |
The theme could not be read; the built-in one is drawn. |
unknown-theme-id |
themeId is not in the set; the set's default is used. |
invalid-theme-values |
Some theme entries did not have the promised form and were ignored: core tokens fall back to the built-in theme of the same brightness, custom tokens become unknown. paths lists up to 20 of them. |
unknown-entry |
entry names an entry point the document does not have; the initial screen is shown. |
no-screen |
The document has no screen to start from. Nothing is drawn. |
ignored-input |
A part of the document, of the bound data or of a request to the host did not have the shape the contract promises and was ignored (where, reason). The rest of the screen is drawn. |
render-error |
A screen threw while rendering and was replaced by the neutral notice. Your application is not affected. |
What is reported, and what is not. A report means the renderer met something that should not be there. A screen that simply has nothing to show yet is not that, and stays silent:
| Reported | Not reported |
|---|---|
A document, a theme, an entry or a themeId that could not be used as given (the rows above ignored-input). |
A bound value that is missing, null or an empty string: the binding's fallback is drawn. |
An image address the policy refused — http:, data:, blob:, a relative address, a script address, or null from resolveImageUrl: ignored-input with where: 'image address “…”'. |
An image with no address at all (no value in the data, an empty address): the placeholder is drawn. |
A part of the document or of the bound data that does not have the promised shape: ignored-input. |
An asset token you gave no URL for in assets: the placeholder is drawn. |
A write to a path outside writablePaths: ignored-input. |
An image whose allowed address failed to load in the browser: the placeholder is drawn. |
A screen that threw: render-error. |
Reports are delivered in the browser (in an effect, once each), never during server rendering.
| Export | |
|---|---|
checkDocumentVersion(version) |
The contract's version rule as a plain function — see Versions. |
loadDocument(url, options?), loadTheme(url, options?) |
Fetch a published file — see Updating a screen without a release. A file of the wrong kind (a theme where a document is expected, an HTML page, an unrelated object) is an error too. Errors are HotlayoutLoadError with url and status. |
runStandardAction(action, handlers) |
The optional helper that performs openUrl, deeplink and close — only those that passed the check, only with the handlers and app-link schemes you give it. Returns true when it did. See Actions and navigation. |
openInNewTab(url) |
A ready openUrl handler: window.open(url, '_blank', 'noopener,noreferrer'). The renderer never calls it; you pass it to runStandardAction. |
paramFormProblem(form, value), ACTION_CATALOG |
The contract's check of an action parameter and the catalog of named actions — plain data and a plain function, no React, usable on the server. |
HotlayoutPlacement, usePlacement(name, options) |
A named place of the app that shows the document the admin put there — see Placements. |
readPlacementMapText(text), readPlacementMapBytes(bytes), resolvePlacement(map, name), pickPlacementMap(input), placementShowing(resolution, document?), placementEvent(previous, next), loadPlacementMap(url, options?) |
The rules behind placements as plain functions — no React, usable on the server. |
resolveTheme(contract, themeId?) |
The theme the renderer would draw with, plus the fallbacks it took. |
resolveLocale(requested, available, source) |
Which language a document is shown in for a given user locale — the renderer's own rule, for the parts of your UI that must speak the same language as the screen. See Languages. |
BUILT_IN_THEME_CONTRACT |
The built-in light and dark themes. |
HOTLAYOUT_CSS |
The stylesheet as a string (also shipped as @hotlayout/react/styles.css). |
READER_SCHEMA_VERSION, READER_THEME_VERSION |
What this build understands. |
| Types | UiDocument, UiNode, ScreenEntry, ThemeContract, Theme, HotlayoutScreenProps, HotlayoutAction, StandardAction, InvalidActionParam, TrackEvent, ActionSource, NavigationEvent, ScreenInfo, ScreenShown, HotlayoutReport, VersionVerdict, DataScope, Collections, LoadOptions, LocaleResolution, ResolveImageUrl, ImageUrlContext, … |
Anything not listed here is internal and may change in a minor release.
A button, a list row or the back arrow carries an action:
{ type: string, params?: Record<string, string> }. The contract names nine
of them and says who performs each; any other type is yours.
type |
Performed by | Parameters |
|---|---|---|
none |
the renderer — does nothing, never reaches you | — |
navigate |
the renderer; to a screen the document does not have — you | screenId |
navigate.back |
the renderer; on the first screen — you | — |
navigate.section |
the renderer; to a screen the document does not have — you | screenId |
setValue, toggleValue |
the renderer — a write, never reaches onAction; see Write actions and choice |
path, a value |
openUrl |
you | url — an https:// address |
deeplink |
you | url — an app link (myapp://offers/42) |
close |
you — the renderer does not touch the screen stack | — |
What reaches onAction:
- The
typeexactly as written in the document, andparamsas a plain map of strings. A parameter that is not a string, and a key such as__proto__orconstructor, is dropped and reported. - For
openUrl,deeplinkandclose, a verdict on the parameters:action.standard({ type: 'openUrl', url },{ type: 'deeplink', url, scheme }or{ type: 'close' }) when they passed the check of the contract, oraction.invalid({ param, problem }) when one did not. Never both; your own actions have neither. - A parameter may come from the data (
paramBindings). It is read from the data of the node at the moment of the press, and an address that does not pass the check is not replaced by the literal fallback — the action isinvalid.
The renderer opens nothing by itself — no window.open, no link with an
address from the document. runStandardAction is an optional helper you call
from your own onAction; it performs only action.standard, only with the
handlers you pass, and an app link only when its scheme is in the list you
named:
const ROUTES: Readonly<Record<string, string>> = { checkout: '/app/checkout', support: '/app/support' }
function handleAction(action: HotlayoutAction, source: ActionSource): void {
// `openUrl`, `deeplink` and `close` arrive checked, and YOU perform them: the renderer opens nothing by itself.
// The helper runs only what passed the contract's check; an app link only with a scheme you named.
const done = runStandardAction(action, {
openUrl: openInNewTab, // or your own: a router, an in-app browser
deeplinkSchemes: ['myapp'], // without this list a `deeplink` is never performed
openDeeplink: (url) => {
router.push(url)
},
close: () => {
router.push('/app')
},
})
if (done) return
if (action.invalid !== undefined) {
// `action.params.url` of an invalid action is a raw string from the document: never open it.
// A catalog action whose parameter failed the check (a bound address that is not `https:`): never run it.
console.warn('layout action refused', action.type, action.invalid.param, action.invalid.problem)
return
}
switch (action.type) {
case 'submit': {
// Which record: the list row the button is in, or the row that opened this screen.
const offerId = source.record?.['offer.id']
void redeem(typeof offerId === 'string' ? offerId : action.params?.offerId)
break
}
case 'share':
void navigator.share({ url: location.href })
break
case 'navigate': {
// A screen this document does not have: it is yours. Match the name against YOUR routes —
// never build a path from `action.params.screenId`, it is text from a document.
const route = ROUTES[action.params?.screenId ?? '']
if (route !== undefined) router.push(route)
break
}
case 'navigate.back': // "back" on the first screen: the user leaves the flow
router.push('/app')
break
default:
console.warn('unhandled layout action', action.type, 'from', source.nodeId)
}
}Action types are free-form strings agreed between whoever builds the screens and your app. An action your app does not know should be ignored, not thrown on: the screen may be newer than the app.
Trust. The address comes from a document and from your data, both of which
travel over a network. The renderer checks it against the form of the contract
(an https:// address without credentials, an app link whose scheme is not on
the contract's list of forbidden ones), but you open it, and the list of
app-link schemes that may be opened is yours: without deeplinkSchemes
runStandardAction never opens an app link.
Rules to keep:
- Open only
action.standard.action.params.urlof an action markedinvalidis a raw string from the document — never open it. - A parameter read from data replaces the literal only whole: there is no
concatenation, so a document cannot glue your data to a foreign address. The
target of
navigate(screenId) is never read from data. Do not build a route fromaction.params.screenIdeither; match it against your own list. - The form says nothing about which hosts are fine: an
https://address of an internal name (intranet.corp) passes it, and addresses written as an IP never do. Which domains may be opened is your decision — keep a list. - A
deeplinkwith a scheme from your list isstandard, but its path and query stay untrusted:myapp://open?url=javascript:…is judged by your router.tel:,sms:,mailto:andmarket:pass the form too; only yourdeeplinkSchemeskeeps them out. Without the helper, keep such a list yourself. - A key held down is one press (the auto-repeat is ignored), but a double click is two actions: protect a payment on your side.
- A package older than 0.9 receives
openUrlanddeeplinkas an unknown action with rawparamsand no verdict. If you already had your ownopenUrlbranch, update the package before managers start using the catalog.
An action may carry track — { event, params? }, a name of letters, digits
and underscores (at most 40) and flat string parameters. It arrives in
onTrack:
- once per press and before the action — before the renderer navigates
(even when your
onNavigatethen cancels the step) and beforeonAction; the only thing that comes earlier is the haptic request of the same press; - for
nonetoo — a button that only measures; - separately from the action: the action you get in
onActiondoes not carry it.
The renderer sends nothing anywhere itself.
export function Tracked({ layout }: { layout: UiDocument }) {
return (
<HotlayoutScreen
document={layout}
onAction={handleAction}
// A button with `action.track`: once per press, BEFORE the action itself (and for `none` too).
onTrack={(event, source) => {
analytics.track(event.event, { ...event.params, node: source.nodeId, screen: source.screenId })
}}
// One call per screen shown: the first one, a forward step, a return. Not on re-renders, not on the server.
onScreenChange={(screen) => {
analytics.track('screen_view', { screen: screen.screenId, cause: screen.cause, depth: String(screen.depth), entry: screen.entry ?? '' })
}}
/>
)
}An action may carry haptic — one of light, medium, heavy, selection,
success, warning — next to track. It is a request to your app: the
renderer never vibrates by itself. It calls onHaptic(kind, source):
- once per press, first — before
onTrackand before the action of the same press (the order is haptic, track, action), and also when the action isnoneor youronNavigatecancels the step; - not for a disabled button, a held key's auto-repeat, a press that reached a control inside a card instead of the card, or a drag to confirm that has not completed (it asks when the thumb is dragged to the end, together with the action);
- not for an unknown word, and not at all for an action without the field — the renderer adds no default, a section switch included;
- separately from the action: the action you get in
onActiondoes not carry it.
Map the word to what your platform can do. In a browser that is often
navigator.vibrate, which exists on Android and not on iOS Safari; without the
prop nothing happens.
// One call per press that asks for it, before `onTrack` and the action. The renderer vibrates nothing itself.
const PATTERNS: Readonly<Record<HapticWord, number | number[]>> = {
light: 8,
medium: 16,
heavy: 28,
selection: 6,
success: [10, 40, 10],
warning: [24, 60, 24],
}
export function WithHaptics({ layout }: { layout: UiDocument }) {
return (
<HotlayoutScreen
document={layout}
onAction={handleAction}
onHaptic={(kind) => {
// Not every browser can: `vibrate` is absent on iOS Safari, and the user may have turned vibration off.
if ('vibrate' in navigator) navigator.vibrate(PATTERNS[kind])
}}
/>
)
}onScreenChange is the screen-shown event. The shown screen is the top of the
stack, and the event happens once whenever the top became another frame:
cause |
When |
|---|---|
entry |
the first screen; also after you changed entry or the document (the stack is built again) |
forward |
navigate put a screen on top — a modal sheet too |
back |
navigate.back (or closing a sheet) removed the top: the screen under it is shown again |
switch |
navigate.section made the named screen the only one in the stack (depth is 1, record is null); pressing the item of the screen that is already shown sends nothing |
The fields of the event:
| Field | |
|---|---|
screenId, name, presentation, documentId |
The screen that is shown. |
entry |
The name you passed, only if the document has it as an entry point; otherwise null. |
cause |
One of the four above. |
depth |
The stack length after the change; the first screen is 1. |
record |
The record the screen was opened with, a flat map of paths, or null. |
modalPlacement |
'bottom' or 'center' — where a modal layer lies. Only when the screen named it: a sheet is the default, so a modal screen that did not name it has no key. |
statusBarIcons |
'light' or 'dark' — a request for the icons of the system status bar, only when the screen named it. The renderer never touches the status bar; whether to honour it is yours. |
A word the renderer does not know in the last two fields is not handed over.
The event is not fired on:
- a re-render, new data or locale, a new object of the same document;
- an action handed to you (
openUrl,close, "back" on the first screen); - a navigation that did not happen (the screen is missing, your
onNavigatecancelled it).
There is no "hidden" event. On the server nothing is fired; hydration fires
the first one. In React StrictMode it is still one.
onNavigate runs before every screen change the renderer is about to make.
Return false and the renderer stays put; entry then says which screen to
show. Entry points are the public, stable names of screens
(document.entryPoints), meant for exactly this: URLs, push notifications,
banners.
export function RoutedRewards({ layout, entry }: { layout: UiDocument; entry: string }) {
return (
<HotlayoutScreen
document={layout}
entry={entry} // an entry-point name from your route, e.g. /rewards/[entry]
onNavigate={(event) => {
if (event.kind !== 'push') return undefined // let the renderer go back by itself
const name = Object.entries(layout.entryPoints ?? {}).find(([, screenId]) => screenId === event.to.screenId)?.[0]
if (name === undefined) return undefined // a screen without a public name: the renderer shows it
router.push(`/rewards/${name}`) // your router owns the URL…
return false // …so the renderer stays put, and the new `entry` opens the screen
}}
onScreenChange={(screen) => {
console.log('shown', screen.screenId, screen.presentation)
}}
/>
)
}An entry name the document does not have opens the initial screen (and reports
unknown-entry): names outlive documents.
The renderer does not scroll your page. If screens are taller than the
viewport, scroll to the top in onScreenChange.
action (and an optional label) on a Column, Row, Container or Stack
makes the whole box a button. For your handlers it is the same action a
Button would send — analytics first, then the action, with the record of the
list row. What differs is on the screen:
- The box is
role="button"and comes in the order of focus before the controls inside it. Enter and Space press it; the auto-repeat of a held key does not. - A press goes to the innermost control under the point, and its ancestors get nothing — a button inside a card does not also press the card.
- The accessible name is the
labelfirst and then the text of the content.
A pressable container or a list row may also carry longPress (an action of the
same form) and longPressLabel (its name). There is no new prop and no new
handler: a long press arrives at the very same onHaptic, onTrack and onAction
as a press, in that order, once, with the record of the row. longPress is not read
on a Button, an IconButton or a navigation item.
- The hold. A pointer that stays down on the node for 450 ms and has not moved
more than 10 CSS pixels runs the action while it is still down. A longer move
before that cancels the long press and the press (the finger is scrolling).
After it fired, the click that follows the release is swallowed:
actiondoes not run. The pressed look stays while the finger is down. A node withoutlongPressis not changed: a long hold released in place is a press, as always. - One receiver. The touch goes to the innermost control under the finger. A button inside a card keeps its press and the card gets no long press; a row with a long press of its own inside a card is the receiver itself.
- Keyboard.
ContextMenuorShift+F10on the focused node runs the long press at once.EnterandSpacestay the press ofaction. A container with only alongPressis a control all the same: a tab stop,role="button", named by its content, and its click does nothing. - Assistive technology. The name of the action is a hidden element the node is
described by (
aria-describedby), andaria-keyshortcuts="ContextMenu Shift+F10"lists the keys. The name is not part of the name of the control, and a reader that does not announce descriptions still has the keys. - The context menu of the browser is cancelled on such a node (the browser of a phone opens it on the same hold). A right mouse button is not a long press and does nothing.
A screen has three zones: the app bar (the first child of the screen root,
when it is an AppBar), the body (everything else) and the
bottom zone.
- The app bar stands; only the body scrolls, and only vertically.
- The background of the root lies under all zones and does not move.
- Padding of the root is shared: the app bar gets the top and the sides, the
body gets the sides, the bottom and — instead of the top — the screen's
gap.
The rules and numbers of the zones are data of the contract
(x-style.layout.screen, bottomZone and navBar in screen-schema.json).
Who scrolls is your decision, not the document's:
scroll |
What it means | What you do |
|---|---|---|
"page" (default) |
The block has no height of its own: it grows with its content and your page scrolls. | Nothing. The app bar sticks to the top of the viewport — see chrome below. |
"screen" |
The root fills the height you gave it and the zones work literally: the body scrolls inside the block. | Give the block a height: 100dvh, a fixed height, a flex remainder. |
With scroll="page", chrome says what the app bar and the bottom zone do:
chrome="sticky"(default) — the app bar sticks to the top of the viewport while the screen is in view, and paints the background of the screen root under itself, so the content travelling beneath never shows through.chrome="static"— the app bar stays the top row of the block and scrolls away with the page. Use it when your site already has its own fixed header.stickyOffset— thetopthe app bar sticks at when your own header is fixed above the block. A number is CSS pixels; a string is a length ('56px','3.5rem') or a CSS variable of yours ('var(--site-header-height)'). Anything else is ignored.- While the app bar is stuck, the elements of the body that take keyboard focus
keep clear of it (
scroll-margin-block-start= the height of the app bar +stickyOffset).
Modal layers. A modal layer lies over the whole page under it, the app bar included (every layer of the stack is its own stacking context).
- With
scroll="screen"its area is the whole block. - With
scroll="page"the block is far taller than the window, so the area is the part of the block that is visible in the window. It is a sticky area, neverposition: fixed: the root clips its content, and that is a boundary of trust. A sheet stands on the lower edge of that part, a window in its middle, and the scrim covers the block. - If you need the exact area, give the block a height of its own
(
scroll="screen").
What an app bar can hold.
- Actions. The app bar draws the first two visible actions; a third and further are not drawn.
- A node in the leading zone. A child with
zone: 'leading'(a button or an icon button) stands in the leading zone instead of the back arrow, whatevershowBacksays, and is not counted among the two actions. Its width is at most half of the bar. A node hidden by its condition gives the zone back toshowBack. - The title.
AppBar.titleAlign: 'start'puts the title right after the leading zone instead of the middle.
Whatever you choose, the renderer never touches the status bar of the system:
the statusBarIcons a screen asks for arrives in onScreenChange for you to
honour (see Screen shown).
The bottom of a screen holds two things, and they never scroll: a navigation bar and a pinned block.
Which child of the screen root stands where is decided by the document order, not by visibility:
| Child of the screen root | Zone |
|---|---|
the first one, when it is an AppBar |
the app bar |
the last one, when it is a NavBar |
the navigation bar |
the last one that is left and has pin: "bottom" |
the pinned block (any node: a button, a row of two buttons, a card with a price) |
| everything else | the body |
- A zone whose node is hidden by a condition does not exist, and nobody takes its place.
- A
pinin any other place, or any other word, is not read — the node is an ordinary child.
Geometry.
- Top to bottom the zone is: the pinned block (with the bottom padding of the root under it), the navigation bar, the safe inset.
- The body ends at the top edge of the zone and finishes with the screen's
gapinstead of the bottom padding, so the last line of content is never hidden under the block. - The navigation bar has no padding of the root: it runs from edge to edge.
The navigation bar.
- It draws the first five visible
NavItems in equal shares, an icon over a one-line label. - It is a
<nav>; every item is a button with the label as its name, and the item whose action isnavigate.sectionto the shown screen carriesaria-current="page". - Pressing an item performs its action like a button does.
navigate.sectionmakes the section the only screen of the stack (the cause of the screen-shown event isswitch), and a modal layer closes with everything on top of it. - A modal layer never draws the bar, but it does draw a pinned block at its bottom.
With scroll="page" the bottom zone sticks to the bottom of the
viewport (position: sticky; bottom: 0):
chromedecides for both zones:chrome="static"leaves the zone as the last row of the block.stickyBottomOffsetmoves the line when your site has a fixed bar below. It takes the same forms asstickyOffset.- A stuck pinned block paints the background of the screen root under itself.
- Elements of the body that take keyboard focus keep clear of the zone
(
scroll-margin-block-end= the height of the zone +stickyBottomOffset).
The renderer does not react to the on-screen keyboard — see Forms.
The renderer adds the safe-area insets itself; both props take 'env'
(default — env(safe-area-inset-top) / env(safe-area-inset-bottom) of the
browser) or a number of CSS pixels you measured. Pass 0 when your host
already leaves the room (it wraps the screen in a safe area, or leaves space
for the home indicator). Anything else — a negative number and NaN for the
top inset included — counts as 0.
safeAreaTop |
safeAreaBottom |
|
|---|---|---|
| Where it is added | Inside the box of the app bar, above its content, so a named background of the bar covers the status bar. Without an app bar, or with one hidden: above the body area. | Under the content of the bottom zone (a pinned block, a navigation bar); the background of the bar reaches the physical bottom. |
| When | With scroll="screen", or while the app bar sticks to the top of the window (scroll="page", chrome="sticky"). |
Only when the screen has a bottom zone. With scroll="page", only while the zone sticks. |
| Never | Inside a modal layer. | — |
The body under the app bar. Screen.props.bodyTop: 'screenTop' starts the
body at the top edge of the screen: the app bar stands over it, takes the
presses of its own box, and the content shows through it. Such a screen adds
no top inset to the content of its body.
One document serves every width: it is built for a phone, and wider classes override it. The renderer measures the width of its own root element — not the window and not the device — and picks a class:
| Class | Width of the root, CSS pixels |
|---|---|
compact |
below 600 |
medium |
from 600 |
expanded |
from 1024 |
The width is not rounded: 599.9 is compact. The renderer resolves the tree
for the class and then draws it like any other document. What a document can
say per class:
| Field | |
|---|---|
propsOn, styleOn |
Override the base: medium on the base, expanded on the base with medium. unset in a layer removes the named fields before the layer is laid on. |
showOn |
The classes a node is shown on (together with its condition visibleIf). The screen root has no showOn: a screen is shown on every width. |
Screen.contentMaxWidth |
Puts the content of the body, of the pinned block, of the app bar and of the navigation bar into a centred column while the backgrounds stay edge to edge. |
Give the root a width. The class follows the width of the element only if the element has a width of its own (a block in a column, a grid cell). A root whose width depends on what it draws — inside a shrink-to-fit parent, or in an area whose scrollbar appears with the height of the content — would go round in a loop: the class changes the content, the content changes the width, the width changes the class. The renderer stops it with a lock; the real fix is a width on the host.
On the server, say the class if you know it. There is no width on the
server, so the markup is compact. Pass initialWidthClass when you know the
width of the place — then the server markup and the first browser frame
already have the final layout. After the first layout the measured class wins;
anything but the three words counts as 'compact'.
/** The class of the place in the words of the document: below 600 CSS pixels `compact`, from 600 `medium`, from 1024 `expanded`. */
function classOfPlace(width: number | undefined): WidthClass | undefined {
if (width === undefined) return undefined
return width >= 1024 ? 'expanded' : width >= 600 ? 'medium' : 'compact'
}
export function Home({ layout, theme, columnWidth }: { layout: UiDocument; theme: ThemeContract; columnWidth: number | undefined }) {
// The reader measures its own root element and switches to the real class after the first layout.
// If you already know the width of the place (a desktop site, a column of a fixed width), say so:
// the server markup and the first browser frame then have the final layout, and nothing jumps after hydration.
return <HotlayoutScreen document={layout} theme={theme} initialWidthClass={classOfPlace(columnWidth)} />
}Resizing the host switches the class without losing anything: the stack of screens, the chosen options, typed text, the position of the carousels and the focus stay.
- The body of the screen is not rebuilt. The root has one shape of markup
on every class and with every combination of zones (root → body zone → body
padding → content column → children), so a class that turns on
contentMaxWidth, hides the app bar or the pinned block changes styles only. - A text field stays the same element: it keeps the focus and an unfinished IME composition. An expanded accordion stays expanded.
- The items of the navigation bar are built again when the bar moves between the bottom and the app bar; an item that had the focus has it again in the new place.
The navigation bar. On medium and expanded the bar stands at the
top; on compact it is always at the bottom.
- With an app bar that is shown in this class and by its condition, the items go inside it — into the trailing zone, before the actions, icon and label side by side. Without one, the bar is a bar of its own, edge to edge.
widePlacement: 'bottom'on the bar keeps it at the bottom on every width.- When the items do not fit, the labels of all of them are hidden together (they stay the accessible name of the button), then the title shrinks to nothing, and what still does not fit is clipped at the end of the bar.
- The view depends on the width alone — on the natural width of the text of the title and of the labels — so the same width gives the same view whether the bar was being narrowed or widened.
- The bottom zone of a wide screen is the pinned block only; the safe inset is added only while that zone exists.
A modal sheet. A modal screen without modalPlacement (or with bottom)
is a sheet on compact and a window in the middle on medium and
expanded.
- The window is at most 560 wide and at most 85 % of the height of the area (or the area without margins — the lesser). The body scrolls while the header of the layer stays.
- The class of the content of the layer is taken from its own box, so a 560
window is always
compacteven over anexpandedpage. modalPlacement: 'center'is a window on every width.- Closing, focus and
inertunder the layer are the same as for a sheet.
This is what the renderer does when the width of the root depends on its own content (see "Give the root a width" above).
- How a loop is recognised. Four changes of the class within half a second. There are three classes, so the fourth change in a row always returns to a class that was already shown, whether the loop has two classes or three; a loop makes one change per repaint, and a person does not cross a class boundary four times in half a second on purpose.
- What the lock does. The class becomes the narrowest of the classes of the loop and stops changing.
- When the lock is lifted. Under the lock the class is constant, so the layout is, so the width is. A width that changes under the lock (by more than half a pixel) was changed by the host — a window, a column — and not by the content: the lock is lifted and the class is taken from the new width. If the loop starts again it is locked again; every lift is paid for by a real change of the width, so this cannot run forever.
- Jitter at a boundary. Dragging the edge of a window back and forth over
600 or 1024 looks like a loop of two classes and is locked like one. It is
told apart by the width under the lock, which is not the width the "loop"
showed for that class. Such a lock is lifted by the next change of the width
like any other, and if the width never changes again, by itself after one
second (the class is then taken from the measured width; a real loop is
locked a second time, and then for good). So a window that comes to rest at
900 is
medium, nevercompact.
data is a flat map from data-contract path to value; collections holds the
lists for Repeater widgets. A value that is missing draws the fallback the
manager typed in the builder, and conditions follow the contract's rule for
missing values — the screen is always whole, with or without data, so you can
render first and fill the data in when it arrives.
Dates are bound as ISO strings and formatted by the renderer
(Intl.DateTimeFormat, the shown language, the device's time zone unless
timeZone is given).
A text can be a template — one translatable sentence with named places,
"{days} {unit} left · {price}" — and a binding can say how its value is
written. All of it is document data; you only pass data as before:
| In the document | On the screen | |
|---|---|---|
format: 'number' |
1,850 / 1 850 |
Group and decimal separators of the shown language, grouping always on, up to three fraction digits. |
format: 'currency', currency: 'JOD' |
120 JOD, 1,850.50 USD |
The amount, a no-break space, the ISO 4217 code — the same order in every language. A whole amount has no fraction; otherwise the digits of the currency. |
…with currencyDisplay: 'symbol', currencyPosition: 'before', sign: 'always' |
«+42 000,50 ₴» | See Money below. |
format: 'date' with dateParts |
day and month; weekday, day and month; day, month and year | dayMonth, weekdayDayMonth, dayMonthYear choose which parts of the date are written. |
format: 'daysUntil' |
4 |
Whole days until the bound moment, rounded up, never below zero: max(0, ceil((moment − now) / 24 h)). No time zone is involved. |
format: 'countdownPart', unit: 'days' | 'hours' | 'minutes' | 'seconds' (and pad: 2) |
2, 14, 05 |
One part of the time left until the bound moment, rounded down: days, hours 0–23, minutes 0–59 or seconds 0–59; past moments give zeros. It is written like number; pad: 2 writes at least two digits (05). A sentence composes the parts with its own words — "{h}:{m}:{s}", "{d} d {h} h" — and a part is the number a plural word agrees with. |
a plural value |
day / days, день / дні / днів |
The form whose CLDR category matches the number (Intl.PluralRules). The language is the one the shown forms are written in — the translation's if the forms are translated, the document's own otherwise — never the device's. |
The sentence and the plural forms are translated in the document
(localization), so a translator can reorder the places. A place whose value
has no data shows that value's own fallback and the rest of the sentence stays
(— / 5 steps); a place the document does not declare is drawn literally,
braces included — a typo is visible, not silent.
Without the three fields below a currency is written with its code after the amount. Each field is optional and changes one thing:
| Field of the binding | What it does |
|---|---|
currencyDisplay: 'symbol' |
The sign of the currency instead of its code (₴, $, €…), from a closed table of twelve codes. A code the table does not have stays a code, in the same place. |
currencyPosition: 'before' |
The currency before the amount, in every language. |
sign: 'always' |
A plus for a positive number. The minus is U+2212; zero has no sign. |
- In text shown right to left the composed form is wrapped in the isolation marks U+2066 … U+2069, so the sign, the amount and the currency keep their order.
Text.spans[place].parts(fraction,currency) gives the decimal part and the currency a style of their own — small cents next to a large amount.
These are the contract's rules, the same in every reader.
- Numbers as numbers. The string
"1850"is not a number: it is shown as it is and takes the plural formother. Do not format values yourself. - Dates as ISO 8601 with a time and a zone —
2026-10-14T09:00:00Z,2026-10-14T12:00+03:00— that exist in the calendar. Anything else is not a date for all four date formats and is shown as it is:2026-10-14,2026-10-14T09:00:00(no zone), a timestamp number,"1850", and also2026-02-30T00:00:00Z, month 13,24:00. Dates are written in the Gregorian calendar in every language. - A locale the platform has no date data for (
dv,la) leaves the date as the string your data has. - Image addresses in data. The renderer asks
resolveImageUrlfirst; with no answer from you onlyhttps:(up to 2048 characters) is drawn. Your answer is final up to 512 KB per address. - Icon names in data. A node can take the name of its icon from data
(
iconPath). Only a name of the embedded set is drawn; anything else is an empty box of the same size. Mirroring in a right-to-left screen follows the resolved name. - An empty string is "no value".
""shows the fallback of the binding, under any format; a string of spaces,0andfalseare values. Inside a list row an empty field still belongs to the row — it shows the fallback, not the value yourdatahas at the same path. - No data, numeric fallback. When a value has no data and its fallback
looks like a number (
0,12,1.5), a plural agrees with that number: "0 points", «0 балів». A fallback like—gives the formother. - Numbers above 2^53 − 1 (in absolute value) are not formatted and do not pick a plural form: the value is shown as it is.
- A locale the platform has no number data for leaves
numberandcurrencyvalues as they are and writesdaysUntilas a plain integer. - A template value the renderer does not understand — an unknown kind, or
a known kind of the wrong shape — draws an empty place, the sentence stays,
and
onReportgetsignored-input.
now is the renderer's clock. daysUntil and the parts of countdownPart
are counted when the screen is rendered, from now — by default the clock of
whoever renders. daysUntil does not tick: re-render the screen (new now, new
data) when you want a fresh number. A countdown does: a screen that shows a
countdownPart keeps one timer, every second when a part is seconds and every
minute otherwise, and none when no node counts down; the number is never more
than one tick behind the clock, and a hidden tab draws nothing until it is
visible again. When you pass now yourself the clock is yours and nothing
ticks — pass a new moment when you want a new number. Pass now explicitly in
three cases: server rendering (below), tests,
and previews of sample data.
A list row usually opens a details screen for that record, and the renderer does it by itself: an action carries the record of its node.
- A
navigatepressed in a list row (aRepeaterinstance) opens the screen with that row's record. The paths of the record (item.name,item.price) are read from it first and everything else fromdata. Going back drops the record; another row opens another record. - Every action that reaches you says which record it is about in
source.record— a flat map of paths,{ 'item.id': '42', 'item.name': 'Coffee set' }. A button in a list row carries the row; a button on a screen opened from a row carries that row too. This is how the app learns which reward to redeem:
export function Catalog({ layout, shared, items, onRedeem }: { layout: UiDocument; shared: DataScope; items: readonly DataScope[]; onRedeem: (itemId: string) => void }) {
return (
<HotlayoutScreen
document={layout}
data={shared} // what every screen reads: { 'user.points': 1250, … }
collections={{ items }} // the list: [{ id: '42', name: 'Coffee set' }, …]
onAction={(action, source) => {
// `source.record` is the record the action is about, as a flat map of paths.
// Pressed in a list row: that row. Pressed on a screen opened from a row: that row too.
const itemId = source.record?.['item.id']
if (action.type === 'submit' && typeof itemId === 'string') onRedeem(itemId)
}}
/>
)
}The rules, in full (the same in the mobile renderer):
| The action is pressed… | navigate opens the screen… |
source.record |
|---|---|---|
| in a list row | with the row's record (laid over the record of its own screen) | the same record |
| outside a list, the target is a page | without a record — "Back to catalog" from a details screen is a clean catalog | the record its screen was opened with, if any |
| outside a list, the target is a modal sheet | with the record of the screen beneath — "Redeem {item.name}?" is about the same record | the same |
An action can also say it explicitly: navigate with carryRecord: true
opens a page from a screen button with the record of its own screen — "Choose
delivery" on the details of a reward is about the same reward. Only exactly true
counts; a plain navigate keeps the rule above.
A screen opened without a record (an entry, a button outside a list) reads
item.* from data, as before: put the record there yourself — for example,
the one the user picked with a ChoiceGroup (see Writes).
Three things to know:
- The record is stronger than
data. On a screen opened from a row,data['item.name']is not read while the record has a name. A field the record does not have — or has asnull— is read fromdata. - The record follows the data. The row is remembered with the list it came from
and with its
id(see A page follows the data): when you pass othercollections, an open details screen shows the new values of that record on the spot. A record without anidstays as it was when the row was pressed. - Treat an id from
source.recordas user input. The values are yours (they come fromcollections), but which collection a prefix such asitemnames is decided by the document. Check on your backend that this user may redeem that reward.
If you cancel a navigation in onNavigate (your router owns the URL), the
renderer opens nothing and carries nothing: event.source.record is the
record to put into data for the screen you open.
A condition on a collection ({ path: 'items', op: 'isEmpty' } — the empty
state of a list) reads the list you pass in collections: a missing list is an
empty one.
Give the renderer new collections (a new object, as your state changes) and every
screen of the stack that was opened from a list row looks its record up again — the
details page, the sheet above it, the page that was carried a record with
carryRecord. The key is the field id of the record (item.id for the entity item):
a non-empty string or a finite number, compared by JSON type, so 7 and "7" are two
records. The record is searched for in the collection the row came from, and then in
every other collection the document names for the same entity (the source of
every Repeater with that item) — a task that moved from tasks to doneTasks is
still found. The first record with the key wins and replaces the old one whole.
What does not change: the stack, onScreenChange (a refresh is not a show), analytics,
the scroll, the carousels and what was typed. The page is not rebuilt; a bound number
with animateChanges counts to its new value. A record that is found nowhere stays as
it was last seen — a page does not go blank because a list was reloaded — and the next
collections are searched again. An action from the page hands you the refreshed
source.record. If you used to close and reopen the page to show a changed record, you
can stop.
A ChoiceGroup bound to a path reports the user's choice through onWrite;
put it into data if other widgets should see it. The path comes from the
document, so treat it as input:
/** The only paths this screen may write to — from your project's data dictionary. */
const WRITABLE = ['item.selectedId', 'survey.answer'] as const
export function Survey({ layout, initial }: { layout: UiDocument; initial: DataScope }) {
const [data, setData] = useState<DataScope>(initial)
return (
<HotlayoutScreen
document={layout}
data={data}
writablePaths={WRITABLE}
onWrite={(path, value) => {
// A FLAT map keyed by the path — never `lodash.set(state, path, value)`:
// the path is chosen by the document, and a deep setter turns it into a property walk.
setData((current) => ({ ...current, [path]: value }))
}}
/>
)
}- With
writablePaths, a write to any other path never reachesonWrite; it is reported asignored-input, and the choice stays local to the group. - Without
writablePathsthe document may name any path made of dot-separated identifiers. Paths with a__proto__,constructororprototypesegment are refused in both cases.
setValue and toggleValue are actions of the catalog that the renderer
performs; a button, an icon button, a list row with an action or a
pressable card can carry them. setValue puts a literal
(a string, a flag or a number, by valueType) under a path of the data
dictionary; toggleValue flips the flag under it.
A press does two things, in this order, and nothing more:
- the analytics event of the action (
onTrack, once, also when nothing is written); - the write — into the session of the renderer and to
onWrite(path, value)with a typed value.
onAction is not called for them, valid or not.
- The same gate as an input field.
writablePaths, the form of the path, no__proto__/constructor/prototypesegments. A path the gate refuses is written nowhere and is reported asignored-input; so is an invalid action. - The path and the value are literals of the document. A binding next to
them (
paramBindings) is never read, so data cannot choose where the document writes. - The same value is not written twice. The current value is read with the
data of the node (the record of the list row, the session, your
data): pressing an option that is already chosen writes nothing, and atoggleValuewrites the negation of what is shown.
How such a node is announced:
| The node carries | It is | Semantics |
|---|---|---|
setValue with a value |
an option | role="radio" with aria-checked, chosen when its value lies under its path. Options that are children of one parent and write the same path are one group; each says its place (aria-posinset / aria-setsize). |
toggleValue |
a toggle | aria-pressed |
setValue without a value ("clear"), or an invalid action |
a plain button | — |
The state is recomputed from the data on every change. The renderer draws no
look of «chosen» of its own — put it into the styleWhen of the node. Every
option is a tab stop; there is no arrow-key navigation between them.
A document can ask the person for things: TextField (one line or several, with
a label, a placeholder, a helper and an error), Switch and Checkbox. The
value of each is a data path — the same flat map you already give as data —
and every change the person makes is written there at once.
- The renderer keeps what was written for the lifetime of the flow (same
documentIdandentry), sovisibleIfconditions and bound texts of the screen update on every keystroke even if you do nothing withonWrite. onWrite(path, value)is called at the same moment, with a value typed by the widget — see the table below.- A value you put into
dataunder the same path wins over what the person typed: that is how you normalise input or clear a field. A value of another type under the path (a number under a text field) is an empty field.
| Widget | onWrite receives |
|---|---|
TextField |
a string, on every change; a cleared field writes ""; an IME composition is written when it ends |
TextField with valueType: 'number' |
a number, or "" when the text is not a number — see below |
Switch, Checkbox |
a boolean |
ChoiceGroup with a bound value |
the chosen option, a string — a choice is written like a field, so the conditions and the buttons of the screen see it at once |
/** The paths the profile screens may write to — from your project's data dictionary. */
const FORM_PATHS = ['form.fullName', 'form.email', 'form.phone', 'form.comment', 'form.agreed', 'settings.notifications', 'settings.marketing'] as const
export function ProfileForm({ layout, initial, trusted }: { layout: UiDocument; initial: DataScope; trusted: boolean }) {
const [data, setData] = useState<DataScope>(initial)
// The document has no validation rules: you say what is wrong, as a string under the path the field's `error` is bound to.
const phone = data['form.phone']
const phoneError = typeof phone === 'string' && phone !== '' && !/^\+?[\d\s()-]{7,}$/.test(phone) ? 'Enter a phone number' : ''
return (
<HotlayoutScreen
document={layout}
data={{ ...data, 'form.phoneError': phoneError }}
writablePaths={FORM_PATHS}
// A document from a source you do not control: no autofill hints at all.
autofill={trusted}
onWrite={(path, value) => {
// Called on EVERY keystroke with a string, and on every press of a switch or a checkbox with a boolean.
// The value is user input — check it again on your backend. Throttle what you send over the network yourself.
setData((current) => ({ ...current, [path]: value }))
}}
/>
)
}What you must do
- Treat every written value as user input.
onWritereceives what the person typed, exactly (no trimming); check it on your backend before you trust it. Theerrorof a field is a string that you put intodataunder the path the document bound it to — there are no validation rules in the document. - Throttle yourself.
onWriteis called on every keystroke and the renderer adds no delay; a network write per keystroke is your decision. - Mind autofill. A text field asks the browser only for the hint its
keyboard names (
email,tel,url) and never for credentials, cards, postal addresses or one-time codes. A field the document hides from the eye (visibleIf, a zero-size style) can still be autofilled by a browser, and the value would be written. For a document from a source you do not fully control, passautofill={false}. - Keep
writablePaths: the path is named by the document, as for a choice group.
What the renderer does
- A field without a readable path (a document not built by the builder) works locally and writes nothing. A value the platform filled in before the person touched the field is neither kept nor written.
maxLengthcounts Unicode code points and cuts the input;lines(1–8) makes atextarea. The key of the on-screen keyboard is "next" (focus moves to the next field of the screen) or "done"; it never runs an action, and there is no<form>, so Enter submits nothing.- The content of an
email,phone,urlornumberfield always runs left to right; alignment follows the direction of the screen. disabledIfmakes a field unfocusable, silent and half transparent.- The on-screen keyboard is the browser's business: the renderer does not listen
to
visualViewport, and the focused field keeps clear of a stuck bottom zone through the scroll margin of the body.
What a document can add to the box of a text field — nothing here reaches your handlers:
- the look of the box itself:
fieldRadius,fieldBackground,fieldBorderColor,fieldBorderWidth, andfieldHeight(not read by a multi-line field); - a mark at the start and at the end of the box (
prefix/suffix): up to eight code points of text or an icon. It is never the value and is never written; a press on it focuses the field; - a character counter (
counter): in code points, only withmaxLength; read with the field, not announced on every key.
This is what a short survey needs. The renderer decides everything here from the document and from what the person typed; there are no props and no callbacks for it.
required on a TextField, a Checkbox or a ChoiceGroup (only the
literal true):
- an asterisk after the label (colour
danger, hidden from screen readers) andaria-required; - no text of the renderer's own ("fill in this field"), and a field is
never put into the error state because it is empty — the error stays the
string you put into
data.
A form is the screen. A field counts as filled by the value under its
path, read through the layers (what the person typed, then data): a text
you can hear, a number, true, a non-empty choice.
- Not counted: a field hidden by
visibleIfor disabled bydisabledIf, and a list drawn from data (Repeater). - Counted: a field in a tab that is not open.
requiresFilled and disabledIf on a Button or an IconButton: the
button is disabled while the form of its screen is not filled, or while the
condition holds.
- A disabled button is drawn at half opacity, keeps its place and stays in
the tab order (
aria-disabled="true", not thedisabledattribute). - Touch, Enter and Space do nothing — no
onAction, noonTrack, no navigation. - It comes alive the moment the form is filled, with the first letter,
without waiting for your
data.
A number field — TextField with valueType: 'number': a numeric keyboard
(inputmode="decimal"), one line, left to right.
- What counts as a number. The typed text is read by the contract's own
rule, not by
Number(): a minus, digits — Latin, Arabic-Indic or Persian — one separator.,or٫, at most 15 digits; no groups, no exponent. - What is written.
onWritereceives a number; a text that is not a number, or a cleared field, writes""(nevernull), and the field keeps what was typed. - What is shown from data. A number in
datais shown as plain ASCII digits with a dot; a string under the path of a number field is an empty field. - The error state. Once the user leaves the field, a non-empty text that is
not a number puts it into the error state (the same look as a non-empty
errorfrom your data, andaria-invalid="true"). While it is focused, or once the text is a number or empty, there is no such state. The renderer adds no text: thehelperof the field says what to type.
/** A survey: required fields, a button that waits for them, a number field. The renderer decides; you only receive and check. */
const SURVEY_PATHS = ['survey.goal', 'survey.name', 'survey.age', 'survey.comment', 'survey.agreed'] as const
export function Survey({ layout, send }: { layout: UiDocument; send: (answers: Readonly<Record<string, string | boolean | number>>) => Promise<void> }) {
const [answers, setAnswers] = useState<Readonly<Record<string, string | boolean | number>>>({})
return (
<HotlayoutScreen
document={layout}
writablePaths={SURVEY_PATHS}
onWrite={(path, value) => {
// A number field writes a NUMBER, or `""` when the text is not a number or was cleared ("there is no number") — never `null`.
setAnswers((current) => ({ ...current, [path]: value }))
}}
onAction={(action) => {
// A button with `requiresFilled` / `disabledIf` never reaches here while it is disabled — but a disabled button is a convenience,
// not protection: check the answers again before you accept them.
if (action.type === 'survey.send' && typeof answers['survey.age'] === 'number') void send(answers)
}}
/>
)
}A disabled button is a convenience for the person, not protection: a hostile
document simply does not set requiresFilled. Check what you receive.
The theme is a separate JSON file with its own version: a rebrand replaces the
theme and touches no screen. One file holds a set of themes (typically light
and dark) and names the default one.
// 1. No theme: the built-in starter theme (light; `themeId="dark"` for the dark one).
export const builtIn = <HotlayoutScreen document={layout} />
// 2. Your project's theme — the parsed `theme/published.json`.
export const own = <HotlayoutScreen document={layout} theme={projectTheme} themeId={prefersDark ? 'dark' : undefined} />
// 3. The theme by URL: a designer republishes it, the app picks it up without a release.
export function Themed({ themeUrl }: { themeUrl: string }) {
const [theme, setTheme] = useState<ThemeContract>()
useEffect(() => {
const controller = new AbortController()
loadTheme(themeUrl, { signal: controller.signal })
.then(setTheme)
.catch(() => undefined) // no theme is not an error: the built-in one is drawn
return () => {
controller.abort()
}
}, [themeUrl])
return <HotlayoutScreen document={layout} theme={theme} onReport={(report) => { console.warn('hotlayout', report) }} />
}A theme that cannot be read (another major, not an object, no themes) never
blanks the screen: the built-in theme is drawn and onReport gets
theme-fallback. A document may name a token the theme does not have; the
property is then skipped (or the field default is used) by the contract's
policy — the renderer never substitutes a "similar" colour.
Theme values become CSS variables (--product-*) on the screen's root element
and nowhere else.
The package does not ship or load font files. The theme names the
typefaces — fontFamilies.body and fontFamilies.heading, each with a
primary family and a fallback stack — and the renderer writes them into
font-family. Making the primary family available is the page's job, exactly
as with any other CSS.
What to load, for any theme:
/** What the page has to load for this theme: families and the weights actually used. */
export function fontsOf(contract: ThemeContract, themeId?: string) {
const { theme } = resolveTheme(contract, themeId)
const weights = (role: 'body' | 'heading') => [...new Set(Object.values(theme.typography).filter((style) => style.role === role).map((style) => style.fontWeight))].sort()
return {
body: { family: theme.fontFamilies.body.primary, weights: weights('body') },
heading: { family: theme.fontFamilies.heading.primary, weights: weights('heading') },
}
}
// For the built-in theme: body — Inter 400, 500, 600; heading — Inter 700.Then load it any way you like. From a font service, in the page <head>:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />or self-hosted, in your global stylesheet:
@font-face {
font-family: 'Inter'; /* exactly the name the theme uses */
src: url('/fonts/Inter-Variable.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
}Three things to know:
- The family name must match the theme's, character for character. Some
font loaders register the font under a generated name (check the computed
font-familyof a heading if you use one, e.g.next/font); the renderer asks for the name written in the theme. - Arabic and other scripts. A Latin typeface has no Arabic glyphs; the
browser then takes them from the next family in the stack. If the brand has
an Arabic typeface, ask the designer to put it in the theme's
fallbacklist (or use a typeface that covers both) and load it the same way. - Without the font nothing breaks, but the screen is not the one that was
designed. The fallback stack is used (
system-ui, …), so line breaks, the width of buttons and badges and the height of multi-line text differ from the builder's canvas and from the mobile app. If a screen looks "almost right", check the font first.
@font-face must be declared at page level. If you render the screen inside
your own shadow root, the declaration still belongs to the document.
Pass the user's locale as the platform gives it — navigator.language, the
Accept-Language winner, ar_EG from a mobile bridge. Translations live in
the document (localization), where the manager edits them in the builder,
and the renderer finds the right one itself.
export const arabic = (
<HotlayoutScreen
document={layout}
locale={navigator.language} // the user's locale as the platform gives it: `ar-EG` finds the document's `ar` — translations, Egyptian date formats, direction (rtl)
timeZone="Africa/Cairo" // dates are written in this zone — on the server and in the browser alike
labels={{ back: 'رجوع' }} // the accessible name of the back arrow is the renderer's text, so it is yours to translate
/>
)
// A language the document has no translation for is shown in the document's own language,
// and the direction follows what is actually shown. Force it only if you know better:
export const forced = <HotlayoutScreen document={layout} locale="en" dir="rtl" />How the translation is found — the contract's rule, the same in the mobile renderer:
_equals-and case does not matter:ar_EG,ar-eg,AR-EGare one tag.- Subtags are dropped from the end until a language of the document matches
(RFC 4647 Lookup):
ar-EG→ar;zh-Hant-TW→zh-Hant→zh. - It never goes the other way:
locale="ar"does not pick a document'sar-EG, andpt-BRdoes not pickpt-PT. Guessing a region would show people the wrong text silently — the builder warns the manager about a regional translation without its base language instead. - No match → the document's own language.
The answer is one per document: a string that has no translation in the chosen language falls back to the document's own text, not to a third language.
Known limit: zh-TW falls to zh (usually Simplified) although Taiwan writes
Traditional — the rule drops subtags and does not consult script data. Give the
document a zh-TW translation if you serve Taiwan.
What follows from the answer:
- Dates are formatted with the requested locale when a translation was
found (
ar-EGgives Egyptian formats next to Arabic text) and with the document's own language when it was not — never Arabic digits next to English text. - Direction follows the language that is actually shown, not the one
that was asked for: no Arabic in the document → left to right. It is a
property of the script and comes from CLDR (
Intl.Locale), not from a list in this package. It is set asdiron the screen's root only — your page is not touched. - Layout is logical throughout (
start/end): rows, paddings, corner radii and the back arrow flip; icons the contract marks as directional are mirrored; the tab strip's arrow keys swap. - Values from your data are not translated — the backend already answered in the user's language.
When your own chrome has to speak the same language as the screen, ask the same function the component uses:
/** The renderer's own texts (`labels`) in the language the screen is actually shown in. */
const BACK_LABELS: Readonly<Record<string, string>> = { en: 'Back', ar: 'رجوع', uk: 'Назад' }
export function backLabelFor(document: UiDocument, deviceLocale: string): string {
const { translation } = resolveLocale(deviceLocale, document.localization?.locales ?? [], document.localization?.defaultLocale)
return BACK_LABELS[translation ?? 'en'] ?? 'Back'
}
// backLabelFor(layout, 'ar-EG') → 'رجوع' when the document has `ar`; 'Back' when it only has `ar-SA` or no Arabic at allrenderToString and streaming work: nothing touches window or document,
and the stylesheet and the theme variables are part of the markup.
The screen is in the first part of the response. The component puts no
Suspense boundary around the screen, so a streaming renderer
(renderToPipeableStream, renderToReadableStream, the Next.js App Router)
has nothing of the screen to send later: the first screen arrives as ordinary,
fully styled HTML, with no placeholder in its place and no script that swaps
it in. A reader with JavaScript off and a crawler see the screen; with
JavaScript on it becomes interactive when it hydrates. The same holds for a
page Next.js prerenders at build time. (If you wrap the component in a
Suspense boundary of your own, React may send your fallback first — that is
how streaming treats any large boundary.)
Next.js App Router: no wrapper needed. The package marks its component as
a Client Component itself ("use client" inside the build), so you can put
<HotlayoutScreen> straight into a Server Component. The helper functions are
not client code — checkDocumentVersion, loadDocument, loadTheme and
resolveTheme can be called on the server:
// app/rewards/page.tsx — a Server Component. No "use client", no wrapper of your own.
export default async function RewardsPage() {
const [layout, theme] = await Promise.all([loadDocument(DOCUMENT_URL), loadTheme(THEME_URL).catch(() => undefined)])
if (checkDocumentVersion(layout.schemaVersion).kind === 'incompatible') return <p>This screen needs a newer app.</p>
// `now` is read once on the server and travels to the browser with the props: both count "days left" from the same moment.
const now = new Date().getTime()
return <HotlayoutScreen document={layout} theme={theme} locale="ar" timeZone="Asia/Dubai" now={now} style={{ minBlockSize: '100dvh' }} />
}Navigation between the document's screens, tabs, accordions and modal sheets
work on such a page after hydration. The one thing a Server Component cannot
do is pass callbacks — that is React's rule, not ours. When you need
onAction and friends, the screen goes into a Client Component of yours:
// app/rewards/Rewards.tsx — starts with 'use client': callbacks are functions,
// and functions cannot be passed from a Server Component to a Client Component.
export function Rewards({ layout, theme }: { layout: UiDocument; theme: ThemeContract | undefined }) {
return (
<HotlayoutScreen
document={layout}
theme={theme}
timeZone="Asia/Dubai"
onAction={(action) => {
if (action.type === 'submit') void redeem(action.params?.offerId)
}}
/>
)
}Four things to set or know when you render on a server:
- Set
timeZonewhenever a screen with dates is rendered on a server. A server usually runs in UTC and the browser does not; the same moment formatted twice gives two texts ("Oct 31" and "Nov 1"), and React reports a hydration mismatch. WithtimeZoneboth sides write the same text. Pick the zone that is true for the user (their profile, your market) — or omit the prop and render date-bound screens on the client only. - Set
nowfor the same reason when the document counts days (daysUntil): the server and the browser render at different moments, and a deadline that crosses a 24-hour boundary in between gives "4 days left" in the markup and "3 days left" after hydration. In a Server Component read the clock once and pass it down — the number travels to the browser with the props, as in the first example above. A countdown (countdownPart) is the same case twice: with yournowthe markup of the server and of the first browser frame is the same; without it the two clocks differ, the text of a countdown is exempt from the hydration check, and the browser's clock replaces the server's number as soon as the page is interactive. - Reports (
onReport) fire in an effect, i.e. in the browser. On the server usecheckDocumentVersionandresolveTheme(theme).reportsif you want to log fallbacks there. - A screen that throws while rendering on the server does not fail the
response: the status stays 200, the rest of your page is rendered, and the
markup carries the neutral notice (
renderError) in place of the screen. The browser hydrates that markup as it is and reportsrender-error. What was drawn before the part that threw is in the markup but not displayed (the package stylesheet hides it with:has(); a browser without:has()shows it above the notice until hydration).
The width class on the server is compact unless you say otherwise — see
Adaptive layout by width.
The renderer lives in the regular DOM of your page.
- It does not leak out. Every visual decision is an inline style; theme
values are CSS variables (
--product-*) set on the root element; the stylesheet contains only selectors scoped to[data-hotlayout], plus the two global at-rules motion needs (@property --hl-motion-phaseand@keyframes hl-motion-cycle: such names cannot be scoped, so they are prefixed). Nothing is added to<head>,<html>or<body>. - Your page does not leak in. A scoped reset (
all: unset) with the specificity of an id selector removes whatever your global CSS gives tobutton,div,img,p,*… inside the screen, and the root restates every inherited text property. The reset matches descendants of the package's own root only — never an element of yours. - Nothing is drawn outside the root. The root clips its content
(
overflow: clip; contain: layout paint). If the screen should scroll inside a fixed-height box, give the rootstyle={{ overflow: 'auto' }}yourself. - Inherited properties you set on purpose.
font-family,color,line-heightand the rest are restated on the root, so abodyfont does not reach the screen. That is intended: the screen's typeface is the theme's.
The limit: !important. A host rule marked !important still wins over
the reset and over inline styles — button { border-radius: 0 !important }
squares every button of the screen. Only Shadow DOM stops that, and it was not
chosen because it breaks server rendering with hydration and hides the screen
from your tests and tooling. If your CSS has global !important rules, either
scope them away (:not([data-hotlayout] *)) or render HotlayoutScreen into
your own shadow root.
Inline style attributes need style-src 'unsafe-inline' (or
style-src-attr 'unsafe-inline'). The inline <style> element can be avoided:
import '@hotlayout/react/styles.css'
import { HotlayoutScreen } from '@hotlayout/react'
import type { UiDocument } from '@hotlayout/react'
export function StrictCsp({ layout }: { layout: UiDocument }) {
return <HotlayoutScreen document={layout} injectStyles={false} />
}Images are loaded from the URLs in the document and in your data, so img-src
must allow those hosts — and only those: see the recommended policy in
Security model.
Each node's box carries data-hl-node="<node id>" and data-hl-type, each
screen layer data-hl-screen and data-hl-presentation. Node ids are stable
across republishing as long as the manager does not delete the node.
The layout document and the theme arrive over the network, and whoever can publish them can change what your users see. The builder validates both on publish, but the renderer does not rely on that: every value is parsed again at the point where it becomes CSS, an address or a key.
For an untrusted document and theme:
-
A value never becomes CSS it was not meant to be. Each value is parsed by an allow-list of its form before it reaches a style:
Value Accepted form a colour #rrggbb/#rrggbbaaand nothing else — notred, notvar(…), noturl(…)a number a finite number clamped to the range of the schema: padding, radius, font size and weight, opacity, sizes, shadow offset / blur / spread, gradient stops, grid columns 2…4, limit,maxLines,weighta token, role or asset name the token pattern, so nothing outside [a-z0-9-]gets into a CSS variable namea font family a name made of letters, digits, spaces and . _ ' & + -an alignment, fitor directionone of the listed values A value that does not parse behaves as the contract's compatibility policy says for an unknown one (the property is not applied, or the field default is used) and is reported. This holds for server rendering too, where a
;in a style value would otherwise start a new declaration. -
Shadows and gradients are assembled from checked parts, so a "colour" cannot add a layer or an address.
url(…)is produced only for images the document may legitimately name, and a theme cannot name an address at all. -
Nothing is drawn or clicked outside the root element. The root clips its content (
overflow: clip; contain: layout paint): a wide node, a shadow, a negative margin or the 48 px press area of a button cannot cover your page or take a click meant for your own controls, and a descendant cannot becomeposition: fixedrelative to the window. -
A malformed document or theme does not take your application down.
- Whatever does not have the promised shape (a node that is not an object,
childrenthat is not a list,nullwhere an object is expected) is ignored and reported. - A theme with holes is completed from the built-in theme of the same brightness.
- If a screen still throws, the error is caught inside the renderer — in
the browser and during server rendering — and replaced by a neutral
notice (
renderError), with arender-errorreport. The notice stays untildocument.documentIdorentrychanges (the same document would throw again); remount the component withkeyto retry.
- Whatever does not have the promised shape (a node that is not an object,
-
What reaches your handlers is checked.
onWriteis never called with a path that has a__proto__/constructor/prototypesegment, and only forwritablePathswhen you give them.- Action
paramsare own keys with string values. source.recordis a fresh frozen map with the record's own keys of the data-path form only (no__proto__,constructororprototypesegment). Its values are the ones you passed incollections— the document cannot name or change them, only choose which list is shown.
-
No lookups through the prototype chain. Data paths, binding formats and every table keyed by a string from the document are read as own keys.
-
The stylesheet matches nothing of yours. Its selectors are scoped to the package's own
[data-hotlayout]root.
An image address named by the document or by bound data is loaded only if it
is an absolute https: URL with a public host name: the same form as an
openUrl link (a letter top-level label, no credentials, no IP literal such
as 192.168.1.1, 127.1 or [::1], no localhost). http:, data:,
blob:, javascript:, relative addresses and anything else are drawn as the
placeholder and reported. For an address that came from your data the report
and the console carry the scheme and host only, never the path or the query.
Every <img> is drawn with referrerPolicy="no-referrer", loading="lazy"
and decoding="async"; a CSS background-image cannot carry a referrer
policy, so the Referrer-Policy of your own page applies to those. To narrow that to your own hosts, to route images through your
proxy, or to allow http://localhost or an intranet address in development,
decide yourself (allowedByDefault is false for the addresses above):
const IMAGE_HOSTS = new Set(['cdn.example.com', 'images.example.com'])
export function OwnImagesOnly({ layout }: { layout: UiDocument }) {
return (
<HotlayoutScreen
document={layout}
resolveImageUrl={(url, { allowedByDefault }) => {
if (IS_DEVELOPMENT && url.startsWith('http://localhost:')) return url
// `allowedByDefault` is true for an absolute https: address — the renderer's own rule.
return allowedByDefault && IMAGE_HOSTS.has(new URL(url).hostname) ? url : null
}}
onReport={(report) => {
if (report.kind === 'ignored-input') console.warn('layout input ignored:', report.where, report.reason)
}}
/>
)
}Addresses you pass in assets are yours and are not filtered. Addresses in
your data and collections are — the document chooses which path an
image is bound to. If your own data carries data: images or site-relative
addresses (/img/42.png), allow exactly those in resolveImageUrl.
!importantrules of your own page still reach inside the screen — see Styles and isolation.- The content of an image you allowed. An
https:image is still a request to that host from your user's browser (IP address, time;Refereris not sent for<img>, but your page'sReferrer-Policyapplies to CSS backgrounds), and the picture can show anything — including text that imitates your UI inside the screen's rectangle. UseresolveImageUrland CSP to decide which hosts are acceptable. - The texts and the flow of the screen. A document says what is written on
a button and which action it carries; treat
onActionas a request from the screen, and authorise it on your backend like any other request. - Size. The renderer caps list length (50 records), theme tokens and the depth of the tree, but does not limit the size of the document itself; the builder publishes documents up to 1 MB.
For the page that hosts the screen:
img-src 'self' https://cdn.example.com; /* exactly the image hosts you allow */
style-src 'self' 'unsafe-inline'; /* or style-src-attr 'unsafe-inline' with injectStyles={false} */
script-src 'self'; /* the renderer never needs inline scripts or eval */
connect-src 'self' https://storage.example.com; /* only if you use loadDocument / loadTheme */
img-src is the second lock on what resolveImageUrl decides: even an address
that slipped through is not requested by the browser.
Built in; nothing to configure except labels.
| Keyboard | Buttons, icon buttons, accordion headers and the back arrow are real <button>s. A list row with an action is role="button", a tab stop, activated by Enter and Space. |
| Focus ring | Every focusable element shows a 2 px ring in the theme's primary colour on :focus-visible. Buttons and rows also get an inner ring in their own text colour, so the focus stays visible where the element touches the edge of the root (the root clips what is drawn outside it). |
| Tabs | WAI-ARIA Tabs with automatic activation: one tab stop per strip, ← → (swapped in RTL), Home, End; tab ↔ tabpanel are linked. |
| Modal screens | WAI-ARIA Dialog: focus moves into the sheet and returns to what opened it, Tab cycles inside a layer the person can dismiss, Esc closes (unless the screen says dismissible: false), the screen underneath is inert. A layer that cannot be dismissed lets Tab leave the package, so a document can never lock a keyboard user out of your page; set trapFocus to keep Tab inside always. |
| Screen changes | After the user navigates, focus moves to the new screen, so the next Tab starts there rather than at the top of your page. The first screen never takes focus. |
| Names | Images use the alt from the document — a plain string or a string of the document (a binding, a template), translated like any text (no alt, or an empty one → decorative, hidden from assistive technology). Icons likewise. The back arrow is named by labels.back (default "Back") — translate it. |
| Icon buttons | An IconButton has no visible text; its accessible name is the label of the node, after translation and data binding (aria-label). A document that left the label empty still gets a working button, named by its icon in words (chevron-left → "chevron left"). An icon next to the label of a Button or a Badge is decorative and hidden from assistive technology; a badge with an icon is text, not a control. |
| Pressable cards | A container with an action is role="button", a tab stop before the controls inside it; its name is the label first, then the text of the content. |
| Long press | A node with a long press is described by the name of the action (aria-describedby to a hidden element) and lists its keys (aria-keyshortcuts="ContextMenu Shift+F10"); those keys run it at once on the focused node. |
| Text fields | A real <input> / <textarea> with a visible <label for> as its name; the helper and the error describe it (aria-describedby), an error sets aria-invalid and is announced politely. Tab order is document order. |
| Switches and checkboxes | A <button> with role="switch" / role="checkbox" and aria-checked, named by its label; the whole row is the target; Space toggles (Enter too for a switch). |
| Options and toggles | A node with setValue is role="radio" with aria-checked and, among the options of one parent that write the same path, aria-posinset / aria-setsize; a node with toggleValue is a button with aria-pressed. Each is a tab stop; Enter and Space press. |
| Choice groups | role="radiogroup" with role="radio" rows and aria-checked; each option is a tab stop, selected by Space or Enter. |
| Reduced motion | With prefers-reduced-motion: reduce every moving node (motion) stands at rest; the document needs no second variant. |
| Progress | role="progressbar" with aria-valuenow / aria-valuemax. |
| Language | The root carries lang and dir, so a screen reader switches voice for an Arabic screen on an English page. |
Colour contrast is a property of the theme, and text alternatives are written by whoever builds the screen — the renderer cannot add what the document does not say.
This version reads layout documents of schema 9.28.x and themes of theme contract 4.0.x, and embeds icon set 1.0.0. Any 9.x document and any 4.x theme is drawn; React 18 and 19 are supported by every release.
The rule is the contract's (x-compatibility in screen-schema.json): the
major must match; a minor only adds optional things; a patch changes
nothing a reader can see.
checkDocumentVersion('9.0.0') // { kind: 'compatible' }
checkDocumentVersion('9.99.0') // { kind: 'newer-minor', documentVersion: '9.99.0' } — drawn, the unknown is skipped
checkDocumentVersion('10.0.0') // { kind: 'incompatible', reason: 'major 10 is not readable by a major-9 reader', relation: 'document-newer' }
console.log(`this build reads schema ${READER_SCHEMA_VERSION}`)- Same major, newer minor — the screen is drawn. Widgets this build does
not know draw their children, unknown tokens and fields are skipped;
onReportgetsnewer-minor-document. Nothing looks broken, something new may be missing. - Another major (newer or older) — the screen is not drawn. Drawing
"as much as possible" of a document from another major would show a screen
nobody designed. By default the user sees a short message
("Update the app to see this screen." for a newer major, "This screen is
not available right now." otherwise;
role="alert"); replace it with your own:
export function Guarded({ layout }: { layout: UiDocument }) {
return (
<HotlayoutScreen
document={layout}
renderIncompatible={(verdict) =>
verdict.relation === 'document-newer' ? (
<p role="alert">Please update the app to see this screen.</p>
) : (
<p role="alert">This screen is not available right now.</p>
)
}
onReport={(report) => {
if (report.kind === 'incompatible-document') console.error('layout refused', report.documentVersion, report.readerVersion, report.reason)
}}
/>
)
}HotlayoutScreen applies the rule itself. Call checkDocumentVersion when
you want to decide before rendering — typically to keep showing the previous
version of a screen instead of the refusal, as in the next section.
When the builder moves to a new schema major, update this package in your app before screens are republished from the new builder.
| Package | Document schema |
|---|---|
| 0.26.x, 0.27.x | 9.28.x |
| 0.24.x, 0.25.x | 9.27.x |
| 0.23.x | 9.25.x |
| 0.22.x | 9.24.x |
| 0.21.x | 9.23.x |
| 0.20.x | 9.21.x |
| 0.19.x | 9.20.x |
| 0.17.x, 0.18.x | 9.19.x |
| 0.15.x, 0.16.x | 9.18.x |
| 0.14.x | 9.17.x |
| 0.13.x | 9.14.x |
| 0.12.x | 9.13.x |
| 0.11.x | 9.12.x |
| 0.10.x | 9.11.x |
| 0.9.x | 9.8.x |
| 0.8.x | 9.7.x |
| 0.7.x | 9.5.x |
| 0.6.x | 9.4.x |
| 0.5.x | 9.3.x |
| 0.4.x | 9.2.x |
| 0.2.x, 0.3.x | 9.1.x |
| 0.1.x | 9.0.x |
Every row: theme contract 4.0.x, React 18 and 19. 0.17 performs half of the 9.19 additions and 0.18 all of them — the details are in the changelog.
Every addition is an optional field: a document without it is drawn as before. The table says where a host can read more — a section of this page when there is something for the host to do, the changelog entry of the package version otherwise.
| Schema | What a document can say | Read more |
|---|---|---|
| 9.1 | The device locale picks the translation | Languages |
| 9.2 | A list row carries its record to the screen it opens | One screen, many records |
| 9.3 | Icons from the embedded set | Icons |
| 9.4 | Template sentences, number / currency / daysUntil, plurals |
Sentences, numbers, money and plurals |
| 9.5 | An icon in a button and a badge, button sizes, IconButton |
Accessibility; changelog 0.7.0 |
| 9.6 | A text made of styled parts (Text.spans) |
changelog 0.8.0 |
| 9.7 | Thickness, track, gradient and size of a progress bar and ring | changelog 0.8.0 |
| 9.8 | The action catalog, the analytics event of a press, the screen-shown event, carryRecord |
Actions and navigation |
| 9.9 | An own image proportion, app bar actions, a modal window in the middle, the status bar request, the three zones of a screen | Scrolling and the app bar; changelog 0.10.0 |
| 9.10 | An overlay layer of a stack (layer) and a pulled child (pull) |
changelog 0.10.0 |
| 9.11 | A carousel (carousel on a scrolling row and on a data list), a stepped progress bar (segments) |
changelog 0.10.0 |
| 9.12 | The bottom zone: NavBar / NavItem, pin: 'bottom', navigate.section |
The bottom zone |
| 9.13 | TextField, Switch, Checkbox |
Forms |
| 9.14 | required, requiresFilled / disabledIf on a button, a number field |
Required fields… |
| 9.15 | Alignment on both axes: Column.justify, Container.align / justify, Screen.align / justify |
below |
| 9.16 | A node in the leading zone of an app bar, titleAlign, glyphSize |
Scrolling and the app bar; below |
| 9.17 | Text emphasis, list items (Text.marker), the own style of a ring label |
below |
| 9.18 | Width classes: showOn, styleOn, propsOn, Screen.contentMaxWidth; the navigation bar and modal sheets on a wide place |
Adaptive layout by width |
| 9.19 | Free composition (a container inside its own type, a tree of up to eight levels — drawn as any other tree) and pressable containers | Pressable cards |
| Money: the sign of a currency, its place, the sign of the number, styled parts | Money | |
The body under the app bar (bodyTop), the top safe inset |
Safe areas | |
Write actions setValue / toggleValue and choice from own nodes |
Write actions and choice | |
| Parts of a list row and of a text field | below; Forms | |
alt as a string of the document, an icon name from data, date parts, unset in a width layer |
Accessibility; Data; Adaptive layout | |
| 9.20 | Motion of a node (motion), blur under a modal layer, glass (style.backdropBlur) |
changelog 0.19.0 |
| 9.21 | A number that counts to its new value (animateChanges), a ticking countdown (countdownPart), round ends of a bar and a ring (cap), an app bar that changes when the body scrolls (styleScrolled, titleShown) |
below |
| 9.22 | A floating navigation bar (NavBar.float), a dot on an item (NavItem.badgeWhen), drag to confirm (Button.confirm), a dashed border (style.border.style) |
below |
| 9.23 | A filtered list (Repeater.where), a comparison with another path (valuePath), a gradient on the letters (style.textGradient), compact numbers (notation) |
below |
| 9.24 | A value that grows when shown (animateIn), the busy state after drag to confirm and the promise of onAction, the motion of the dot on an item, nodes pinned to the top (pin: "top") |
below |
| 9.25 | The steps of a bar that fill smoothly, the glow of a scale (glow), a shadow in the colour of the background (style.shadowColor), the press feedback of a pressable node, a haptic request (haptic) |
below |
| 9.26 | A long press (longPress, longPressLabel), grouped lists (Repeater.groupBy), a sheet that follows the finger down, a modal screen that waits for an action (dismissible: false) |
Long press; below |
| 9.27 | A page opened from a list row follows the data | A page follows the data |
| 9.28 | No new field: the rules of a reader the contract now states — the default picture address and its network limits, at most 2550 list instances per screen, theme numbers out of bounds, transparent text out of the name of a card, focus of a layer that cannot be dismissed | changelog 0.26.0 |
None of these asks anything of the host; they are listed so that you know what you are looking at.
- Alignment (9.15).
justifyis the main axis;alignof a row container is the vertical axis. Free space on the main axis comes only from a namedstyle.height,style.minHeightor a stretched layer — a column of the height of its content has none, and that is not an error.alignof a column container and of a screen is the rule of the column. glyphSize(9.16). An integer 8…128: the side of the drawing of anIconand anIconButton, clamped by the named box and not multiplied by the system font scale.- Text emphasis (9.17).
style.fontStyleandstyle.textDecorationof a text are inherited field by field and read byTextonly. A part of a text can nameweight,fontStyleandtextDecoration; the line is drawn by each run itself. - List items (9.17).
Text.marker(bullet/number) draws a column of the font size of the text with a hanging indent; the number counts the visible items of a group of neighbours. - The label of a ring (9.17).
labelTypography/labelColor. The label never scales with the ring, never inherits and never sizes it.
All four are optional; a document without them is drawn exactly as before.
animateChangesonText,ProgressBarandCircularProgress. When the data behind a bound number change while the node is on screen, the number counts from the old value to the new one in 700 ms (ease-out) and ends exactly on the new one: whole steps when both ends are whole, the cents of the currency forcurrency, three digits fornumber; a bar and a ring grow without a step. Every number on the way is written by the same format, sentence and plural word as the last one. There is no transition for the first draw of a node (so none on the server and none while hydrating), for a node that appears, for a language changed in the same step, forreduce motion, for a value that is not a number on either side ("no data" to data included) and for the same value; a new change in the middle starts from the number shown at that moment. The screen reader gets the final value only: nothing is announced on the way. A new document is a first draw.format: 'countdownPart'— see Sentences, numbers, money and plurals.cap: 'round'onProgressBarandCircularProgress. The ends of the bar track and fill are half-circles of half the thickness (the themepillis not read); a ring arc gets half-circle ends that stay inside the filled part, a tiny share is a dot, and a full ring has no ends and no seam.styleScrolledandtitleShown: 'scrolled'on the app bar. Once the body has scrolled under the app bar zone, the bar takesbackground,color,shadowandbackdropBlurfromstyleScrolled(never its box; a token the theme does not know is dropped), and the title is drawn only then — at rest it keeps its place and its accessible name (opacity: 0). The screen root listens to the scroll of its own body passively, and only when the bar asks; withscroll="page"the scroll is how far the root top went past the line the bar sticks to. The editor canvas never scrolls, so it shows the bar at rest.
All four are optional; a document without them is drawn exactly as before. The package adds no global style for them: everything is inline, and nothing is added to the markup of a node except the marks named below.
NavBar.float. With the bar in the bottom zone (compact, orwidePlacement: "bottom") the whole zone — the pinned block and the bar — lies over the body: it has no height in the flow (data-hl-zone="bottom"carriesdata-hl-floating), the body area runs to the bottom of the screen and the content scrolls under the bar. The root measures the zone and the body ends with its usual end plus that height, so the last row scrolls clear of the bar. The bottom safe inset (safeAreaBottom) is the transparent room under the box of the bar, not part of its background. The look of the pill — margin, radius,backdropBlur, shadow — is thestyleof the node; nothing is built in. Only the boxes of the block and of the bar catch presses; the rest of the zone lets them through to the body. Withscroll="page"the zone sticks to the bottom of the host page over the content (no backdrop). At the top of a wide screen the field is not read.NavItem.badgeWhen. A condition of the form ofvisibleIf; while it holds a dot of 8 in the theme colouraccentstands on the top corner of the icon at the end of the line (data-hl-badge, hidden from screen readers; the name of the item stays its label). A form the reader does not understand draws no dot.Button.confirm: "swipe". The button is drawn as a track with a round thumb (data-hl-confirm="swipe",data-hl-swipe-thumb). A pointer drags the thumb along the line; releasing it at 90% of the way or further runs the action, with itstrackevent once before it; less, and the thumb returns. A press with no drag does nothing. Enter, Space and an activation from a screen reader run the action at once; a held key repeating is not a press. A disabled button does not move. Give the host nothing: it is a plain<button>withtouch-action: pan-y.style.border.style: "dashed". The border keeps its room as a transparent solid one and the dashes are an image over the border box (border-image), drawn once the box is measured — so the server markup has the transparent border and the dashes appear when the page is live. A node with a dashed border carriesdata-hl-dashedand is followed by one hidden element that does the measuring.
All three are optional; a document without them is drawn exactly as before.
Repeater.wherekeeps only the records that pass a condition of the form ofvisibleIf, evaluated on each record in its own scope; the condition may compare with the value under another path (valuePath), for instance a category chosen by asetValueaction. A comparison with no operand passes every record, so a filter that was never set shows everything. The grid closes up, the carousel counts cards by the records drawn, andlimittakes the first records that passed. The empty state of a filtered list is a conditionisEmpty/isNotEmptyon the collection with the sameitemandwhere.valuePathis a comparison operand everywhere a condition is read (visibleIf,styleWhen,disabledIf,badgeWhen, the states of parts). With nothing under it the condition does not hold,eqandneqalike.style.textGradientpaints the letters of aTextwith a gradient token or an own gradient: one gradient over the whole content box of the text, a part with its own colour keeps it, and the line of an emphasis and the marker of a list item keep the ordinary colour (also the colour of the text when the gradient does not resolve). It is not inherited and other widgets do not read it.notation: "compact"besideformat: "number"writes 48 200 as48.2K(48,2 тис.in Ukrainian): one fraction digit, half up, a closed table of suffixes by the first part of the language of the format — notIntlcompact. A plural word agrees with the full value.
All four are optional; a document without them is drawn exactly as before.
animateIn: trueonText,ProgressBarandCircularProgress: when the node is shown, its numbers grow from zero to their own with the curve, duration (700 ms) and step ofanimateChanges. A text counts the numbers it is bound to (and the numbers of a template); a scale grows its value (a literal value too) while the maximum stands. The node is shown when it is drawn on a shown screen: the first screen, a screen pushed on top, a screen shown again by "back" or a section switch, a node that appears by a condition or a width class. Closing a modal layer is not a show, and a change of data is not a show. "Reduce motion", no number and zero give the value at once, and a screen reader reads the final value. A screen that came from the server does not grow on its first show. While React hydrates server markup the nodes keep the value the server wrote, because a drop to zero under the reader's eyes is worse than no motion. Everything shown after that (a transition, "back", a node that appears) grows. A client-only render grows from the first frame. The server markup is always the final value.- Drag to confirm stays busy. After the thumb passes the threshold the action runs
at once and the thumb glides to the end and stays there with a ring instead of the
icon (
aria-busy, no more drag, press or key).onActionmay now return aPromise: the state lasts until it settles (fulfilled or rejected), then the thumb returns. Without a promise it lasts until the shown screen changes or eight seconds pass. An action the reader runs (navigate…) changes the screen at once, andnoneor an invalid standard action return the thumb at once. Return the promise fromonAction:
// The thumb of a drag-to-confirm button waits here: it stays busy until the promise settles.
async function redeemAndWait(action: HotlayoutAction, source: ActionSource): Promise<void> {
const offerId = source.record?.['offer.id']
await redeem(typeof offerId === 'string' ? offerId : action.params?.offerId)
}- The dot on a navigation item pops in (scale 0 → 1.25 → 1 in 320 ms) and fades out (200 ms) when its condition changes on a shown screen. The first show and a screen shown again have no motion. The size and the name of the item never change.
pin: "top"on the children of the root that stand in a run at the start (right after the app bar, or first) pins them under the app bar: they never scroll, the body scrolls below them. Hidden nodes take no room; the word is not read withbodyTop: "screenTop"or after a child without it. In a block without its own height (scroll="page") the zone sticks right under the app bar by the samechromeswitch.
All five are optional; a document without the fields is drawn as before, except the two rules that need none: the steps of a bar fill smoothly while a value transition runs, and a pressable node gives feedback to the finger.
- The steps of a bar fill smoothly. At rest a step is full or empty. While the
value of a bar with
segmentsruns a transition (animateChanges,animateIn) the filled steps are a number with a fraction on the way from the whole steps of the old value to the whole steps of the new one; the step it reaches is cut at that share and the radius of the step stays. Both ends are pictures at rest, so nothing jumps when the transition ends; a change inside one step moves nothing; "reduce motion" shows whole steps at once. A gradient runs along the drawn fill. glow: "soft" | "strong"onProgressBarandCircularProgress: a blurred copy of the fill paint under the scale (blur 4 and 8 px, opacity 0.55 and 0.9), without offset. The track does not glow. A bar with steps lights one step: the current one whencurrentColorpaints it, otherwise the last step with any fill. The glow takes no room, is hidden from assistive technology, and is not clipped by the scale itself; an ancestor that clips (the root of the screen does) clips it, as it clips a shadow.style.shadowColor: "background": every layer ofstyle.shadowtakes the hue of the node's own background (a gradient gives the stop nearest to its middle) and keeps its own opacity. No background, an image or an unknown colour: the shadow keeps its colour.- Press feedback. The node that performs the press gives it: a card or a list row
scales to 0.97, a button to 0.96, an icon button, a ghost button and a navigation item
fade to 0.7 (80 ms down, 160 ms back with a slight overshoot). It follows the pointer,
so even a very short tap reaches the pressed pose. The keyboard gets none; with the
system "reduce motion" every kind only fades.
pressFeedback: "none"on a node turns it off. The scale is painting only: neighbours do not move. hapticon an action: see Haptic feedback.
All four are optional; a document without the fields is drawn as before, except the rule that needs none: a modal sheet follows the finger down.
longPressandlongPressLabel: see Long press.Repeater.groupBywith a second child as the header of a group. The first child is the template of a record, as always; the second is drawn before each group, in the scope of the first record of the group (a press inside the header carries that record). Records that follow one another with the same value under the path are one group — absent,nulland""are one value, and1and"1"are two. The groups are made from the records that are drawn (afterwhereandlimit), in the order of the data: the renderer never sorts, so the same day after another one is a new group. Headers and records stand in one flow with onegap; a header hidden by its condition takes no room; a numbered item counts the records only. A list in a grid or a row is drawn without headers.- A sheet follows the finger down. A modal screen shown as a sheet from the bottom
is dragged down by any point of it while its body is scrolled to the top (otherwise the
move scrolls the body back first). The drag starts after 10 px and only when it is more
down than sideways. On release: a fling down (700 px/s) closes, a fling up returns,
otherwise 0.3 of the height decides. It returns in 250 ms, or goes down in 200 ms and
only then performs the same
navigate.backas a tap on the scrim (soonNavigateand thebackshow event fire after the sheet has gone; if youronNavigaterefuses, the sheet comes back). Under "reduce motion" the sheet still follows the finger and the return and the exit are instant. A centred window is not dragged. The renderer sets notouch-action, so the body keeps its own scrolling; a handle drawn by the document is an ordinary node, so the sheet cannot tell it from the rest and always lets a scrolled body scroll back first. dismissible: falseon a modal screen entry: no drag, no tap on the scrim and noEscclose it.navigate.backfrom a button inside the layer does, and so does your own system "back". Only the wordfalselocks a layer; on a page the field is not read.
- the style and the states of the trailing text:
trailing.style,trailing.styleWhen; - a one-line caption under it:
caption,captionStyle,captionStyleWhen; - the colour and the tinted tile of the leading icon:
leading.color,leading.tile; titleMaxLines/subtitleMaxLines.
The package embeds icon set 1.0.0 (READER_ICON_SET_VERSION): 332 names,
drawings from Lucide (ISC) and ten of our own, licence texts in
THIRD-PARTY-NOTICES.md.
- An icon name is document data, so the renderer cannot know which names it
will meet and carries all of them: about 60 KB of compiled JavaScript (18 KB
gzipped) in its own module,
icon-set.js. - A name outside the set draws an empty box of the same size.
- The set version is independent of the schema version: a redrawn icon is a patch of the set.
A placement is a named place of your app — home.banner, promo.raffle-button.
Your code names the place once; which published document stands there is decided
in the HotLayout admin (Placements) and can be changed without a release. The
decision lives in one small file per project, placements/published.json, next
to theme/published.json, readable without a token.
const PROJECT = 'https://firebasestorage.googleapis.com/v0/b/my-bucket/o/orgs%2Facme%2Fprojects%2Fshop%2F'
const placements = {
mapUrl: `${PROJECT}placements%2Fpublished.json?alt=media`,
documentUrl: (documentId: string) => `${PROJECT}documents%2F${documentId}%2Fpublished.json?alt=media`,
}
export function HomeBanner() {
const [shown, setShown] = useState(true)
const onPlacementStatus = (event: PlacementStatusEvent) => {
// Collapse the frame while the placement draws nothing (empty, unknown, document gone…),
// and open it again when a document stands there. The placement stays mounted to hear it.
setShown(event.status === 'document')
}
return (
<section className="banner-frame" hidden={!shown}>
<HotlayoutPlacement name="home.banner" {...placements} onPlacementStatus={onPlacementStatus} locale="en" />
</section>
)
}<HotlayoutPlacement> takes name, mapUrl and documentUrl (how to turn a
document id into its address — the id is always a safe path segment), and every
prop of <HotlayoutScreen> except document and entry (theme, data, locale,
actions…). usePlacement(name, options) is the same without the drawing:
export function PromoButton() {
const placement = usePlacement('promo.raffle-button', { ...placements, lastKnown: false })
if (placement.status !== 'document') return null
// `placement.document` and `placement.entry` go to <HotlayoutScreen> as they are.
return <p>{placement.document.documentId} starts at {placement.entry ?? 'its first screen'}</p>
}placement.status is loading (also all a server render sees), document
(document, documentId, entry — the entry point to start from, or null for
the first screen) or nothing (report says why).
What a placement draws, and what you are told. Nothing is drawn when the place
is empty (declared in the admin, no document), unknown (the map has no such
name: usually a typo), unreadable (the map or this entry of it cannot be read),
documentMissing (the document was unpublished) or documentUnreadable (no
network, not a document, a schema major this build cannot read). entryMissing
is different: the document IS drawn, from its first screen, because the entry
point the admin named is gone — someone has to fix the setup.
onPlacementStatus({ name, status, report, mapStatus, documentId, entry }) is
called on the first answer and on every change of any of these fields — including
the way back: a document drawn again arrives as status: 'document' with
report: null, so a frame you collapsed can open again. A refresh that changes
nothing calls nothing; new content of the same document is not a change.
Where the map comes from. The network first; if it gives nothing, the last
good map this device saw (lastKnown, default localStorage; pass false to
keep nothing, or { read, write } for your own storage), then the copy you ship
(bundledMap, the text of the file). mapStatus says which case it was:
fresh, offline (no answer, a redirect, or no complete answer within 20 s),
corrupt (not JSON, not UTF-8, more than 262 144 bytes or more than 32 nested
levels; one leading byte order mark is fine), unsupported (another major
version), notPublished (the project has no map yet — every name is then
unknown). Your own lastKnown store gets the map address as a key
(read(key), write(text, key)): keep the maps of different projects apart. Only a fresh, readable map from the network becomes the last known
one. A place missing from a fresh map is unknown, never taken from an older
copy: a place removed in the admin must disappear from the phones.
The map is re-read on mount, when the tab becomes visible again
(refreshOnVisible, default on) and, if you set it, every pollIntervalMs (kept
within 5000 … 2147483647; a value that is not a finite number means no polling).
A newer read cancels an older one, and a new name or mapUrl starts again from
loading. Redirects are not followed, and the map and the document each have 20 s
from the start of the request to the end of the body. The browser revalidates the file with its ETag by itself, and the
package sends no custom headers, for the reason given in
Updating a screen without a release. To
get documents from your own client or cache pass loadDocument(documentId, signal); reject with a HotlayoutLoadError of status 404 for a document that is
gone (and drop it from your cache). The same deadline applies, and a value that is
not a document is documentUnreadable. fetch replaces the global fetch for the map and the default document
loader.
The file is untrusted input, like a document. Names are looked up among the
file's own keys only (constructor is a place only if the map lists it,
__proto__ never is); a documentId that is not a plain path segment makes
just its own place unreadable and never reaches documentUrl.
This is the point of server-driven UI: the manager presses Publish, and the screen in your app changes — no build, no deploy. The published document and theme are static JSON files at stable URLs; the app re-reads them.
/** The published layout, re-read whenever the user comes back to the tab. */
export function useLayout(url: string): UiDocument | undefined {
const [layout, setLayout] = useState<UiDocument>()
useEffect(() => {
const controller = new AbortController()
const refresh = () => {
loadDocument(url, { bypassCache: true, signal: controller.signal })
.then((next) => {
// A layout this build cannot read does not replace the one on screen.
if (checkDocumentVersion(next.schemaVersion).kind !== 'incompatible') setLayout(next)
})
.catch(() => undefined) // offline or a bad deploy: keep showing what we have
}
const onVisible = () => {
if (document.visibilityState === 'visible') refresh()
}
refresh()
document.addEventListener('visibilitychange', onVisible)
return () => {
controller.abort()
document.removeEventListener('visibilitychange', onVisible)
}
}, [url])
return layout
}
export function LiveRewards({ url, theme }: { url: string; theme: ThemeContract }) {
const layout = useLayout(url)
return layout === undefined ? <p>Loading…</p> : <HotlayoutScreen document={layout} theme={theme} />
}- A new version of the same document keeps the user where they are. The
screen stack is tied to
documentId, so replacing the object re-draws the current screen with the new layout. (If that screen is no longer in the document, the user lands on the nearest earlier screen of the flow, or at its start.) loadDocument/loadThemesend no custom request headers, on purpose. A header such asCache-Controlmakes the request a preflighted one, and a storage bucket that does not allow it answers with what looks like a network failure (TypeError: Failed to fetch).bypassCachetherefore changes the URL, not the headers.- CORS. In a browser the bucket must allow your origin for
GET. Ifcurlgets 200 for a URL and the browser gets Failed to fetch, that is CORS — the error message ofHotlayoutLoadErrorsays so. - The loaders check the kind of the file, not its content.
loadDocumentrejects withHotlayoutLoadErrorwhen the answer is not JSON (a server that gives an HTML page with status 200 for an unknown path is the usual case — the message names the content type), not a JSON object, or an object withoutschemaVersionandscreens;loadThemedoes the same forthemeContractVersionandthemes. A theme at the document's address, or the other way round, is named as such in the message. Nothing else is validated: versions and shapes are the renderer's business. Handle the rejection — a loader that only stores the result leaves "Loading…" on the screen forever when the address is wrong. - The loaders are optional conveniences. Any way of getting the JSON works: your own API, a server component, a bundled file as the offline fallback.
What the package does not do
- No schema validation. The builder validates on publish, and the renderer
parses every value it turns into CSS or hands to you
(Security model) — but it does not tell you that a
document is invalid. If you accept documents from elsewhere, validate them
with
screen-schema.jsonand any JSON Schema validator at your boundary. - No data fetching, no cache.
dataandcollectionsare yours. - No animation. The contract says what to show; screens appear without transitions, and motion is up to the host.
- No scroll management. The renderer neither scrolls your page on a screen change nor restores positions on "back".
- Fonts and real asset files are not shipped — see Fonts and the
assetsprop. The icon set is the contract's, and the whole of it is embedded (see Icons).
Data and formats
- Navigation params are not screen data.
action.paramsare handed toonNavigate/onAction, not read by bindings. A screen gets its record from the list row that opened it — see One screen, many records — and a screen opened throughentrygets none: put the record intodata. - Only a countdown ticks. "Days left" (
daysUntil) is counted when the screen is rendered, fromnow; nothing re-renders it at midnight. Hours, minutes and seconds until an event arecountdownPart, and a screen that shows one redraws itself (unless you passnow). - Currency is written with its code, after the amount (
120 JOD), in every language, unless the binding namescurrencyDisplay: 'symbol'and / orcurrencyPosition: 'before'; a code the closed table of signs does not have stays a code. Percent and compact notation (1.2K) are not formats either — a percent is written with a template,{percent}%. - Arabic digits depend on the locale you pass. Digits and separators come
from the platform's CLDR data:
ar-EGwrites Arabic-Indic digits, plainarwrites Latin ones in current browsers.
State and interaction
- Carousel state is the renderer's. Which card is open is not in the document and is not reported to the host; it is kept while the screen is in the stack. No animated scrolling is ever started by the renderer.
- Local UI state is not persisted. The open tab, expanded accordions and unbound choices reset when the screen is left.
- Choice groups and options built from own nodes have no arrow-key navigation; each option is a tab stop. A radio group has no accessible name of its own (the contract has no field for one).
Look and placement
- Modal screens are bottom sheets or windows (
modalPlacement) of content height, inside the root element — not a browser-level<dialog>, and not full-viewport unless the root is (withscroll="page"they live in the part of the root visible in the window). A layer has no built-in look: its background, radius and shadow are thestyleof the screen root. - Buttons are as big as the contract says, not bigger. A small button is 28 px high and a small icon button 28 × 28 under the built-in theme. The renderer adds no minimum size; the touch target is an invisible 48 px area around the button that does not take part in layout — leave room around small buttons you place next to your own controls.
- Host
!importantrules get through — see Styles and isolation. - Browsers: current evergreen browsers (CSS logical properties,
:is(),aspect-ratio,inert,Intl.Locale). No Internet Explorer.
The rules the renderer follows are those of the HotLayout contract; the sections above state the ones a host meets. Changes between versions: CHANGELOG.md.
Apache-2.0 — see LICENSE.