UI styling
The style system shared by every UI element: one .style() method, one property vocabulary,
flexbox-only layout (Yoga engine — no CSS grid, no block flow), logical-px units. This page is the
canonical list of style properties and value forms; per-element extras (objectFit, onPressed,
scrollDirection, …) live on the element's own page. Two mechanisms layered on top have their own
pages: theme variables (theme(), var(--x)) and style classes
($name state blocks).
At a glance
const card = UIColumn(
UIText("Title").style({ color: "white", fontSize: 20, fontWeight: 700 }),
)
.style({ bgColor: "#111", borderRadius: 16, p: 16, gap: 8 }) // merge — chainable
.style({ onLandscape: { flexDirection: "row" } }) // responsive override
card.style.opacity = 0.5 // single-prop mutation (hot paths)
card.animateTo({ bgColor: "#333", duration: 200 }) // tween to new values (ms)Three ways to set styles
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, e.g. el.style.transform = translateY(${y}px)``.
Any primitive-valued prop also accepts a () => value function — a reactive binding that
re-applies itself when a signal it read changes:
el.style({ bgColor: () => (selected.value ? "#FF4032" : "#333") })Animating: animateTo / animateFrom
el.animateTo({ opacity: 0, bgColor: "#000", duration: 300, delay?: 0, commit?: true,
loop?: false, loopMode?: "ping-pong" }): this
el.animateFrom({ opacity: 0, duration: 300, delay?: 0 }): this // from given values → currentduration/delayare milliseconds.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.
Looping
badge.animateTo({ transform: "scale(1.15)", duration: 600, loop: true }) // pulse forever
spin.animateTo({ transform: "rotate(360deg)", duration: 900, loop: true, loopMode: "restart" })
alert.animateTo({ opacity: 0.4, duration: 300, loop: 3 }) // 3 cycles, then doneloop: truerepeats forever;loop: nrunsncycles.delayapplies once, before the first cycle.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 it ends back at its base appearance, whatever the mode or count.
- Stopping: a later
animateTo/animateFromon the element replaces the loop (that's the idiom — animate to the resting state to stop a pulse). The loop also stops when the element leaves the screen; it does not resume if the screen comes back from router history.
the options type also allows layer?: number — an escape hatch that targets an
internal style layer (the mechanism behind onPressed/orientation styles). Not part of the
supported surface.
For free-value tweens (numbers you apply yourself), use animate() — see
animate.
Property vocabulary
Layout is flexbox only. Every element is a flex container, position: relative,
flexDirection: column by default. Two exceptions: UIRow flips it to row, and UIButton
defaults to row with children centered on both axes (justifyContent + alignItems
"center") — the icon+label shape; see interactive.md.
Container layout (UIRow / UIColumn / UIScrollable / screens / buttons / widgets)
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"Flex child (all elements)
flex: number | string
flexGrow: number // default 0 — nothing grows without it
flexShrink: number // default 0 — nothing shrinks either
flexBase: UIValue | "auto" // note: flexBase, not flexBasis
alignSelf: "flex-start" | "center" | "flex-end" | "stretch"
aspectRatio: numberflex: 1 expands to flexGrow: 1, flexShrink: 1, flexBase: 0 — the zero basis is the point.
For equal-width children (tab bars, button pairs) put flex: 1 on each: 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.
Size & position (all elements)
width, height: UIValue | "auto"
minWidth, maxWidth,
minHeight, maxHeight: UIValue | "auto"
position: "relative" | "absolute" | "static" // default "relative"
top, left, bottom, right: UIValue | safe-area keyword // offsets; % allowed
inset: UIValue | "auto" // all four at once; % allowed
boxSizing: "border-box" | "content-box" // default "border-box"inset is the shorthand for top/right/bottom/left at one value — the usual way to make an
absolutely-positioned child fill its parent. A longhand always wins over it, whichever is declared
last, so { inset: 0, top: 20 } means "fill, but 20 down from the top":
UIView().style({ position: "absolute", inset: 0 }) // full-bleed overlay
UIView().style({ position: "absolute", inset: "10%" }) // inset by 10% on every edgeunlike 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 containers, UIScreen, UIButton, UIInput, UIVideo. UIText, UIImage, and
UISpacer only have bgColor (plus UIImage's own numeric borderRadius).
bgColor: Color // ≡ backgroundColor; all elements
bgImage: string | FetchResponse | File // ≡ backgroundImage; decoration only
bgSize: "cover" | "contain" | "tile" // ≡ backgroundSize
bgGradient: string // one+ comma-separated linear-gradient()/radial-gradient() layers
border: string | number // "1px solid #333" or bare width
borderWidth: number
borderColor: Color
borderTop / borderRight / borderBottom / borderLeft // per-side shorthands
borderTopWidth / borderTopColor / … // per-side longhands
borderRadius: UIValue | string // string = per-corner "0 0 20 20"
borderTopLeftRadius / borderTopRightRadius /
borderBottomLeftRadius / borderBottomRightRadius: UIValueBackground layers paint bottom-to-top: bgColor → bgImage → bgGradient — so a gradient over
an image makes the usual text-protection scrim:
.style({ bgImage: photo, bgSize: "cover",
bgGradient: "linear-gradient(to top, rgba(0,0,0,0.7), transparent)" })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.
Linear — an optional direction (to <side> / <angle>deg, default to bottom = 180deg)
followed by ≥2 stops:
.style({ bgGradient: "linear-gradient(135deg, #FF4032, #7B2FF7)" })
.style({ bgGradient: "linear-gradient(to right, #000 0%, #333 60%, #fff 100%)" })Radial — an optional [<shape> || <extent>]? [at <position>]? prefix, then ≥2 stops:
.style({ bgGradient: "radial-gradient(#7B2FF7, #0a0a1a)" }) // default: ellipse, farthest-corner, at center
.style({ bgGradient: "radial-gradient(circle at 50% 40%, #7B2FF7, #0a0a1a)" }) // circle, off-center
.style({ bgGradient: "radial-gradient(ellipse closest-side at top left, #fff, #101014)" })
.style({ bgGradient: "radial-gradient(circle 80px at center, #fff, #101014)" }) // explicit radiusThe radial size can be an extent keyword or explicit radii. Both, plus shape and position, are honored on every surface (browser preview, iOS, headless renderer):
| 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 browser preview renders every CSS radial form exactly (it's raw CSS).
On device (iOS) and in the headless renderer the fields above are resolved 1:1. Rarer CSS
forms outside this table (e.g. 4-value at left 10% top 20% positions) fall back to a centered
default there while the preview stays exact. conic-gradient() is not supported.
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:
.style({ bgColor: "#0a0a1a", bgGradient:
"radial-gradient(circle at 15% 20%, #7B2FF7, transparent 60%), " +
"radial-gradient(circle at 85% 80%, #2FB0F7, transparent 55%)" })Linear and radial layers can be mixed, each honors the full syntax above, and the stack is composited on every surface (browser preview, iOS, headless renderer) — 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.
Everything else (all elements)
opacity: number | string // 0..1
transform: string // CSS-style string, e.g. `translateY(12px)` — great for gestures
display: "none" | "flex" // "none" removes it from layout
overflow: "visible" | "hidden" // default "hidden" — children are clipped
pointerEvents: "all" | "none" // drawable elements; "none" lets taps pass throughValues & 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) — there is NO style inheritance
"calc(100vw - 32px)"
"min(...)" "max(...)" "clamp(min, val, max)"
"var(--name)" // theme variable, optional fallback "var(--name, 16px)" — see theme.mdemresolves against the element's ownfontSize, never a parent's — setfontSizeon the same element oremmeans14px-relative.calc()/min()/max()/clamp()combine px, viewport units,em, and safe-area keywords, and nest freely — but do not support%.
width: "calc(100% - 20px)" // ✗ % can't appear inside calc
width: "calc(100vw - 20px)" // ✓ use viewport units insteadColors (UI styles only)
UI color strings are parsed by the UI engine and accept more than the engine-side
ColorInput (conventions):
"#f33" "#f33c" "#ff3333" "#ff3333cc" // hex, 3/4/6/8 digits
"rgb(255, 51, 51)" "rgba(255, 51, 51, 0.8)"
0xff3333 // packed int
// named — exactly this set, nothing more:
"white" "black" "red" "green" "blue" "yellow" "orange" "purple"
"gray" "cyan" "magenta" "brown" "transparent" "clear"this parser exists only for UI styles. 2D/3D APIs (Sprite.color,
Material, Scene2D background) accept hex strings and packed ints only — rgba(...) and
named colors silently become opaque black there.
Safe areas & comfort
Two families of edge keywords, 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.comfort-top/comfort-bottom/comfort-left/comfort-right— where content should comfortably start. On a clearance inset (iOS notch/home indicator, gesture nav — 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; the 3-button navigation bar — 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 + 4). Knob defaults: 12 logical px top, 4 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():
bar.style({ pt: 8, pb: "comfort-bottom" }) // tab bar: 34 over a home indicator, 52 above Android's button bar, 4 on inset-less devices
page.style({ px: "comfort-x" }) // page gutter that also clears the notch in landscape
list.style({ pb: "calc(comfort-bottom + 56px)" }) // scroll content clearing a 56px floating barTune 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, system keys (color, fontFamily, primaryColor, the UITabs bar,
comfort knobs), live re-theming, and replaceTransition.
const T = theme({ accent: "#15A34A" }) // T.accent === "var(--accent)"
label.style({ color: T.accent })Responsive: onLandscape / onPortrait
Any style object can nest orientation overrides; they merge on top when the device is in that orientation and lift off when it leaves:
screen.style({ flexDirection: "column", p: 16, onLandscape: { flexDirection: "row", p: 32 } })Style classes — $name
A $-prefixed key inside .style() declares a named style state — like onPressed, but with
any name and driven by you through the el.class proxy. Classes cascade to descendants (one
toggle restyles a whole composite control) and two names are system-toggled: $pressed and
$focused. The canonical page is classes.md — declaring, the el.class contract,
the cascade rules, reserved classes, and precedence.
const toggle = UIButton(UIText("Dark mode")).style({
bgColor: "#222",
$checked: { bgColor: "#FF4032", duration: 150 },
})
toggle.onClick(() => toggle.class.checked = !toggle.class.checked)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: "hidden"— children are clipped to the parent's box.position: "relative",boxSizing: "border-box"(width/height include padding + border).- The screen background is black and default
fontSizeis 14 — always setbgColorand 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 100vwSee also
- UI element model — factories, children, refs,
onLayout - Theme — app-wide variables,
var(), live re-theming - Style classes —
$namestates, cascade,$pressed/$focused - Containers — where container layout props apply
- Content elements — text styles,
objectFit,tintColor - animate — free-value tweens and easings