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.
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 }): 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.
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 (UIRow flips it to row).
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: numberSize & 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
boxSizing: "border-box" | "content-box" // default "border-box"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)"emresolves 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
Keyword values that resolve to the device's safe insets (notch, home indicator). The exact set:
safe-top safe-bottom safe-left safe-right
safe-top-comfort safe-bottom-comfort safe-left-comfort safe-right-comfort
safe-all safe-all-comfort // only on the p / m (padding / margin) shorthandsWhere they're accepted:
- padding per-side (
pt,paddingBottom, …) — the side-matching keyword;p/paddingtakessafe-all - margin per-side and
m/margin— same set (alongside"auto") - position offsets
top/left/bottom/right— the side-matching keyword - inside
calc()/min()/max()— the canonical header pattern:
.style({ pt: "max(safe-top, 24px)" }) // at least 24px even on inset-less devicesThe -comfort variants never collapse to zero: they guarantee comfortable breathing room
(≥ 12 logical px vertically by default) even when the raw inset is 0 — use them for content that
would otherwise touch the screen edge on old devices.
Responsive: onLandscape / onPortait
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 } })the portrait key really is spelled onPortait (missing the second "r") — that is the
actual API name.
Style classes
A style class is a named style state you declare inline and toggle from code — like onPressed,
but with any name and driven by you, not the system. Use it for persistent states: selected,
checked, active, expanded.
Declare a class with a $-prefixed key inside .style(); its block holds the overrides (plus an
optional transition duration / delay). Activate or deactivate it on the element:
const toggle = UIButton([ UIText("Dark mode") ]).style({
bgColor: "#222",
$checked: { bgColor: "#FF4032", duration: 150 }, // declared like onPressed
})
toggle.onClick(() => toggle.toggleClass("checked")) // flip it
// or set explicitly:
toggle.setClass("checked", true)
toggle.hasClass("checked") // → trueel.setClass(name, enabled): this // activate (true) / deactivate (false) a class on this element
el.toggleClass(name): this // flip it
el.hasClass(name): boolean // is it currently active on this element?- The name is given with or without the leading
$—setClass("checked", …)andsetClass("$checked", …)are equivalent. - Activation is per element: it restyles only the element you call it on, not its children.
- The active set is remembered on the element, so a class stays active across a screen being closed and re-opened.
- The transition (
duration/delay, ms) makes the swap animate, on every platform.
Precedence — when two states set the same prop, the later-declared and more-interactive one wins, in this order (lowest → highest):
base style < $classes (later-declared beats earlier) < onPressed / onFocusedSo a class beats the base style, a class declared later beats one declared earlier, and onPressed
(or onFocused) always beats a class while the element is pressed/focused. Example — a checked
button that still shows press feedback:
UIButton([ UIText("Save") ]).style({
bgColor: "#222",
$checked: { bgColor: "#2a7" }, // shown when checked
onPressed: { bgColor: "#195" }, // wins while the finger is down, even when checked
})to bind a class to a signal instead of toggling it by
hand, a primitive style prop already accepts a () => value binding (see above) — reach for a
class when you want a named, reusable state block rather than a single computed value.
Defaults that surprise
flexShrink: 0— elements don't shrink to fit; aUIScrollablein a column overflows instead of scrolling until it (and any wrapping ancestors) getflexShrink: 1.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 or a scrollable screen.
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 - Containers — where container layout props apply
- Content elements — text styles,
objectFit,tintColor - animate — free-value tweens and easings