Core concepts

The sx prop.

Every Aktion component accepts a universal sx prop — a single, bounded styling channel for one‑off layout and visual tweaks. sx values are not raw CSS: each one is a design‑token reference, a small enum, or a sanitised scalar. That keeps the surface theme‑safe (everything resolves through CSS variables), XSS‑safe (no arbitrary CSS injection), and enumerable (an LLM or an editor can list every legal value). The same prop also accepts responsive breakpoint maps and interaction‑state styling.

Mental model

Reach for layout components (Column, Row, Grid, Box… — see Layout) first; they cover structure. Use sx for the per‑element polish on top: a little extra padding, a tinted surface, a rounded corner, a hover lift, a sticky header.

// sx is just another prop on the options bag.
Card([Text("Hello")], { sx: { p: "lg", bg: "surface", radius: "lg", shadow: "md" } })

// It composes with a component's own props:
Button("Save", { variant: "primary", sx: { mt: "md", w: "full" } })

Escape hatches exist, but prefer sx. Raw className/style are also accepted on every component (see Universal props), but they bypass the token system and the safety guarantees. Use sx unless you genuinely need a property it doesn’t expose.

How a value resolves

For any sx key, the value is matched in this order, and the first match wins:

  1. A responsive map ({ base, sm, md, lg, xl }) — each breakpoint resolves with the rules below and emits real @media rules. See responsive.
  2. A token for that key (spacing scale, color name, radius/shadow step, size keyword, z‑index layer…) → resolves to a themeable --rui-* CSS variable.
  3. A sanitised scalar where allowed (a CSS length like "12px"/"60%", a raw color, or a number).
  4. Otherwise the value is dropped.

Everything that survives step 2 or 3 becomes an inline style on the element the component renders; responsive maps become atomic classes in a shared stylesheet instead. That split decides what wins — see how responsive values beat component defaults.

Nothing here ever concatenates your value into a stylesheet verbatim. Lengths and colours go through a restricted alphabet with a 64‑character cap first, which is why an exotic raw CSS value can vanish.

Spacing — padding, margin, gap

Spacing keys take a spacing‑scale token, a safe‑area inset, or a CSS length. The scale mirrors a Tailwind‑ish mental model and resolves to --rui-spacing-* variables, so it rescales with the theme.

Scale: none3xs2xsxssmmdlgxl2xl3xlauto  (none = 0; a CSS length such as "10px" also works).

Safe‑area insets (notches / home indicators): safesafe-topsafe-rightsafe-bottomsafe-leftsafe is the all‑around shorthand.

Key(s)SetsExample
ppadding (all sides){ p: "lg" }
px / pypadding inline (L/R, logical) / block (T/B){ px: "xl", py: "sm" }
pt pr pb plpadding top / right / bottom / left{ pt: "md", pb: "lg" }
ps / pepadding inline start / end (RTL‑aware){ ps: "lg" }
mmargin (all sides){ m: "auto" }
mx / mymargin inline (logical) / block{ mx: "auto" }
mt mr mb mlmargin top / right / bottom / left{ mt: "xl" }
ms / memargin inline start / end (RTL‑aware){ me: "auto" }
gapgap between fl/grid children{ gap: "md" }

Logical properties mirror in RTL. px/mx emit padding-inline/margin-inline, and ps pe ms me target the inline start/end sides. Under dir="rtl" they flip automatically — prefer them over pl/pr for internationalised UIs.

Live — padding, gap & auto margins
$app(Column([
  Box([Text("p: none")], { sx: { p: "none", bg: "surface-muted", border: "subtle" } }),
  Box([Text("p: sm")],   { sx: { p: "sm",   bg: "surface-muted", border: "subtle" } }),
  Box([Text("p: lg")],   { sx: { p: "lg",   bg: "surface-muted", border: "subtle" } }),
  Row([
    Badge("left"),
    Badge("pushed right", { tone: "primary", sx: { ms: "auto" } })
  ])
], { gap: "md" }))

Sizing — width & height

Sizing keys take a size keyword or a CSS length ("320px", "60%", "40ch"…).

Keywords: fullhalfscreenscreen-wscreen-hdvhminmaxfitautofull = 100%, dvh = 100dvh (dynamic viewport height, ideal for mobile full‑height panels).

