UI styling
How every UI element looks and lays out: one .style() method, one property vocabulary — flexbox
layout, size, spacing, background, border, opacity, transform, in logical px — and animateTo /
animateFrom to animate it. State styles: classes.md; dark mode: theme.md.
- style a card: background color, rounded corners, padding, a border
- center content, or lay out children in a row with a gap
- make buttons share a row's width equally, or let one child take the rest
- pin a badge to a corner, or cover an element with an overlay
- put a gradient or a photo behind the content
- keep content clear of the notch, the status bar and the home indicator
- change a style when a signal changes
- fade in an element and slide it up (
animateFrom/animateTo) - pulse, blink or spin an element in a loop
At a glance
const card = UIColumn(
UIText("Title").style({ color: "white", fontSize: 20, fontWeight: 700 }),
UIText("Subtitle").style({ color: "#8e8e93" }),
)
.style({ bgColor: "#111", border: "1px solid #333", borderRadius: 16, p: 16, gap: 8 }) // merge — chainable
.style({ $landscape: { flexDirection: "row" } }) // state block — see classes.md
UIScreen(card).style({ bgColor: "black", p: 16, pt: "comfort-top" }).open()
card.style.opacity = 0.5 // single-prop mutation (hot paths)
card.animateTo({ bgColor: "#333", duration: 200 }) // tween to new values (ms); returns an AnimationSet and change styles: .style() and el.style
Set an element's styles with .style() — at creation or any time later — and write or read one
prop through el.style.prop:
el.style({ ... }): this // MERGES into the current style (does not replace); chainable
el.style.prop = value // direct single-property write after creation (hot paths)
el.style.prop // read back the last value you set.style() merging means later calls only override the keys they mention:
UIText("Hi").style({ color: "white" }).style({ fontSize: 20 }) // both applyUse direct mutation for per-frame updates — a drag handler writing el.style.transform.
Bind a style to a signal
To restyle an element when app state changes — a button's color while it is selected — give the
prop a () => value function instead of a value: a reactive binding that re-applies itself
when a signal it read changes. Any primitive-valued prop takes one:
const selected = signal(false)
const chip = UIButton(UIText("Favourite").style({ color: "white" }))
.style({ px: 12, py: 6, borderRadius: 16, bgColor: () => (selected.value ? "#FF4032" : "#333") })
.onClick(() => { selected.value = !selected.value })
UIScreen(chip).style({ bgColor: "black", p: 16, alignItems: "flex-start" }).open()A state that changes several props at once (selected, checked, done) reads better as a $ block
switched by el.class — see classes.md.
.style(obj) takes the () => value entries out of the object you pass — they
become that element's bindings. A shared Style<T> preset holding a binding therefore binds only
the first element it is applied to; build such a preset per element (a function returning it).
A plain value replaces a binding only through .style({ prop: value }): after
el.style.prop = value the binding stays and re-applies on the next signal change.
Animate an element: fade, slide, scale — animateTo / animateFrom
To fade an element in, slide it into place or move any style to new values over time, call
animateFrom (an entrance: from the values you give back to the element's own style) or
animateTo (from the current values to new ones). animateTo / animateFrom animate one
element's style; animate() tweens free values you apply yourself
(animate); Timeline choreographs several targets on one clock
(Timeline).
const card = UIColumn(
UIText("Welcome back").style({ color: "white", fontSize: 20 }),
).style({ bgColor: "#1c1c1e", borderRadius: 16, p: 16 })
UIScreen(card).style({ bgColor: "black", p: 16, justifyContent: "center" }).open()
// fade in while sliding up 24px: from these values back to the card's own style
card.animateFrom({ opacity: 0, transform: "translateY(24px)", duration: 400 })
.finished.then((done) => console.log("card in place:", done)) // true when it landsAn element that waits hidden — a toast, a banner — is styled hidden (opacity: 0, transform: "translateY(24px)") and shown with animateTo({ opacity: 1, transform: "translateY(0px)" }),
which writes the new values into its style. A transform tweens between the same function list:
to "translateY(0px)" it slides on every host; to "none" it jumps everywhere but on the web
(Web Lite), whose browser slides it.
el.animateTo({ opacity: 0, bgColor: "#000", duration: 300, delay?: 0, easing?, commit?: true,
loop?: false, loopMode?: "ping-pong", clock?: "ui" }): Animation
el.animateFrom({ opacity: 0, duration: 300, delay?: 0 }): Animation // from given values → currentduration/delayare milliseconds;durationdefaults to 225.easingdefaults tocubic-bezier(0.42, 0, 0.58, 1)— the CSSease-in-outcurve, the one UIKit transitions ease with — on every tween with a target: elements, 3D and 2D objects,Timeline.add. (The name"ease-in-out"is an alias ofinOutQuad, a close but different curve.) Uniform motion is a choice to write down:easing: "linear". Onlyanimate()(a raw 0..1 progress the app maps itself) stays linear by default. A per-prop map works too:easing: { opacity: "linear", transform: "outBack" }.- A prop given as an array is a list of keyframes (
opacity: [1, 0.3, 1]), evenly spaced unlesstimes: [0, 0.2, 1]places them. animateTotweens from the current values to the given ones, and commits the targets into the element's style immediately (so reads and later merges see the final state). Passcommit: falseto animate without writing the style — e.g. fading out an overlay right before.hide().animateFromis the entry animation: it snaps to the given values and animates back to the element's current style. It never modifies the stored style.- Both return the
Animationhandle —pause()/resume()/seek()/cancel()/finish(),time,rate, andfinished(a promise:trueat the end,falsewhen cancelled or replayed; a tween whose props a later one took over still runs its clock and resolvestrue). They return the handle, not the element, so they end a chain. - A UI tween runs on the UI clock (wall time — it keeps running while the game is paused);
clock: "game"makes it followTime.scale.
Pulse, blink or spin in a loop
To repeat an animation — a badge that pulses, a dot that blinks, a spinner — pass loop:
const badge = UIText("3").style({ color: "white", bgColor: "#FF4032", px: 7, py: 2, borderRadius: 10 })
const spinner = UIColumn().style({ width: 20, height: 20, borderRadius: 4, bgColor: "#0A84FF" })
const inbox = UIButton(UIText("Inbox").style({ color: "white" }), badge, spinner).style({ gap: 8, p: 12 })
UIScreen(inbox).style({ bgColor: "black", p: 16, alignItems: "flex-start" }).open()
// pulse: to 1.15 and back, forever (a blink is the same on `opacity`)
const pulse = badge.animateTo({ transform: "scale(1.15)", duration: 600, loop: true })
// spin: a full turn, then again from 0, at an even speed
spinner.animateTo({ transform: "rotate(360deg)", duration: 900, loop: true, loopMode: "restart", easing: "linear" })
inbox.onClick(() => {
badge.animateTo({ transform: "scale(1)", duration: 150 }) // takes `transform` over: the pulse stops
pulse.cancel() // and ends the loop's handle
})loop: truerepeats forever;loop: nrunsncycles. Thedelayis part of each cycle — it repeats with the loop.loopMode: "ping-pong"(the default) animates to the targets and back each cycle — no visual jump."restart"snaps back and replays forward — for full-turn spinners and shimmers.- A looping animation never commits — it is an effect, not a state change. The element's stored style is untouched and a counted loop ends back at its base appearance, whatever the mode.
- Stopping: a prop belongs to the newest animation that names it, so a later
animateTo/animateFromof the same props takes over the loop (that's the idiom — animate to the resting state to stop a pulse). Aloop: truehandle runs on without its props —playingstaystrue,finishednever settles — until youcancel()it, as above.cancel()alone stops the loop where it is, mid-pulse. The loop also stops when its screen is covered or closed; it does not resume if the screen comes back from router history.
the options type also allows layer?: number — a legacy key that nothing reads.
Property vocabulary
Layout is flexbox only (the Yoga engine — no CSS grid, no block flow). Every element is a flex
container, position: relative, flexDirection: column by default. The exceptions: UIRow flips
it to row, UIBox centers its children on both axes, and UIButton defaults to row with
children centered on both axes (justifyContent + alignItems "center") — the icon+label
shape; see interactive.md. This page lists what every element shares;
per-element props (objectFit, rippleColor, scrollDirection, …) are on the element's own page.
Center and arrange children: row, column, gap (containers)
To center content, or to lay children out in a row or a column with a gap between them, style
their container — UIRow / UIColumn / UIScrollable, a screen, a button, a widget.
justifyContent places the children along the direction, alignItems across it:
flexDirection: "row" | "column"
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-evenly"
alignItems: "flex-start" | "center" | "flex-end" | "stretch" // default "stretch"
gap: UIValue
flexWrap: "nowrap" | "wrap" | "wrap-reverse"const avatar = UIColumn().style({ width: 40, height: 40, borderRadius: 20, bgColor: "#0A84FF" })
const row = UIRow(avatar, UIText("Ada Lovelace").style({ color: "white", fontSize: 17 }))
.style({ gap: 12, alignItems: "center" }) // side by side, 12 apart, centered vertically
UIScreen(row)
.style({ bgColor: "black", justifyContent: "center", alignItems: "center" }) // in the middle of the screen
.open()Equal widths, grow and shrink: flex (all elements)
How a child shares its row or column with its siblings — equal widths, filling the rest, keeping its own size:
flex: number | string // CSS shorthand: grow [shrink] [basis] — "0 0 120px", "1 1 auto"
flexGrow: number // default 0 — nothing grows without it
flexShrink: number // default 0 — nothing shrinks either
alignSelf: "flex-start" | "center" | "flex-end" | "stretch"
aspectRatio: numberFor equal-width children (tab bars, button pairs) put flex: 1 on each: it expands to grow 1,
shrink 1, basis 0 — the zero basis is the point, so they split the whole axis evenly. flexGrow: 1
alone distributes only the leftover space on top of content-sized bases, so the child with the
longer label stays wider — that is the one for a child that takes whatever the others leave:
const tab = (label: string) =>
UIButton(UIText(label).style({ color: "white" })).style({ flex: 1, py: 10, bgColor: "#2c2c2e", borderRadius: 8 })
const tabs = UIRow(tab("Day"), tab("Week"), tab("Month view")).style({ gap: 8 }) // three equal widths
const header = UIRow(
UIText("Settings").style({ flexGrow: 1, color: "white", fontSize: 22 }), // takes what the button leaves
UIButton(UIText("Done").style({ color: "#0A84FF" })),
).style({ alignItems: "center" })
UIScreen(header, tabs).style({ bgColor: "black", p: 16, gap: 16 }).open()flexBase is in the style type, but the UI core has no such property — it
is ignored (the basis stays auto). Set a basis through the flex string: flex: "0 0 120px".
Size (all elements)
How big an element is, and its limits:
width, height: UIValue | "auto"
minWidth, maxWidth,
minHeight, maxHeight: UIValue | "auto"
boxSizing: "border-box" | "content-box" // default "border-box"Pin to a corner or overlay: absolute position (all elements)
To float an element over its siblings — a badge pinned to a corner, an overlay over a card — make
it position: "absolute" and place it with the offsets:
position: "relative" | "absolute" | "static" // default "relative"
top, left, bottom, right: UIValue | safe-area keyword // offsets; % allowed
inset: UIValue | "auto" // all four at once; % allowedAn absolute child is placed by its offsets inside its parent and takes no room in the parent's
layout. inset is the shorthand for top/right/bottom/left at one value — the usual way to
make an absolutely-positioned child fill its parent:
const overlay = UIColumn().style({ position: "absolute", inset: 0, bgColor: "rgba(0,0,0,0.4)" }) // covers the whole card
const badge = UIText("NEW").style({ position: "absolute", top: 8, right: 8, color: "white", fontSize: 12,
bgColor: "#FF4032", px: 6, py: 2, borderRadius: 6 }) // pinned to the top-right corner
const card = UIColumn(overlay, badge).style({ height: 160, bgColor: "#2c2c2e", borderRadius: 12, overflow: "hidden" })
UIScreen(card).style({ bgColor: "black", p: 16 }).open()A longhand always wins over inset, whichever is declared last: { inset: 0, top: 20 } means
"fill, but 20 down from the top"; inset: "10%" insets every edge by 10% of the parent.
unlike CSS, inset takes a single value — there is no inset: "0 10" multi-value
form, matching padding/margin in this SDK. Use the longhands for per-edge offsets.
Padding & margin (all elements)
Shorthands and long forms are interchangeable (p ≡ padding, pt ≡ paddingTop, …):
p (padding) px (paddingHorizontal) py (paddingVertical)
pt pb pl pr // per side
m (margin) — also accepts "auto" mx (marginHorizontal) my (marginVertical)
mt mb ml mr — also accept "auto"mx: "auto" centers a fixed-width element; a single "auto" margin pushes it to the far side.
Background & border (drawable elements)
Available on every element except UIImage and UISpacer — containers, UIScreen, UIButton,
UIText (a text chip is one UIText with padding, bgColor and a radius), UIInput, UIVideo,
widgets. UIImage and UISpacer only have bgColor (plus UIImage's own numeric
borderRadius).
bgColor: Color // ≡ backgroundColor; all elements
bgImage: string | FetchResponse | File | SvgSource // ≡ backgroundImage; decoration only
bgSize: "cover" | "contain" | "fill" | "tile" // ≡ backgroundSize; default "cover"
bgGradient: string // ≡ backgroundGradient; one+ comma-separated linear-gradient()/radial-gradient() layers
border: string | number // "1px solid #333"; "red" = 1px red; 2 = 2px, transparent
borderWidth: number
borderColor: Color
borderTop / borderRight / borderBottom / borderLeft // per-side shorthands
borderTopWidth / borderTopColor / … // per-side longhands
borderRadius: UIValue | string // one value, or "TL TR BR BL" (below)
borderTopLeftRadius / borderTopRightRadius /
borderBottomLeftRadius / borderBottomRightRadius: UIValueA border shorthand always writes both its width and its color: a bare number comes with a
transparent color, a color alone with a 1px width. So whichever of border / borderColor
is written later wins, like CSS — { border: 2, borderColor: "#333" }, not the reverse.
The string form of borderRadius rounds some corners at one shared radius — "0 0 20 20" (CSS
order: top-left, top-right, bottom-right, bottom-left), each value 0 or the same radius. Two
different radii in it are rejected (a warning, nothing applied): give corners their own radius
through the longhands.
Background layers paint bottom-to-top: bgColor → bgImage → bgGradient — so a gradient over
a photo makes the usual text-protection scrim:
const hero = UIColumn(
UIText("Lisbon").style({ color: "white", fontSize: 28, fontWeight: 700 }),
).style({
height: 220, p: 16, justifyContent: "flex-end", borderRadius: 16,
bgImage: asset("./lisbon.jpg"), bgSize: "cover", // the photo, cropped to fill
bgGradient: "linear-gradient(to top, rgba(0,0,0,0.7), transparent)", // a scrim over it, under the text
})
UIScreen(hero).style({ bgColor: "black", p: 16 }).open()bgImage takes a URL or an asset() file, a fetch response, a picked File, or an SVG source —
asset('./pattern.svg'), assetIcon(...). An SVG is drawn at the size it is shown at, so it stays
sharp at any box size. The source's own tint (assetIcon(id, { color: token })) is not applied to
a background: bake the color into the SVG, or use a UIImage.
bgSize — how the image fills the box, clipped to the box and its radius:
| Value | The image |
|---|---|
"cover" |
scaled to cover the box, keeping its aspect; the overflow is cropped (centered) |
"contain" |
scaled to fit inside the box, keeping its aspect (centered) |
"fill" |
stretched to the box |
"tile" |
repeated at its own size from the top-left corner — the size of a raster in pixels, of an SVG its width / height, read as logical px |
UIScreen(UIText("Notes").style({ color: "#333", fontSize: 22 }))
.style({ p: 16, bgColor: "#fffdf8", bgImage: asset("./dots.svg"), bgSize: "tile" })
.open()Gradients (bgGradient)
bgGradient takes a CSS gradient string — linear-gradient() or radial-gradient(). Color stops
accept any Color (hex, rgb()/rgba(), named, transparent) with optional % positions (a
% only — no px stops, no color hints).
Linear — an optional direction (to <side> / <angle>deg — degrees only; default to bottom
= 180deg) followed by ≥2 stops:
bgGradient: "linear-gradient(135deg, #FF4032, #7B2FF7)"
bgGradient: "linear-gradient(to right, #000 0%, #333 60%, #fff 100%)"Radial — an optional [<shape> || <extent>]? [at <position>]? prefix, then ≥2 stops:
bgGradient: "radial-gradient(#7B2FF7, #0a0a1a)" // default: ellipse, farthest-corner, at center
bgGradient: "radial-gradient(circle at 50% 40%, #7B2FF7, #0a0a1a)" // circle, off-center
bgGradient: "radial-gradient(ellipse closest-side at top left, #fff, #101014)"
bgGradient: "radial-gradient(circle 80px at center, #fff, #101014)" // explicit radiusThe radial size can be an extent keyword or explicit radii:
| Field | Values | Default |
|---|---|---|
| shape | circle | ellipse |
ellipse |
| extent | closest-side | closest-corner | farthest-side | farthest-corner |
farthest-corner |
| explicit radii | circle <len> or ellipse <len-or-%>{2} — lengths are px/em/vw/calc(), or % of the box axis |
— |
at <position> |
keywords (center, top, left, bottom right, …) or % (20% 80%) |
center |
the UI core parses the string, the same on every host but Web Lite (which hands it
to the browser's CSS): the fields above resolve 1:1; a rarer position form (e.g. 4-value
at left 10% top 20%) falls back to the center. conic-gradient(), px stop positions and color
hints are not supported — such a gradient is dropped, and an error naming the reason is logged.
Layering multiple gradients. Comma-separate several gradients (like CSS background-image) to
stack them — the first listed paints on top, so lead with the ones that have transparent regions:
UIScreen(UIText("Aurora").style({ color: "white", fontSize: 32 }))
.style({ alignItems: "center", justifyContent: "center", bgColor: "#0a0a1a", bgGradient:
"radial-gradient(circle at 15% 20%, #7B2FF7, transparent 60%), " +
"radial-gradient(circle at 85% 80%, #2FB0F7, transparent 55%)" })
.open()Linear and radial layers can be mixed, each honors the full syntax above, and the stack is composited on every host — a common way to build soft multi-glow or "aurora" backgrounds.
bgImage is decoration behind children. An image that is the content belongs in
UIImage — see content.md.
Opacity, transform, hiding, clipping (all elements)
How an element is drawn — see-through, moved, scaled or rotated without changing the layout — and whether it shows at all or clips its children:
opacity: number // 0..1
transform: string // CSS-style list, e.g. `translateY(12px)`, `translate(-50%, -50%)`
transformOrigin: string // pivot of `transform`: "50% 50%" (default) / "left top" / "0 12px"
display: "none" | "flex" // "none" removes it from layout
overflow: "visible" | "hidden" // default "visible"; "hidden" clips the children to the box
pointerEvents: "all" | "none" // drawable elements; "none" lets taps pass throughtransform takes translate(x, y) / translateX() / translateY() (px, or % of the element's
own box), scale(s) / scale(x, y) / scaleX() / scaleY() and rotate() (deg, or rad),
space-separated and composed as CSS composes them — translate(10px) rotate(45deg) rotates the
element in place and then moves it; rotate(45deg) translate(10px) moves it along the rotated
axis. Any other function is skipped. A percent stays a percent: translate(-50%, -50%)
centres an element on its top-left corner whatever its size, and follows a resize — the host
measures it against the laid-out box when it draws, like transformOrigin. transformOrigin is one
or two values — keywords, % of the box, or px.
Nothing clips by default: a child larger than its parent draws outside it unless the parent has
overflow: "hidden" (clipped to the rounded box when it has a borderRadius). Scroll containers
always clip.
Values & units (UIValue)
16 // bare number = logical px (the default unit everywhere)
"50%" // percent of the parent
"50vw" "50vh" // viewport width/height
"50vmin" "50vmax"
"1.5em" // × the element's own fontSize (default 14)
"calc(100vw - 32px)"
"min(24px, 5vw)" "max(safe-top, 24px)" "clamp(12px, 4vw, 20px)"
"var(--name)" // theme variable, optional fallback "var(--name, 16px)" — see theme.mdemresolves against the element's ownfontSize. Text styles are typed onUITextandUIInputonly, so on a containeremmeans14px-relative.calc()/min()/max()/clamp()combine px, viewport units,em, safe-area keywords andvar(), and nest freely — but do not support%: a%inside is read as px.
width: "calc(100% - 20px)" // ✗ = 80px — the % is read as px
width: "calc(100vw - 20px)" // ✓ use viewport units insteadColors
Every color-valued style (color, bgColor / backgroundColor, border*Color, tintColor,
overlayColor, placeholderColor, refreshControlColor, rippleColor) takes the same
ColorInput as the rest of the SDK (Color) plus theme references — one parser,
the same on every host:
"#f33" "#f33c" "#ff3333" "#ff3333cc" // hex, 3/4/6/8 digits ('#' required)
"rgb(255, 51, 51)" "rgba(255 51 51 / 80%)" // rgb() / rgba(), comma or space syntax
"hsl(210 50% 40%)" "hsla(210, 50%, 40%, .5)" // hsl() / hsla()
"red" "rebeccapurple" "Green" // the 148 CSS color names, case-insensitive
"transparent" "clear" // (0,0,0,0) — `clear` is an alias of transparent
0xff3333 // number = opaque 0xRRGGBB; above 0xFFFFFF throws
[1, 0.2, 0.2] [1, 0.2, 0.2, 0.8] // floats 0..1
"var(--accent)" "var(--accent, #0A84FF)" // theme variable — see theme.mdgreenis CSS green,#008000(it used to be#00ff00in UI styles — that color islime).- A number or an array that is not a color throws at the write, naming the key and the value —
a number with alpha such as
0xff333380too (write alpha as"#ff333380"or[r, g, b, a]). A string is the UI core's to parse, as in CSS: one it can't read is transparent, no error. - Reads (
el.style.bgColor) answer what you set, in the form you set it. - In
animateTo, an array on a color key is a keyframe list (bgColor: ["#f00", "#00f"]) — pass a float color as one value through a string (Color.toHexString([1, 0, 0])). - A theme color is a string (
theme({ accent: "#0A84FF" })) — numbers intheme()are px lengths (theme.md). - Colors inside compound strings —
border: "1px solid red",bgGradient,background— are parsed by the UI engine with the same grammar.
Safe areas & comfort
To keep content clear of the notch, the status bar and the home indicator, pad it with the edge keywords — two families, split by owner:
safe-top/safe-bottom/safe-left/safe-right— the device's raw insets (notch, home indicator). Facts about the hardware; 0 on devices without them and on the desktop host.comfort-top/comfort-bottom/comfort-left/comfort-right— where content should comfortably start. On a clearance inset (iOS notch/home indicator — breathing room already built in) it's the inset floored by an app-tunable knob,max(safe-edge, knob). On an exact-height system bar (Android's status bar and its navigation bar, buttons or gestures — content at the inset touches the chrome) the knob adds past it,safe-edge + knob. The host declares which edges are bars, so the same screen resolves to the platform-correct value everywhere: a tab bar withpb: "comfort-bottom"sits flush over an iOS home indicator (34) but keeps a gap above Android's button bar (48 + 8) and its gesture handle (16 + 8). Knob defaults: 12 logical px top, 8 bottom, 16 horizontally — socomfort-left/comfort-rightdouble as the page gutter, and in landscape the notch automatically wins.
Pair and all-edge forms (each side resolves independently — a landscape notch differs left vs
right): comfort-x on px/mx, comfort-y on py/my, comfort-all and safe-all on the
p/m shorthands only.
Where they're accepted: per-side padding & margin, position offsets (top/left/bottom/
right), and inside calc()/min()/max():
const title = UIText("Inbox").style({ color: "white", fontSize: 22, fontWeight: 700, px: "comfort-x" })
const list = UIScrollable(
UIText("No messages yet").style({ color: "#8e8e93" }),
).style({ flexGrow: 1, px: "comfort-x" }) // the page gutter; it also clears the notch in landscape
const tabBar = UIRow(
UIText("Inbox").style({ color: "white" }),
UIText("Settings").style({ color: "white" }),
).style({ justifyContent: "space-evenly", pt: 8, pb: "comfort-bottom", bgColor: "#1c1c1e" })
// pb: 34 over an iPhone's home indicator, 48 + 8 above Android's button bar, 8 with no inset
UIScreen(title, list, tabBar).style({ bgColor: "black", pt: "comfort-top" }).open()Inside calc(): pb: "calc(comfort-bottom + 56px)" keeps a scroll list's last row clear of a
56px floating bar.
Tune the knobs (or any edge) through the theme:
theme({ "comfort-left": 20, "comfort-right": 20 }) // this app's gutter is 20Theme variables — theme()
Any style value can be a theme variable — "var(--accent)" — resolved live from the one
app-wide table theme() maintains; re-calling theme() restyles the running UI in place (dark
mode is a second call). The canonical page is theme.md — defining variables, the
accessor pattern, the roles the SDK reads (bg, surface, border, text, textMuted,
accent, fontFamily), el.theme() scopes, comfort knobs, live re-theming, and
replaceTransition.
const T = theme({ accent: "#15A34A" }) // T.accent === "var(--accent)"
const done = UIText("Done").style({ color: T.accent })States — $ classes
Style that applies only in some state — pressed, hovered, focused, landscape / portrait, or one
of your own (selected, checked) — goes in a $-prefixed block inside .style(). The canonical
page is classes.md: the reserved classes and their scope, the el.class proxy, the
cascade, precedence, and a block's duration / delay / easing.
Defaults that surprise
flexShrink: 0— elements don't shrink to fit. The exceptions areUIScrollable,UIVirtualizedListandUIPager(they defaultflexShrink: 1so they shrink/scroll instead of overflowing) — but wrapping containers between them and the screen still needflexShrink: 1by hand.flexGrow: 0— nothing grows along the main axis without asking; there are no implicit min-sizes either.alignItems: "stretch"— children fill the cross axis by default; set a size oralignSelfto opt out.overflow: "visible"— nothing clips: a child bigger than its parent draws outside it until the parent setsoverflow: "hidden".position: "relative",boxSizing: "border-box"(width/height include padding + border).- A screen has no background of its own (the host's backdrop shows: black on iOS, near-black on
the desktop host, transparent in a headless PNG), the default
fontSizeis 14, and unstyled text takes the host's default color — hosts differ (white on the phones, black in headless renders). Always setbgColorand a textcolorexplicitly.
Logical px & the design canvas
All units are logical px (iOS pt / Android dp), never physical pixels — fontSize: 16 looks the
same on every device. Design for a phone canvas of width ~360–430 (390 is a good default) and
height ~670–930; vertical space is scarce on SE-class screens, so long content belongs in a
UIScrollable body (screens themselves never scroll).
Pitfalls
display: "grid" // ✗ flexbox only (Yoga)
lineHeight: 1.5 // ✗ a number is px (= 1.5px); a multiplier is "1.5em"
node.style({ width: "calc(100% - 20px)" }) // ✗ no % inside calc — use 100vw
UIColumn(card.animateTo({ opacity: 1 })) // ✗ animateTo returns the Animation, not the card
el.animateTo({ transform: "none" }) // ✗ jumps (but on the web) — tween to "translateY(0px)"See also
- UI element model — factories, children, refs,
onLayout - Theme — app-wide variables,
var(), live re-theming - Style classes —
$namestates, the reserved classes, cascade, precedence - Containers — where container layout props apply
- Content elements — text styles,
objectFit,tintColor - animate — free-value tweens, the
Animationhandle, easings - Timeline — choreography over several elements