Skip to main content

Styling

Style lives in one place: the style prop. A style property passed flat is an error in development that names the fix, rather than the silent no-op an unrecognised prop used to be.

The rule

One namespace per kind of thing, and no name in both:

  • style — everything CSS has a concept for: layout, paint, text, cursor, overflow, zIndex, pointerEvents.
  • props — everything else: title, window geometry and size hints, resizable, wmClass, windowType, grab, value, src, focusable, tabIndex, disabled, handlers. The 3D elements keep flat property props (position, material) — those are object properties, not CSS.

style takes an object or a nested array, flattened left-to-right with falsy entries skipped. That is what replaces the cascade: precedence is written at the call site instead of resolved by specificity.

<box style={[s.card, isWide && s.wide, { backgroundColor: theme.panel }]} />

What it fixes on <window>

width/height on a <window> are the real X window's geometry — the user can drag them, and flush() reads them back — so they must never reach the root yoga node. That used to mean stripping them out of the style bag (_yogaProps), and it forced the size hints to hide inside a sizeHints object because minWidth/maxWidth were already taken by yoga.

With the split, both workarounds go:

<window
title="Editor"
width={900}
height={600}
x={40}
y={40} // the X window
minWidth={400}
minHeight={300}
resizable // WM hints, flat, no nesting
style={{ flexDirection: 'row', backgroundColor: '#1e1e1e' }} // the root box
/>

WINDOW_HINT_PROPS is mapped back to ntk's sizeHints shape in windowAttributes(), and style.backgroundColor is forwarded as the window's creation attribute — the ntk contract is unchanged, only the call site is.

Inline pseudo-states

The thing inline CSS cannot do, and the reason people keep a stylesheet:

<box
style={{
backgroundColor: theme.surface,
':hover': { backgroundColor: theme.surfaceHover },
':focus': { borderColor: theme.borderFocus },
}}
/>

:hover, :focus, :focus-visible, :active, :disabled, :drag-over, :dragging. These are node states, not selectors — each is something the node itself already knows, so resolving them needs no cascade, no specificity and no tree walk. The event manager already tracks the hover path and the focused node; a state change now recomputes one node's style and repaints. No React render.

Precedence is fixed and low-to-high: :hover:focus:focus-visible:active:disabled:drag-over:dragging, merged per property, so a disabled control never looks hovered, and a drag in progress outranks all of the pointer and focus states. Because the hover path is the ancestor chain, hovering a child lights up an ancestor's :hover block, exactly like CSS.

:active is a press, and it follows the same chain for the same reason: the node under the pointer is whatever a control happens to be built out of — a button's label, a switch's thumb — and it is the control that has to draw the press. It is also live for the whole gesture: it drops when the pointer leaves the chain and comes back when it returns, so :active always means "releasing now activates this", matching the nearest-common-ancestor rule the click itself is synthesized on.

This is not decoration. A control acts on the release, so :active is the only thing it can show while it is held, and a click held half a second is half a second of silence without it. Give every interactive thing one — see components.md.

<box
focusable
style={{
backgroundColor: theme.background,
':hover': { backgroundColor: theme.surfaceHover },
':active': { backgroundColor: theme.surfaceActive },
}}
/>

A transition on the same property is welcome — what matters is that the change starts on the press frame, not that it finishes there.

The keyboard has no press state: Space and Enter act on the down, so the activation is immediate, but nothing draws held-ness. X reports auto-repeat as release/press pairs and neither ntk nor node-x11 implements XKB's DetectableAutoRepeat, so a held key cannot be told from fast tapping.

:focus-visible is focus that came from the keyboard — Tab, an arrow inside a widget, autoFocus, node.focus(), a modal handing focus back as it closes. A press sets :focus and not :focus-visible, for the reason CSS grew the distinction: the user knows where they clicked, and a ring on every click is noise, where a ring on Tab is the only cue a keyboard user has. Put focus rings in :focus-visible and colour changes that are welcome either way in :focus.