KeySetsExample
w / hwidth / height{ w: "full", h: "240px" }
minW / maxWmin / max width{ maxW: "640px" }
minH / maxHmin / max height{ minH: "dvh" }
Live — widths & a capped reading column
$app(Column([
  Box([Text("w: full")], { sx: { w: "full", p: "sm", bg: "primary", color: "primary-text" } }),
  Box([Text("w: half")], { sx: { w: "half", p: "sm", bg: "surface-muted", border: "subtle" } }),
  Box([Text("w: 120px")], { sx: { w: "120px", p: "sm", bg: "surface-muted", border: "subtle" } }),
  Box([Text("maxW 360px, centered with mx: auto — a comfortable reading column.")],
    { sx: { maxW: "360px", mx: "auto", p: "md", bg: "surface", border: "default", radius: "md" } })
], { gap: "md" }))

Color & surface

Color keys take a semantic color token, a named gradient (gradient.*, for bg), or a raw/sanitised color ("#0ea5e9", "rgb(…)"). Tokens resolve to --rui-color-* variables so they follow the active theme and light/dark mode.

Color tokens: bgbg-subtlesurfacesurface-mutedborderborder-subtletexttext-mutedmutedprimaryprimary-hoverprimary-textaccentsuccesswarningdangerinfotransparentcurrent

Gradients (use as bg: "gradient.<name>"): gradient.brandgradient.accentgradient.warmgradient.coolgradient.successgradient.danger — rebrandable via $theme({ gradients: {…} }).

KeySetsExample
bgbackground (token / gradient.* / raw){ bg: "primary" }
colortext color{ color: "text-muted" }
borderColorborder color (pairs with border){ borderColor: "danger" }
Live — semantic tokens & gradients
$app(Grid([
  Box([Text("surface")], { sx: { p: "lg", bg: "surface", border: "subtle", radius: "md" } }),
  Box([Text("primary")], { sx: { p: "lg", bg: "primary", color: "primary-text", radius: "md" } }),
  Box([Text("success")], { sx: { p: "lg", bg: "success", color: "#fff", radius: "md" } }),
  Box([Text("gradient.brand")], { sx: { p: "lg", bg: "gradient.brand", color: "#fff", radius: "md" } }),
  Box([Text("gradient.cool")],  { sx: { p: "lg", bg: "gradient.cool",  color: "#fff", radius: "md" } }),
  Box([Text("gradient.warm")],  { sx: { p: "lg", bg: "gradient.warm",  color: "#fff", radius: "md" } })
], { columns: 3, gap: "md" }))

Border, radius, shadow & opacity

KeyAcceptsExample
bordernone · subtle · strong · true/default · a color token/value{ border: "subtle" }
radiusnonexssmmdlgpillfullcircle or a length{ radius: "lg" }
shadownonesmmdlg{ shadow: "md" }
opacitya number 01{ opacity: 0.6 }
Live — radius ramp, shadows, opacity
$app(Column([
  Row([
    Box([Text("xs")],   { sx: { p: "md", bg: "surface", border: "subtle", radius: "xs" } }),
    Box([Text("md")],   { sx: { p: "md", bg: "surface", border: "subtle", radius: "md" } }),
    Box([Text("lg")],   { sx: { p: "md", bg: "surface", border: "subtle", radius: "lg" } }),
    Box([Text("pill")], { sx: { p: "md", bg: "surface", border: "subtle", radius: "pill" } })
  ], { gap: "md" }),
  Row([
    Box([Text("shadow sm")], { sx: { p: "md", bg: "surface", radius: "md", shadow: "sm" } }),
    Box([Text("shadow md")], { sx: { p: "md", bg: "surface", radius: "md", shadow: "md" } }),
    Box([Text("shadow lg")], { sx: { p: "md", bg: "surface", radius: "md", shadow: "lg" } }),
    Box([Text("opacity .5")], { sx: { p: "md", bg: "primary", color: "primary-text", radius: "md", opacity: 0.5 } })
  ], { gap: "md" })
], { gap: "md" }))

Flexbox & grid

Layout components already wrap flex/grid for you, but sx lets you turn any element into a flex or grid container and control its children inline.

