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:

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

TypeScript
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()
Note

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 a duration the 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 — use animateTo for a custom Easing.
TypeScript
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.

Note

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.

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

TypeScript
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.checked and el.class.$checked are 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 always false. Ask the button instead: button.isPressed / button.isHovered (interactive.md).
Note

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:

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

TypeScript
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.active on the icon above is false while the button's class 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 pages of a UIPager and the items of a UIVirtualizedList are 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 / $focused stop 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:

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

Note

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:

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

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

TypeScript
base  <  $landscape / $portrait  <  your $classes (later-declared beats earlier)  <  $hovered  <  $pressed  <  $focused

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

TypeScript
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 () => value bindings
  • Interactive elements — press feedback, rippleColor, isPressed / isHovered
  • Pager — UITabs, the standard tab bar
  • App — app.setOrientation, locking the screen orientation