The last two belong to drag and drop. :drag-over follows the pointer during a drag on exactly the same ancestor-path rule as :hover — and, like :hover, it says where the pointer is, not whether the node would accept the drop; useDropTarget's isAccepted is the one that answers that. :dragging is set on the source node for the duration of a drag. See drag-and-drop.md.

State blocks may only set paint properties (backgroundColor, borderColor, borderRadius, zIndex, outlineWidth, outlineColor, outlineOffset, color) — enforced at declaration time by createStyles. A :hover that could set padding would reflow the tree on pointer move: jitter, and the end of the "hover is a repaint" property that makes this worth having. Anything that changes layout or what renders stays in React state.

The focus ring

Every focusable node draws one already, on :focus-visible, with no styling at all — a bare <box focusable> included. It is not something an application opts into, because a keyboard user cannot opt into needing it.

outlineWidth, outlineColor and outlineOffset override it, and they are paint properties like any other: animatable, legal in a state block, and painted outside the border box so switching one on cannot move the thing it surrounds. That is the whole reason CSS has outline as well as border, and the reason these are not in the layout vocabulary.

<box focusable /> {/* ring, for free */}
<box focusable style={{ outlineWidth: 0 }} /> {/* opted out */}
<box
focusable
style={{ ':focus-visible': { outlineWidth: 3, outlineColor: '#e17055' } }}
/>

A theme sets focusRing, focusRingWidth and focusRingOffset to restyle every ring under it at once — the renderer reads them from the nearest theme prop, so <ThemeProvider> covers the widgets and anything an application writes itself.

createStyles

Identity is the point — a hoisted style object lets applyProps skip the whole update with a === check, the same reason RN's StyleSheet.create still exists now that its id registry is gone. It also validates keys, which a bare object literal cannot: an unknown style property is an error at declaration instead of a silent no-op.

flattenStyle

flattenStyle(style) collapses whatever the style prop accepts — an object, an array, nested arrays, holes — into one plain object, which is what the reconciler itself does before applying props. Two details are worth knowing if you call it: a lone object is returned as-is, not copied, so === still identifies a hoisted style; and a state block merges with one already collected rather than replacing it, so [{ ':hover': { color } }, { ':hover': { backgroundColor } }] keeps both.

In components

Every component takes style and merges it after its own, so an override wins by position instead of clobbering a computed value: style: [control.style, base, checked && on, style].

useControl(disabled, onActivate, { styled: true }) stops holding hover, focus and the press in React state: no enter/leave handlers, no re-render on pointer move. Switch and Button are the worked examples. Without it the hook returns hover/focused/pressed as React state, which is what a control needs when the part that has to change is not on the press chain — a Checkbox's well is a sibling of the label the press lands on, and no node-local state block can cross that.

A component's own props are never style. ProgressBar takes color, Dialog takes width/height — a dialog is a real popup window and needs its geometry up front — and ContextMenu takes fontSize because it measures labels with it. Select and Slider used to take width purely to put it in their own box; that is style={{ width }} now.

Theme tokens

A style value of '$name' resolves against the nearest theme above the node — <ThemeProvider value={palette}>, or a theme prop on any element. The sigil is what keeps it unambiguous: 'red' is a CSS colour, '$red' is a token.

const s = createStyles({
card: { backgroundColor: '$panel', padding: '$gutter' },
title: { color: '$text', fontSize: 20 },
});

<window theme={palette} style={{ backgroundColor: '$bg' }}>
<box style={s.card}></box>
</window>;

That is the point of tokens: the style is hoisted — declared once, outside render, with no access to React context — and still follows the theme. Without them a palette has to be threaded to every element that paints (style={[s.card, { backgroundColor: theme.panel }]}).

Tokens are not colour-only; padding: '$gutter' resolves a number just as well.