KeyAcceptsExample
displayflexgridblockinlineinline-flexinline-blocknonecontents{ display: "flex" }
directionrowcolumnrow-reversecolumn-reverse{ direction: "column" }
alignstartcenterendstretchbaseline (align‑items){ align: "center" }
justifystartcenterendbetweenaroundevenlystretch{ justify: "between" }
wraptrue / false{ wrap: true }
grow / shrinka number (flex‑grow / flex‑shrink){ grow: 1 }
basisa size keyword or length (flex‑basis){ basis: "200px" }
columnsa number → that many equal grid columns{ display: "grid", columns: 3 }
Live — a flex toolbar & a 3-column grid, inline
$app(Column([
  Box([
    Badge("Logo", { tone: "primary" }),
    Badge("Docs"), Badge("Pricing"),
    Box([Button("Sign in", { variant: "primary" })], { sx: { ms: "auto" } })
  ], { sx: { display: "flex", align: "center", gap: "sm", p: "sm", bg: "surface", border: "subtle", radius: "md" } }),
  Box([
    Box([Text("A")], { sx: { p: "lg", bg: "surface-muted", radius: "sm" } }),
    Box([Text("B")], { sx: { p: "lg", bg: "surface-muted", radius: "sm" } }),
    Box([Text("C")], { sx: { p: "lg", bg: "surface-muted", radius: "sm" } })
  ], { sx: { display: "grid", columns: 3, gap: "md" } })
], { gap: "md" }))

Position & layering

Pin elements and control stacking order. zIndex takes a layer token (recommended — consistent, themeable via $theme({ zIndex: {…} })) or a number. Layer tokens resolve through --rui-z-* variables.

Layer tokens (low → high): baseraisedstickybanneroverlaydropdownmodalpopovertoasttooltip

Pick the token that names your intent rather than a number, and the app stays consistent with the library’s own surfaces. The numbers behind the tokens are the theme’s --rui-z-* scale — listed in full on the theming page — so a $theme({ zIndex: {…} }) override moves your elements and the library’s together.

Anchored popups do not compete for z-index any more. Tooltips, dropdowns, popovers and picker lists are promoted into the browser top layer, so they paint above everything regardless of these tokens — see the floating layer. Reach for a layer token when you are stacking your own elements: a sticky table header, a custom banner, a decorative ribbon.

KeyAcceptsExample
positionrelativeabsolutefixedstickystatic{ position: "sticky" }
top right bottom lefta size keyword or length{ top: 0 }
inset0 or a length (all four offsets){ inset: 0 }
zIndexa layer token or a number{ zIndex: "sticky" }
Live — a corner badge via absolute positioning
$app(Box([
  Box([Text("New")], { sx: {
    position: "absolute", top: "8px", right: "8px", zIndex: "raised",
    px: "sm", py: "3xs", bg: "danger", color: "#fff", radius: "pill", fontSize: "xs", weight: "700"
  } }),
  Image({ src: "https://picsum.photos/seed/sx/480/200", alt: "Cover" }),
  Box([Text("Product card with a positioned ribbon")], { sx: { p: "md" } })
], { sx: { position: "relative", maxW: "420px", bg: "surface", border: "subtle", radius: "lg", overflow: "hidden" } }))

Typography

For body text prefer the Text / Display / Heading components and their variants. Use these sx keys for fine adjustments on any element.

KeyAcceptsExample
fontSizexssmbasemdlgxl2xl3xl4xl or a length{ fontSize: "2xl" }
weight100900 · normal · bold{ weight: "700" }
textAlignleftcenterrightjustifystartend{ textAlign: "center" }
textDecorationunderlinenoneline-throughoverline{ textDecoration: "underline" }
Live — size ramp, weight, alignment
$app(Column([
  Text("fontSize 4xl / weight 800", { sx: { fontSize: "4xl", weight: "800" } }),
  Text("fontSize xl / centered",    { sx: { fontSize: "xl", textAlign: "center" } }),
  Text("muted & struck through",    { sx: { color: "text-muted", textDecoration: "line-through" } }),
  Text("right-aligned link-ish",    { sx: { color: "primary", textDecoration: "underline", textAlign: "right" } })
], { gap: "sm" }))

