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:
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:
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:
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.checkedandel.class.$checkedare 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:
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.activeon the icon above isfalseeven while the button's cascade styles it. - There is no opt-out: while an ancestor has the class, a descendant's
el.class.name = falseonly clears its own flag. - The cascade stops at presentable roots — hosted screens (
UIPagerpages) 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:
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:
base style < $classes (later-declared beats earlier) < $pressed / $focused < onPressed / onFocusedPress/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:
UIButton(UIText("Save")).style({
bgColor: "#222",
$checked: { bgColor: "#2a7" }, // shown while checked
$pressed: { bgColor: "#195" }, // wins while the finger is down, even when checked
})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
() => valuebindings - Interactive elements —
onPressed/onFocused, ripple, press mechanics