LeCodesdocs

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

TypeScript
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

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

TypeScript
el.style({ bgColor: () => (selected.value ? "#FF4032" : "#333") })

Animating: animateTo / animateFrom

TypeScript
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 → current
  • duration / delay are milliseconds.
  • 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.

Looping

TypeScript
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 done
  • loop: true repeats forever; loop: n runs n cycles. delay applies 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 / animateFrom on 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.
Note

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)

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"

Flex child (all elements)

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

flex: 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)

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

TypeScript
UIView().style({ position: "absolute", inset: 0 })         // full-bleed overlay
UIView().style({ position: "absolute", inset: "10%" })     // inset by 10% on every edge
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 (ppadding, ptpaddingTop, …):

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 containers, UIScreen, UIButton, UIInput, UIVideo. UIText, UIImage, and UISpacer only have bgColor (plus UIImage's own numeric borderRadius).

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

Background layers paint bottom-to-top: bgColorbgImagebgGradient — so a gradient over an image makes the usual text-protection scrim:

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

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

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

The 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
Radial fidelity

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:

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

Note

bgImage is decoration behind children. An image that is the content belongs in UIImage — see content.md.

Everything else (all elements)

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

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) — 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.md
  • em resolves against the element's own fontSize, never a parent's — set fontSize on the same element or em means 14px-relative.
  • calc() / min() / max() / clamp() combine px, viewport units, em, and safe-area keywords, and nest freely — but do not support %.
TypeScript
width: "calc(100% - 20px)"   // ✗ % can't appear inside calc
width: "calc(100vw - 20px)"  // ✓ use viewport units instead

Colors (UI styles only)

UI color strings are parsed by the UI engine and accept more than the engine-side ColorInput (conventions):

TypeScript
"#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"
Note

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-rightwhere 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 with pb: "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 — 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
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 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, system keys (color, fontFamily, primaryColor, the UITabs bar, comfort knobs), live re-theming, and replaceTransition.

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

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

TypeScript
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 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: "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 fontSize is 14 — always set bgColor and 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

See also