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

TypeScript
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 Animation

Set 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:

TypeScript
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:

TypeScript
UIText("Hi").style({ color: "white" }).style({ fontSize: 20 })   // both apply

Use 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:

TypeScript
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.

Note

.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).

TypeScript
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 lands

An 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.

TypeScript
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 → current
  • duration / delay are milliseconds; duration defaults to 225.
  • easing defaults to cubic-bezier(0.42, 0, 0.58, 1) — the CSS ease-in-out curve, 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 of inOutQuad, a close but different curve.) Uniform motion is a choice to write down: easing: "linear". Only animate() (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 unless times: [0, 0.2, 1] places them.
  • animateTo tweens 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). Pass commit: false to animate without writing the style — e.g. fading out an overlay right before .hide().
  • animateFrom is 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 Animation handle — pause() / resume() / seek() / cancel() / finish(), time, rate, and finished (a promise: true at the end, false when cancelled or replayed; a tween whose props a later one took over still runs its clock and resolves true). 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 follow Time.scale.

To repeat an animation — a badge that pulses, a dot that blinks, a spinner — pass loop:

TypeScript
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: true repeats forever; loop: n runs n cycles. The delay is 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 / animateFrom of the same props takes over the loop (that's the idiom — animate to the resting state to stop a pulse). A loop: true handle runs on without its props — playing stays true, finished never settles — until you cancel() 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.
Note

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:

TypeScript
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"
TypeScript
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:

TypeScript
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: number

For 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:

TypeScript
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()
Not implemented

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:

TypeScript
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:

TypeScript
position:                 "relative" | "absolute" | "static"    // default "relative"
top, left, bottom, right: UIValue | safe-area keyword           // offsets; % allowed
inset:                    UIValue | "auto"                      // all four at once; % allowed

An 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:

TypeScript
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.

Note

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, …):

TypeScript
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).

TypeScript
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: UIValue

A 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:

TypeScript
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
TypeScript
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:

TypeScript
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:

TypeScript
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 radius

The 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
Fidelity

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:

TypeScript
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.

Note

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:

TypeScript
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 through

transform 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)

TypeScript
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.md
  • em resolves against the element's own fontSize. Text styles are typed on UIText and UIInput only, so on a container em means 14px-relative.
  • calc() / min() / max() / clamp() combine px, viewport units, em, safe-area keywords and var(), and nest freely — but do not support %: a % inside is read as px.
TypeScript
width: "calc(100% - 20px)"   // ✗ = 80px — the % is read as px
width: "calc(100vw - 20px)"  // ✓ use viewport units instead

Colors

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:

TypeScript
"#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.md
  • green is CSS green, #008000 (it used to be #00ff00 in UI styles — that color is lime).
  • 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 0xff333380 too (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 in theme() 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 with pb: "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 — so comfort-left/comfort-right double 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():

TypeScript
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:

TypeScript
theme({ "comfort-left": 20, "comfort-right": 20 })   // this app's gutter is 20

Theme 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.

TypeScript
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 are UIScrollable, UIVirtualizedList and UIPager (they default flexShrink: 1 so they shrink/scroll instead of overflowing) — but wrapping containers between them and the screen still need flexShrink: 1 by 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 or alignSelf to opt out.
  • overflow: "visible" — nothing clips: a child bigger than its parent draws outside it until the parent sets overflow: "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 fontSize is 14, and unstyled text takes the host's default color — hosts differ (white on the phones, black in headless renders). Always set bgColor and a text color explicitly.

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

TypeScript
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 — $name states, the reserved classes, cascade, precedence
  • Containers — where container layout props apply
  • Content elements — text styles, objectFit, tintColor
  • animate — free-value tweens, the Animation handle, easings
  • Timeline — choreography over several elements