Effects — overflow, cursor, backdrop

KeyAcceptsExample
overflowhiddenautoscrollvisibleclip{ overflow: "hidden" }
cursorpointerdefaultnot-allowedgrabgrabbingtextmovewaithelpnone{ cursor: "pointer" }
backdropblur — frosted‑glass backdrop filter{ backdrop: "blur" }
Live — clipped scroll area & a glass panel
$app(Box([
  Box([Column([
    Text("Scrollable region (overflow: auto, capped height)."),
    Text("Line 2"), Text("Line 3"), Text("Line 4"),
    Text("Line 5"), Text("Line 6"), Text("Line 7")
  ], { gap: "xs" })], { sx: { maxH: "120px", overflow: "auto", p: "md", bg: "surface", border: "subtle", radius: "md" } }),
  Box([Text("backdrop: blur over a gradient")], {
    sx: { mt: "md", p: "md", radius: "md", color: "#fff", backdrop: "blur", bg: "rgba(255,255,255,.12)" }
  })
], { sx: { p: "lg", bg: "gradient.cool", radius: "lg" } }))

Background images & overlays

bgImage sets a background image; bgOverlay layers a color or gradient.* wash on top (perfect for keeping hero text readable). URLs are scheme‑whitelisted — only http(s), root/relative paths, and data:image/* pass; anything else (e.g. javascript:) is rejected.

KeyAcceptsExample
bgImagean http(s) / relative / data:image URL{ bgImage: "/hero.jpg" }
bgOverlaya color token/value or gradient.* wash{ bgOverlay: "rgba(0,0,0,.45)" }
bgSizecover (default) · contain{ bgSize: "contain" }
Live — hero banner with a readable overlay
$app(Box([Column([
  Text("Ship UIs in one language", { sx: { fontSize: "3xl", weight: "800", color: "#fff" } }),
  Text("A background image with a dark overlay keeps text legible.", { sx: { color: "rgba(255,255,255,.85)" } }),
  Row([Button("Get started", { variant: "primary" })], { sx: { mt: "sm" } })
], { gap: "sm" })], {
  sx: {
    p: "2xl", radius: "lg", minH: "260px",
    display: "flex", direction: "column", justify: "center",
    bgImage: "https://picsum.photos/seed/hero/1200/500",
    bgOverlay: "rgba(15,23,42,.55)"
  }
}))

Responsive maps

Most sx values can be a breakpoint map { base, sm, md, lg, xl } instead of a single value. These emit real @media (min-width: …) rules — not just the resolved base — so the style genuinely changes at the breakpoint.

It is mobile‑first: base applies at every width, and each larger key overrides from its min‑width up. A map needs only one of the five keys to count as one.

KeyMin‑width
base0 (the default, no media query)
sm640px
md768px
lg1024px
xl1280px
Live — resize the preview / window
$app(Box([Text("I'm full-width on mobile, 60% from md, and 40% from lg — with more padding as I grow.")], {
  sx: {
    bg: "surface", border: "subtle", radius: "lg",
    w: { base: "100%", md: "60%", lg: "40%" },
    p: { base: "sm", md: "lg", lg: "xl" },
    textAlign: { base: "left", lg: "center" }
  }
}))

Three keys, three sets of @media rules, one element. Because base is the only key without a media query, a map with no base simply has no style below its smallest breakpoint.

Which keys accept a map

The whole box model, the flex/grid controls, position, and the typography keys do. Five keys do not, and a map passed to one of them collapses to its base value rather than erroring — so the style still renders, it just stops changing at the breakpoint.

Accept a map: ppxpyptprpbplpspemmxmymtmrmbmlmsmegapwhminWmaxWminHmaxHbgcolorradiusshadowborderborderColoropacitydisplaydirectionalignjustifywrapgrowshrinkbasiscolumnsoverflowpositiontoprightbottomleftinsetzIndexfontSizeweighttextAligntextDecoration

Do not — a map collapses to base: cursorbackdropbgImagebgOverlaybgSize — and the state channels hoverfocusstates, which take a style object of their own rather than a value.

If you need a background image only above a breakpoint, put the two variants on two elements and toggle them with display: { base: "none", md: "block" }display is responsive.

Why a responsive value wins

A responsive key is not emitted inline. It becomes a deduplicated atomic class in one shared stylesheet, and that class is written into the selector three times — .ak-rX.ak-rX.ak-rX. That is deliberate: nearly every component styles itself with an attribute selector such as .rui-stack[data-gap="md"], which outranks a single class, so a single‑class rule would lose and the map would silently do nothing.

The tripled class still loses to !important and to inline styles, which is the intended order — a non‑responsive sx value is an inline style, so an explicit single value on the same element should beat a breakpoint rule. If a map looks ignored, check whether something else on the element already sets the same CSS property as a plain value, and fold it into the map’s base key instead.

Upgrading: maps that used to do nothing now apply

Re‑check any responsive sx map you wrote against a component’s own prop. Before the specificity fix, a map like Row([…], { gap: "sm", sx: { gap: { md: "xl" } } }) was overridden by the component’s own data-gap rule and rendered as if it were not there. It now takes effect at md, so a layout that looked correct only because the map no‑opped will change.

Interaction states

sx styles interaction states with no dynamic CSS injection — two complementary forms:

1. Quick effect shorthands

hover and focus accept a preset effect name (or an object of { effect: true }) that maps to a prebuilt utility class: liftgrowglowbrightborderunderlinescale.

Card([Text("Lift on hover")], { sx: { p: "lg", hover: "lift" } })
Button("Glow", { sx: { hover: { glow: true }, focus: { border: true } } })

2. Arbitrary state styling with states

For full control, states (or a rich object form of hover/focus) compiles a small, bounded style object into a scoped :state rule in the shared stylesheet. Supported states: hoverfocusfocus-visiblefocus-withinactivedisabledcheckedgroup-hover.

Each state value accepts these bounded keys: bgcolorborderColorshadowradiusopacitycursortextDecorationscaletranslateXtranslateYrotate. A 150 ms transition on background, color, shadow, transform, opacity and border‑color is added automatically, and prefers-reduced-motion is respected. rotate is capped at ±360 degrees.

Two states match more than their pseudo‑class, because component libraries express those conditions with attributes: disabled also fires on [disabled] and [data-disabled="true"], and checked also fires on [aria-checked="true"]. So styling disabled works on a custom control that is not a native <button>.

group-hover fires when an ancestor carrying the ak-group class is hovered — add it via { className: "ak-group" } on the parent to reveal/animate children on parent hover.

Live — hover & focus the cards / input
$app(Column([
  Grid([
    Card([Text("Hover: lift + shadow")], { sx: { p: "lg", states: { hover: { translateY: "-4px", shadow: "lg" } } } }),
    Card([Text("Hover: scale + tint")],  { sx: { p: "lg", states: { hover: { scale: 1.04, bg: "surface-muted" } } } }),
    Card([Text("Hover: rotate a touch")], { sx: { p: "lg", states: { hover: { rotate: 2, shadow: "md" } } } })
  ], { columns: 3, gap: "md" }),
  Input("email", { placeholder: "Focus me — border + glow",
    sx: { states: { focus: { borderColor: "primary", shadow: "md" } } } }),
  Box([
    Text("Parent is .ak-group — hover anywhere on me"),
    Box([Text("I appear on group hover")], { sx: { mt: "sm", opacity: 0.35, states: { "group-hover": { opacity: 1, color: "primary" } } } })
  ], { className: "ak-group", sx: { p: "lg", bg: "surface", border: "subtle", radius: "lg", cursor: "pointer" } })
], { gap: "md" }))

The sibling animate prop

Alongside sx, every component accepts animate for entrance / loop motion. Pass a preset name or an object { preset, delay?, duration?, repeat? } (delay / duration in ms; repeat a number or "infinite"). Motion auto‑respects prefers-reduced-motion.

Presets: fadefade-upfade-downfade-leftfade-rightzoomzoom-inslide-upslide-downslide-leftslide-rightpulsefloatshimmerbouncespinpingwiggle

Live — staggered entrance + a looping pulse
$app(Column([
  Card([Text("fade-up, delay 0")],   { animate: { preset: "fade-up", delay: 0 } }),
  Card([Text("fade-up, delay 120")], { animate: { preset: "fade-up", delay: 120 } }),
  Card([Text("fade-up, delay 240")], { animate: { preset: "fade-up", delay: 240 } }),
  Row([
    Badge("Live", { tone: "success", animate: { preset: "pulse", repeat: "infinite" } }),
    Text("loops forever")
  ], { gap: "sm" })
], { gap: "md" }))

The full universal channel

sx and animate are part of a small set of universal props every component accepts in addition to its own props:

PropWhat it does
sxThe bounded, token‑aware styling object documented on this page.
animateEntrance / loop motion preset (see above).
id / anchorSets the element’s id (validated identifier) — handy as a scroll anchor.
className / classEscape hatch. Adds sanitised CSS class tokens (string or array).
styleEscape hatch. A sanitised inline‑style string for the rare property sx doesn’t cover.
ariaAn object of ARIA attributes, e.g. { label: "Close", expanded: true }aria-*. Keys are lower‑cased for you; letters and hyphens only.
roleOverrides the element’s ARIA role. Allow‑listed — see below.
dataAn object of data-* attributes, e.g. { testid: "row-1" }. Keys are lower‑cased and stripped to a–z 0–9 -; null values are skipped.
dataAttrsThe same channel under a second name, for components that declare a data prop of their own. Wins when both are present.
testIdEnd‑to‑end test hook. Renders data-testid on the rendered root, value used verbatim (no character allow‑list). Wins over data/dataAttrs when more than one is present. Alias: testid.
tooltipSets the native title attribute.
hiddentrue → adds the hidden attribute.
Button("Close", {
  variant: "ghost", icon: "xmark",
  aria: { label: "Close dialog" },
  testId: "dialog-close",
  tooltip: "Esc",
  sx: { radius: "circle" }
})

Universal props sit alongside a component’s own props in the same options bag — there is no separate slot for them, and every component accepts all of them.

testId marks the component’s ROOT element. On a form field that renders a label, hint, error, or required marker, the root is the .rui-field wrapper rather than the control inside it — an unlabelled Input returns the bare control as its own root, but a labelled one returns the wrapper (see src/library/components/forms-shared.ts). Reach the control itself with a scoped query: within(getByTestId("email")).getByRole("textbox").

Overriding the role

role exists for the case where a component’s markup is right but its semantics are not — a Box that is really a live status region, a Row that is really a toolbar. It is allow‑listed: the value is trimmed and lower‑cased, and anything not on the list is dropped silently, because a plausible‑but‑wrong role is worse for a screen‑reader user than the original defect.

Accepted roles: bannercomplementarycontentinfoformmainnavigationregionsearcharticlegrouplistlistitemseparatorheadingfigurenotedefinitiontermstatusalertlogtimermarqueeprogressbarmeterbuttonlinkimgtoolbartooltipdialogtabtabpaneltablistmenuitemoptioncheckboxradioswitchnonepresentation

Box([Text("Live figures")], { padding: "md", background: "muted", role: "status" })
// roles that need owned children or matching ARIA state (grid, listbox, menu, …)
// are deliberately absent — a bare role override cannot supply those

Composite roles are excluded on purpose. If you need one, use the component that implements it — see Accessibility.

Six components need dataAttrs

LineChart, JsonTree, Async, Draggable, Lottie and QRCode all declare a data prop of their own — the payload they render. On those six the component prop wins, so use dataAttrs for the attribute channel.

QRCode("https://example.com/join", { size: 160, dataAttrs: { analyticsId: "join" } })
// the positional argument IS `data` (the QR payload); dataAttrs emits data-analyticsid="join"

On every other component the two spellings are interchangeable, and dataAttrs wins if you pass both.

A test id specifically no longer needs dataAttrs on these sixtestId is a separate universal channel that reaches the rendered root directly, regardless of whether the component declares its own data prop. dataAttrs remains the way to set any other data-* attribute on them.

Elements with no box of their own

A few component roots are display: contents — they exist in the tree but generate no box, so padding, background, width, a border, or an animate transform on them provably cannot render. Show, Async, Lazy and Fragment are a related case: they return a fragment rather than an element, so there is nothing for the universal channel to land on at all.

Both are handled for you. A fragment‑returning component gets a wrapper <span class="rui-universal-host"> when — and only when — the universal channel actually carries something, and a boxless root is promoted to display: block when your sx asks for a box (or your animate preset needs one). An empty output is left un‑hosted, so a false Show with no fallback still renders nothing rather than an empty padded box.

$open = false
$app(Column([
  Show($open, [Card([Text("Hosted in a span, so the padding and border render.")])], Text("Nothing yet."), {
    sx: { p: "lg", bg: "surface-muted", radius: "lg" }
  }),
  Button("Toggle", { onClick: () => $open = !$open })
], { gap: "md" }))

Two consequences worth knowing. Your sx styles the host span, not the branch inside it. And the promotion only fills in a display you did not set yourself: pass display: "contents" to keep the boxless behaviour, or any other value to choose the box.

The one override is hidden: true, which always wins — the browser’s own [hidden] rule would otherwise lose to an author display: contents and the element would stay visible. When you want the box to be explicit in the code rather than inferred, wrap the group in a Box.

Mini UIs built with sx

Putting the keys together. Each of these is plain components + sx — no stylesheet, no raw CSS.

Pricing card

Live
$app(Box([Column([
  Row([
    Text("Pro", { sx: { fontSize: "lg", weight: "700" } }),
    Badge("Popular", { tone: "primary", sx: { ms: "auto" } })
  ], { sx: { align: "center" } }),
  Row([
    Text("$29", { sx: { fontSize: "4xl", weight: "800" } }),
    Text("/mo", { sx: { color: "text-muted", mb: "xs" } })
  ], { sx: { align: "end", gap: "3xs" } }),
  Separator(),
  Column([
    Row([Icon("check", { sx: { color: "success" } }), Text("Unlimited projects")], { sx: { gap: "sm", align: "center" } }),
    Row([Icon("check", { sx: { color: "success" } }), Text("Priority support")], { sx: { gap: "sm", align: "center" } }),
    Row([Icon("check", { sx: { color: "success" } }), Text("Custom domains")], { sx: { gap: "sm", align: "center" } })
  ], { gap: "xs" }),
  Button("Choose Pro", { variant: "primary", sx: { w: "full", mt: "sm" } })
], { gap: "md" })], {
  sx: { maxW: "320px", mx: "auto", p: "xl", bg: "surface", border: "subtle", radius: "lg", shadow: "md",
        states: { hover: { translateY: "-4px", shadow: "lg" } } }
}))

Notification banner

Live
$app(Box([Row([
  Box([Icon("circle-info", { sx: { color: "#fff" } })], {
    sx: { p: "sm", bg: "rgba(255,255,255,.18)", radius: "circle" } }),
  Column([
    Text("Deployment complete", { sx: { color: "#fff", weight: "700" } }),
    Text("Your changes are live on production.", { sx: { color: "rgba(255,255,255,.85)", fontSize: "sm" } })
  ], { gap: "3xs" }),
  Button("View", { variant: "ghost", sx: { ms: "auto", color: "#fff", states: { hover: { bg: "rgba(255,255,255,.15)" } } } })
], { sx: { align: "center", gap: "md" } })], {
  sx: { p: "md", radius: "lg", bg: "gradient.success" }, animate: "slide-down"
}))

Glass profile card over a gradient

Live
$app(Box([
  Box([Column([
    Avatar({ name: "Ada Lovelace", size: "lg" }),
    Text("Ada Lovelace", { sx: { color: "#fff", weight: "700", fontSize: "lg" } }),
    Text("Founder & Engineer", { sx: { color: "rgba(255,255,255,.8)", fontSize: "sm" } }),
    Row([
      Button("Follow", { variant: "primary" }),
      Button("Message", { variant: "ghost", sx: { color: "#fff", borderColor: "rgba(255,255,255,.5)", border: "rgba(255,255,255,.5)" } })
    ], { sx: { gap: "sm", mt: "sm" } })
  ], { gap: "xs", align: "center" })], {
    sx: { p: "xl", radius: "lg", maxW: "320px", mx: "auto",
          bg: "rgba(255,255,255,.12)", backdrop: "blur", border: "rgba(255,255,255,.25)" }
  })
], { sx: { p: "2xl", bg: "gradient.brand", radius: "lg" } }))

Before you hand‑roll a chip

sx can build a pill, but two components already are one. Badge(label, { tone, icon }) is the solid, high‑attention chip (“Recommended”, “Save 50 %”). Pill(label, { tone, icon }) is the softer tinted label for the current state of a thing — “SSL active”, “pending”, “broken”.

Pill tones are neutral · activating · success · warning · critical · promoting · corporate. Both components follow the theme, both accept sx, and neither needs you to pick a padding.

Why didn’t my sx apply?

sx never throws and never warns: an unusable key or value is dropped so the rest of the page still renders. That is the right trade for a channel an LLM writes into, but it means a silent no‑op is the failure mode you will meet. Work down this table.

SymptomCauseFix
A key does nothing at all The key is not part of the bounded surface — a typo, or a CSS property sx does not expose. Check it against the tables above. For a genuinely missing property use the style escape hatch.
The key is right, the value is ignored The value is not a token or enum member for that key, and did not survive sanitisation. Use a token, or a plain length / colour. Enum keys (display, justify, shadow…) accept only the listed values.
An exotic raw length or colour vanished Lengths and colours pass a restricted alphabet with a 64‑character cap before they reach an inline style. Keep them short and plain. clamp(1rem, 2vw, 2rem) is fine; quotes, ;, url(…) and @import are not. See Security.
A whole style string vanished The raw style channel is all‑or‑nothing: a string containing markup, javascript:, expression(, @import, or a url(javascript:…) / url(data:text…) is rejected whole, as is anything over 2048 characters. Move what you can into sx; keep the leftover declaration minimal.
A responsive map never changes at the breakpoint That key has no responsive resolver, so the map collapsed to its base value. See which keys accept a map.
A responsive map is overridden Something on the same element sets the same CSS property inline, or with !important. See why a responsive value wins — and what still beats it.
Padding or a background does nothing on Show, a route, or a transition wrapper That root generates no box. It is promoted for you — unless your own sx declares a display. Drop the display key, or set a non‑contents one explicitly. See boxless roots.
data-* attributes never appear The component declares a data prop of its own, which shadows the channel. Use dataAttrs, or testId if it’s specifically a test hook — see the six components.
A role is missing from the DOM The value is not on the allow‑list. Pick one of the accepted roles, or use the component that implements the semantics.
An aria key is missing Keys are lower‑cased, then must be 2–33 characters of letters and hyphens — a key containing a digit or an underscore is dropped. Use the plain ARIA name. Case does not matter (labelledBy and labelledby both work) and the aria- prefix is optional.
No @media rules in a test environment Responsive emission needs constructable stylesheets. Where they are unavailable, the map degrades to its base value inline. Nothing — this is deliberate. Assert on the base value, or test breakpoints in a real browser.

Safety & gotchas

Cheat sheet

GoalSnippet
Padded surface card{ sx: { p: "lg", bg: "surface", border: "subtle", radius: "lg" } }
Push to the right (in a Row){ sx: { ms: "auto" } }
Center horizontally{ sx: { mx: "auto", maxW: "640px" } }
Full‑width button{ sx: { w: "full" } }
Hover lift{ sx: { states: { hover: { translateY: "-4px", shadow: "lg" } } } }
Gradient background{ sx: { bg: "gradient.brand", color: "#fff" } }
Hero with overlay{ sx: { bgImage: "/h.jpg", bgOverlay: "rgba(0,0,0,.5)" } }
Responsive width{ sx: { w: { base: "100%", md: "50%" } } }
Sticky header{ sx: { position: "sticky", top: 0, zIndex: "sticky" } }
Inline 3‑col grid{ sx: { display: "grid", columns: 3, gap: "md" } }
Pill‑shaped chip (or just use Pill){ sx: { px: "sm", py: "3xs", radius: "pill", bg: "danger", color: "#fff" } }
Glass panel{ sx: { backdrop: "blur", bg: "rgba(255,255,255,.12)" } }
Entrance animation{ animate: "fade-up" }
Fix a wrong role{ role: "status" }
Test hook on a chart / QR code{ testId: "chart" }

Next