LeCodesdocs

Style classes — named states that cascade

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: selected, checked, active, expanded. Declared as a $-prefixed block inside .style(), toggled through the el.class proxy, and — the part that makes composite controls cheap — a class set on an element cascades to all its descendants, lighting up their same-name $ blocks too.

At a glance

A favorite button: one toggle restyles the container, the icon, and the label — each part declares its own reaction, nothing re-renders:

TypeScript
const fav = UIButton(
  UIImage(assetIcon("lucide:heart")).style({ width: 18, height: 18,
    tintColor: "#8a919e", $fav: { tintColor: "#ff453a" } }),
  UIText("Favorite").style({ color: "#8a919e", $fav: { color: "#ff453a" } }),
).style({ height: 36, px: 12, gap: 6, borderRadius: 18,
          bgColor: "#17181c", $fav: { bgColor: "#2a181a", duration: 150 } })

fav.onClick(ev => ev.target.class.fav = !ev.target.class.fav)

Declaring: $name blocks

A $-prefixed key inside any .style() call declares a class; the block holds the overrides, plus an optional transition:

TypeScript
el.style({
  bgColor: "#151515",
  $selected: { bgColor: "#1d2b45", duration: 150 },   // duration/delay (ms) animate the swap
})

Every element in a cascade animates per its own block's duration — the toggle is one write, the transitions are local.

Driving: el.class

el.class is a proxy with the same contract as el.style:

TypeScript
el.class.selected                    // read: is it active on THIS element? → boolean
el.class.selected = true             // activate (false deactivates)
el.class.open = !el.class.open      // toggle
el.class.done = () => sig.value      // reactive binding — re-applies when the signal changes
el.class({ checked: true, done: () => sig.value })   // batch form: returns the element, chainable
Object.keys(el.class)                // the element's active class names
  • The name works with or without the leading $el.class.checked and el.class.$checked are equivalent (handy when copying a key from the declaration).
  • The active set is remembered on the element — a class stays active across a screen being closed and re-opened.
  • The reactive form binds the class to a signal; hand toggling and binding are the same mechanism.

The cascade

A class set on an element is also active on all its descendants — their same-name $ blocks light up too, like a .dark class on <body> in CSS. That's the pattern for any composite control: the parts declare their own reactions, the container carries the one flag.

A tab-bar button, with the highlight bound to the pager's active index — icon and label follow one binding on the button:

TypeScript
const activeTab = signal(0)
pager.onSelect(i => activeTab.value = i)      // taps AND swipes move the highlight

const TabButton = (label: string, icon: string, i: number) => UIButton(
  UIImage(assetIcon(icon)).style({ width: 24, height: 24,
    tintColor: "#98A29C", $active: { tintColor: "#15A34A" } }),
  UIText(label).style({ fontSize: 12, mt: 4, color: "#98A29C", $active: { color: "#15A34A" } }),
)
  .style({ flex: 1, flexDirection: "column", py: 6 })
  .class({ active: () => activeTab.value === i })     // ONE binding drives every part
  .onClick(() => pager.select(i))

Rules of the cascade:

  • Reads reflect only the element's own classes — el.class.active on the icon above is false even while the button's cascade styles it.
  • There is no opt-out: while an ancestor has the class, a descendant's el.class.name = false only clears its own flag.
  • The cascade stops at presentable roots — hosted screens (UIPager pages) and widgets don't inherit from what contains them.

$pressed and $focused

Two class names are reserved and toggled by the system: $pressed while the element is held, $focused while an input has focus. Unlike onPressed/onFocused they cascade — a button's children can restyle during its press:

TypeScript
UIButton(
  UIImage(icon).style({ $pressed: { opacity: 0.5 } }),
  UIText("Buy").style({ $pressed: { color: "#999" } }),
).style({ $pressed: { transform: "scale(0.97)" } })

Prefer $pressed/$focused in new code — one vocabulary for the element and its children. onPressed/onFocused remain the per-element, non-cascading forms; they never light up from an ancestor, which is what you want on nested interactives (a button inside a clickable card shouldn't "press" with the card).

Precedence

When two states set the same property, the later-declared and more-interactive one wins, lowest to highest:

TypeScript
base style  <  $classes (later-declared beats earlier)  <  $pressed / $focused  <  onPressed / onFocused

Press/focus feedback always beats other classes while the element is pressed or focused, regardless of declaration order — so a checked button still shows its press:

TypeScript
UIButton(UIText("Save")).style({
  bgColor: "#222",
  $checked: { bgColor: "#2a7" },        // shown while checked
  $pressed: { bgColor: "#195" },        // wins while the finger is down, even when checked
})
Reactive shortcut

a single computed value doesn't need a class — any primitive style prop accepts a () => value binding directly (bgColor: () => sel.value ? "#FF4032" : "#333"). Reach for a class when you want a named, reusable block of overrides — especially one several elements react to via the cascade.

See also

  • Styling — the property vocabulary class blocks are made of
  • Theme — app-wide variables (theme = app-wide, classes = per-element states)
  • Signals — the reactivity behind () => value bindings
  • Interactive elementsonPressed/onFocused, ripple, press mechanics