A theme prop anywhere scopes its subtree, and an inner one merges over the outer, so a panel can restate a colour or two without repeating a palette. Popups resolve through their place in the tree, not their window, so a menu inherits the theme of the UI that opened it even though it is a separate X window.

Note that a raw theme prop is exactly the object you wrote: it merges, but nothing is computed from it. The widget palette's derived tokens — the pressed accentActive/surfaceActive/dimActive — are filled in by <ThemeProvider>, which plants the resolved palette in the tree for this lookup to find. Hand-writing theme={{ accentHover: … }} on a box gets the merge and not the derivation.

Changing the theme restyles the subtree in place. Nodes whose own props did not change are still updated — which is why a theme change also drops the memoised text layouts under it, or cached text would keep painting the old colour.

An unknown token is an error naming what the theme does have, and a token with no theme above it at all warns in dev — that one is otherwise silent, since the whole style is stripped rather than one value failing. Resolution is cached per (style object, theme object), so a hoisted style under one theme keeps its identity across renders and the === fast path still applies.

Resolution walks the node tree, not React context, so a palette has to reach the tree to be seen. <ThemeProvider> puts it in both places — the context widgets read with useTheme(), and a theme prop on a node — which is what makes provider and token one mechanism rather than two (components.md). Widgets plant their own merged palette on their root node as well, so $tokens work inside a widget subtree — and in a style you pass one — with no provider anywhere.

Transitions

transition names how long a change takes. A number covers every animatable property; an object picks them individually.

<box
style={{
backgroundColor: theme.surface,
transition: 120, // or { backgroundColor: 120, left: 200 }
':hover': { backgroundColor: theme.surfaceHover },
}}
/>

Numbers lerp and colours lerp per channel through ntk's own CSS colour parser, so anything the paint path accepts animates. A value with no meaningful midpoint — an enum like flexDirection, a percentage, auto — snaps instead, and zIndex is excluded on purpose: restacking every frame is not an animation.

The easing is a fixed ease-out cubic. A transition starts from what is on screen, not from the declared value, so interrupting one reverses from where it got to rather than jumping to the end first.

The animation is the repaint loop: the window keeps asking its frame clock for frames while any transition is unfinished, and stops the frame the last one lands. Nothing polls, and there is no per-widget requestAnimationFrame.

Transitions may animate layout properties, unlike state blocks. That is not an inconsistency: a pointer move must never reflow the tree, but an author who writes transition: { left: 200 } has asked for animated layout and pays a layout pass per frame for it. Switch is the worked example — the thumb is absolutely positioned and slides on left, because justifyContent would flip between the ends with nothing in between.

Window size queries

'@width >= 600' and friends are the X11 analogue of @media: what a style can usefully ask about here is the window it is laid out in, not the screen.

const s = createStyles({
bar: {
flexDirection: 'column',
gap: 4,
'@width >= 600': { flexDirection: 'row', gap: 16 },
},
});

width and height, with >=, <=, > or <. Blocks that match are merged in declaration order, before state blocks — so a :hover inside the wide layout still wins over the wide layout.

A size query may set layout properties, unlike a state block. That is not an inconsistency: a pointer state must never reflow the tree, but a size query is only ever re-evaluated inside a layout pass that a resize has already required, so it costs nothing extra. Nodes that declare one are registered with their window and re-resolved just before it lays out, and only when the size actually changed.

A malformed query is an error rather than a key that silently never matches.

Decided

  • ':hover', not _hover. The CSS spelling costs a pair of quotes and buys transfer from every other styling system.

Elements that are not styled

The 3D scene elements and the declarative SVG children carry their own vocabularies — position, color and width mean a transform, a material and a radius there — so the style channel does not apply to them, the same way it does not apply to an <input type> in the DOM. They report stylable === false and their props are passed through untouched.

Next

opacity (needs offscreen composition — see NEXT_STEPS §3), and per-node container queries if the window-level ones prove too coarse.