Style classes — selected, pressed and other states
A style class is a named state of an element's style: a $name block in .style() whose
overrides apply while the class is on — selected, checked, expanded, pressed, focused. One prop
that follows a signal needs no class: bind it directly (bgColor: () => …, styling.md).
- show an item as selected, checked or active and switch it from code
- keep a selected state in step with a signal
- restyle the icon and the label inside a button together with the button
- dim or recolor a button while it is pressed or under the mouse
- highlight a text field while it has focus
- lay a screen out differently in landscape and in portrait
- animate the change from one state to another
- remove a background or a border while a state is on
- know which style wins when two states set the same property
At a glance
A favorite button: one switch restyles the container, the icon and the label — each part declares its own reaction:
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 },
$pressed: { opacity: 0.7 } })
fav.onClick(() => fav.class.fav = !fav.class.fav)
UIScreen(fav).style({ alignItems: "center", justifyContent: "center" }).open()Declare a state: $name blocks
A $-prefixed key inside any .style() call declares a class; its block holds the overrides that
apply while the class is on and takes every prop the element's own .style() takes. Declaring it
changes nothing until the class is switched on:
const row = UIRow(UIText("Inbox")).style({
height: 44, px: 16, alignItems: "center", bgColor: "#151515",
$selected: { bgColor: "#1d2b45" }, // applies while `selected` is on
})
row.class.selected = true
UIScreen(row).open()a block is static — a () => value inside it is not a binding.
Bind the prop at the top level, or bind the class itself (el.class, below).
Animate the change: duration, delay, easing
A block may carry the timing of its swap, played both ways — on and off:
duration/delay— milliseconds; without adurationthe swap is instant.easing— a curve name (outCubic,inOutSine,outBack, …; default an ease-in-out curve),"cubic-bezier(x1,y1,x2,y2)"or"steps(n)". A function cannot ride a style block — useanimateTofor a customEasing.
const tile = UIButton(UIText("Tap me")).style({
height: 48, px: 16, borderRadius: 12, bgColor: "#151515",
$selected: { bgColor: "#1d2b45", transform: "scale(1.05)", duration: 150, easing: "outCubic" },
})
tile.onClick(() => tile.class.selected = !tile.class.selected)
UIScreen(tile).style({ p: 24 }).open()The timing works on your classes and on $pressed / $hovered / $focused alike; the
orientation swap is always instant. In a cascade every element animates by its own block's
timing — the switch is one write, the transitions are local.
the swap animates on Android, the Windows and Linux desktop and the web. On iOS, the
macOS desktop, the web player and in headless tests (lecodes render / lecodes test) the
timing is ignored and the swap is instant. A change that must animate on every host is an
animateTo (styling.md).
Remove a property while a state is on: null
A null value inside a block means "this property is absent while the class is on": it shadows
the base value (and lower classes), and the base comes back when the class goes off.
const chip = UIButton(UIText("Draft")).style({
height: 32, px: 12, borderRadius: 16, bgColor: "#333", border: "1px solid #555",
$flat: { bgColor: null, border: null }, // no background, no border while `flat` is on
})
chip.onClick(() => chip.class.flat = !chip.class.flat)
UIScreen(chip).style({ p: 24 }).open()Switch a class on and off: el.class
el.class switches, toggles, binds and reads an element's classes — a proxy with the same
contract as el.style:
el.class.selected // read: is it on for THIS element? → boolean
el.class.selected = true // switch on (false switches off)
el.class.open = !el.class.open // toggle
el.class.done = () => sig.value // bind: follows the signal
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 the same class. - The class stays on the element: a screen closed and opened again comes back with it.
- The five reserved names (below) are not writable:
el.class.pressed = true(with or without$, in the batch form, or as a binding) throws, and reading one is alwaysfalse. Ask the button instead:button.isPressed/button.isHovered(interactive.md).
a class is for a named block of overrides, especially one several elements react to
through the cascade. A single value needs none: bgColor: () => sel.value ? "#FF4032" : "#333".
Keep a class in step with a signal
A function value binds the class to the signals it reads: it re-applies when they change. A list whose selection is a signal — each row's class follows it, and a tap moves it:
const selected = signal("inbox")
const Row = (id: string, label: string) => UIButton(
UIText(label).style({ color: "#9aa0a6", $selected: { color: "#ffffff" } }),
)
.style({ height: 44, px: 16, justifyContent: "flex-start", bgColor: "#151515", $selected: { bgColor: "#1d2b45" } })
.class({ selected: () => selected.value === id }) // follows the signal
.onClick(() => selected.value = id)
UIScreen(Row("inbox", "Inbox"), Row("sent", "Sent"), Row("trash", "Trash")).open()A later write to the same class — a value or a function — replaces the binding.
Restyle the icon and label with their button: the cascade
The icon and the label inside a button restyle together with it: a class set on an element is on
for all its descendants too — their same-name $ blocks light up, like a .dark class on
<body> in CSS. The parts of a control declare their own reactions; the container carries the
one flag.
A tab bar of your own (the standard one is UITabs, pager.md), the highlight bound to
the pager's tab — icon and label follow one binding on the button:
const activeTab = signal(0)
const pager = UIPager([UIScreen(UIText("Home page")), UIScreen(UIText("Profile page"))]).style({ flexGrow: 1 })
pager.onSelect(i => activeTab.value = i) // taps AND swipes move the highlight
const TabButton = (label: string, icon: SvgSource, i: number) => UIButton(
UIImage(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))
UIScreen(
pager,
UIRow(TabButton("Home", assetIcon("lucide:house"), 0), TabButton("Profile", assetIcon("lucide:user"), 1)),
).open()Rules of the cascade:
- Reads reflect only the element's own classes —
el.class.activeon the icon above isfalsewhile the button's class 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 pages of a
UIPagerand the items of aUIVirtualizedListare roots of their own: a class on the pager or the list (or above it) does not reach inside — set or bind it inside the page or the item. On the web it does reach them; a class set inside looks the same on every host. A widget is reached: the classes of the screen it belongs to cascade into it. - Your classes pass nested buttons unchanged; only
$pressed/$hovered/$focusedstop there (below).
Reserved classes
Five classes the system switches; el.class reads them as false and cannot write them.
| class | on while | reach |
|---|---|---|
$pressed |
a finger (or a mouse button) is down on the button | cascades, stops at a nested button |
$hovered |
a mouse cursor is over the button — desktop, the web and the web player; never on a phone | cascades, stops at a nested button |
$focused |
the field has focus | cascades, stops at a nested button |
$landscape |
the app's window is wider than it is tall | every element at once |
$portrait |
the app's window is taller than it is wide | every element at once |
Pressed, hovered and focused styles: $pressed, $hovered, $focused
The system switches an interaction class on the element it hit — the button under the finger or the cursor, the focused field — and it cascades into the children: an icon and a label restyle with their button's press. It stops at the nearest nested button, which starts its own scope; a button inside a pressed card is not pressed:
const card = UIButton(
UIText("Order #1042").style({ color: "white", $pressed: { color: "#aaa" } }), // presses with the card
UIButton(UIText("Cancel").style({ color: "#ff453a", $pressed: { opacity: 0.5 } }))
.style({ height: 32, px: 12, borderRadius: 8, $pressed: { bgColor: "#2a181a" } })
.onClick(() => console.log("cancel order")),
)
.style({ flexDirection: "column", alignItems: "flex-start", p: 16, gap: 8, borderRadius: 16,
bgColor: "#151515", $hovered: { bgColor: "#1a1a1a" }, $pressed: { bgColor: "#1f1f1f", duration: 100 } })
.onClick(() => console.log("open order"))
UIScreen(card).style({ p: 16 }).open()Pressing the card lights up the card's and the title's $pressed blocks, never Cancel's or its
label's; pressing Cancel presses Cancel (and its label) and leaves the card as it is. $hovered
and $focused follow the same rule. On Android and the desktop a button with a rippleColor
shows the ripple instead of its $pressed blocks (interactive.md).
on the web the browser's :active / :hover decide, and the scope does not stop at a
nested button: pressing or hovering the card also lights Cancel's $pressed / $hovered blocks,
and the card stays hovered while the cursor is on Cancel.
A field that shows its focus:
const email = UIInput({ placeholder: "Email" }).style({
height: 44, px: 12, borderRadius: 8, border: "1px solid #333",
$focused: { border: "2px solid #4c8dff" },
})
UIScreen(email).style({ p: 24 }).open()Styles per orientation: $landscape, $portrait
To lock the app to one orientation, call app.setOrientation("landscape")
(runtime/app.md); these two classes rotate nothing — they restyle the layout
for the orientation the app is in:
const photo = UIBox().style({ flex: 1, bgColor: "#333", borderRadius: 12 })
const details = UIColumn(UIText("Title"), UIText("Description")).style({ flex: 1, gap: 8 })
UIScreen(photo, details)
.style({ flexDirection: "column", p: 16, gap: 16, $landscape: { flexDirection: "row", p: 32 } })
.open()The orientation is one for the whole app, from the size of its window (the screen on a phone),
and every $landscape / $portrait block in the tree follows it — there is nothing to set per
element. On rotation the blocks swap in the same frame as the new layout, without a transition.
Which style wins: precedence
When several active blocks set the same property, the higher one wins, lowest to highest:
base < $landscape / $portrait < your $classes (later-declared beats earlier) < $hovered < $pressed < $focusedBetween two of your classes, "later" is app-wide: class names are ranked in the order the app first
uses them, anywhere. { $a, $b } on a fresh pair of names makes $b win; if another element used
$b first, $a wins.
Interaction feedback always beats your classes, whatever the declaration order — a checked button still shows its press — and orientation blocks sit right above the base, so any class overrides them:
const save = UIButton(UIText("Save")).style({
height: 44, px: 16, borderRadius: 8, bgColor: "#222",
$checked: { bgColor: "#2a7" }, // shown while checked
$pressed: { bgColor: "#195" }, // wins while the finger is down, even when checked
})
save.onClick(() => save.class.checked = !save.class.checked)
UIScreen(save).style({ p: 24 }).open()See also
- Styling — the property vocabulary class blocks are made of,
animateTo - Theme — app-wide variables (theme = app-wide, classes = per-element states)
- Signals — the reactivity behind
() => valuebindings - Interactive elements — press feedback,
rippleColor,isPressed/isHovered - Pager —
UITabs, the standard tab bar - App —
app.setOrientation, locking the screen orientation