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.
- One prop, every component. A single internal hook
applies
sxto the element a component renders — you never edit individual component specs to style them. - Tokens, not pixels. Prefer scale tokens
(
"lg","primary","md") so a theme swap restyles everything at once. Raw lengths/colors are accepted where it makes sense, but tokens travel better. - Unknown keys and invalid values are ignored — never
injected. A typo silently no‑ops rather than breaking the page, which is
also the one thing that can make
sxpuzzling: a checklist for a value that did nothing.
// 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:
- A responsive map (
{ base, sm, md, lg, xl }) — each breakpoint resolves with the rules below and emits real@mediarules. See responsive. - A token for that key (spacing scale, color name,
radius/shadow step, size keyword, z‑index layer…) → resolves to a
themeable
--rui-*CSS variable. - A sanitised scalar where allowed (a CSS length like
"12px"/"60%", a raw color, or a number). - 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-left
— safe is the all‑around shorthand.
| Key(s) | Sets | Example |
|---|---|---|
p | padding (all sides) | { p: "lg" } |
px / py | padding inline (L/R, logical) / block (T/B) | { px: "xl", py: "sm" } |
pt pr pb pl | padding top / right / bottom / left | { pt: "md", pb: "lg" } |
ps / pe | padding inline start / end (RTL‑aware) | { ps: "lg" } |
m | margin (all sides) | { m: "auto" } |
mx / my | margin inline (logical) / block | { mx: "auto" } |
mt mr mb ml | margin top / right / bottom / left | { mt: "xl" } |
ms / me | margin inline start / end (RTL‑aware) | { me: "auto" } |
gap | gap 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.
$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-hdvhminmaxfitauto
— full = 100%, dvh = 100dvh (dynamic viewport
height, ideal for mobile full‑height panels).
| Key | Sets | Example |
|---|---|---|
w / h | width / height | { w: "full", h: "240px" } |
minW / maxW | min / max width | { maxW: "640px" } |
minH / maxH | min / max height | { minH: "dvh" } |
$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: {…} }).
| Key | Sets | Example |
|---|---|---|
bg | background (token / gradient.* / raw) | { bg: "primary" } |
color | text color | { color: "text-muted" } |
borderColor | border color (pairs with border) | { borderColor: "danger" } |
$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
| Key | Accepts | Example |
|---|---|---|
border | none · subtle · strong · true/default · a color token/value | { border: "subtle" } |
radius | nonexssmmdlgpillfullcircle or a length | { radius: "lg" } |
shadow | nonesmmdlg | { shadow: "md" } |
opacity | a number 0–1 | { opacity: 0.6 } |
$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.
| Key | Accepts | Example |
|---|---|---|
display | flexgridblockinlineinline-flexinline-blocknonecontents | { display: "flex" } |
direction | rowcolumnrow-reversecolumn-reverse | { direction: "column" } |
align | startcenterendstretchbaseline (align‑items) | { align: "center" } |
justify | startcenterendbetweenaroundevenlystretch | { justify: "between" } |
wrap | true / false | { wrap: true } |
grow / shrink | a number (flex‑grow / flex‑shrink) | { grow: 1 } |
basis | a size keyword or length (flex‑basis) | { basis: "200px" } |
columns | a number → that many equal grid columns | { display: "grid", columns: 3 } |
$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.
| Key | Accepts | Example |
|---|---|---|
position | relativeabsolutefixedstickystatic | { position: "sticky" } |
top right bottom left | a size keyword or length | { top: 0 } |
inset | 0 or a length (all four offsets) | { inset: 0 } |
zIndex | a layer token or a number | { zIndex: "sticky" } |
$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.
| Key | Accepts | Example |
|---|---|---|
fontSize | xssmbasemdlgxl2xl3xl4xl or a length | { fontSize: "2xl" } |
weight | 100…900 · normal · bold | { weight: "700" } |
textAlign | leftcenterrightjustifystartend | { textAlign: "center" } |
textDecoration | underlinenoneline-throughoverline | { textDecoration: "underline" } |
$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
| Key | Accepts | Example |
|---|---|---|
overflow | hiddenautoscrollvisibleclip | { overflow: "hidden" } |
cursor | pointerdefaultnot-allowedgrabgrabbingtextmovewaithelpnone | { cursor: "pointer" } |
backdrop | blur — frosted‑glass backdrop filter | { backdrop: "blur" } |
$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.
| Key | Accepts | Example |
|---|---|---|
bgImage | an http(s) / relative / data:image URL | { bgImage: "/hero.jpg" } |
bgOverlay | a color token/value or gradient.* wash | { bgOverlay: "rgba(0,0,0,.45)" } |
bgSize | cover (default) · contain | { bgSize: "contain" } |
$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.
| Key | Min‑width |
|---|---|
base | 0 (the default, no media query) |
sm | 640px |
md | 768px |
lg | 1024px |
xl | 1280px |
$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.
$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
$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:
| Prop | What it does |
|---|---|
sx | The bounded, token‑aware styling object documented on this page. |
animate | Entrance / loop motion preset (see above). |
id / anchor | Sets the element’s id (validated identifier) — handy as a scroll anchor. |
className / class | Escape hatch. Adds sanitised CSS class tokens (string or array). |
style | Escape hatch. A sanitised inline‑style string for the rare property sx doesn’t cover. |
aria | An object of ARIA attributes, e.g. { label: "Close", expanded: true } → aria-*. Keys are lower‑cased for you; letters and hyphens only. |
role | Overrides the element’s ARIA role. Allow‑listed — see below. |
data | An object of data-* attributes, e.g. { testid: "row-1" }. Keys are lower‑cased and stripped to a–z 0–9 -; null values are skipped. |
dataAttrs | The same channel under a second name, for components that declare a data prop of their own. Wins when both are present. |
testId | End‑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. |
tooltip | Sets the native title attribute. |
hidden | true → 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 six
— testId 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
$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
$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
$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.
| Symptom | Cause | Fix |
|---|---|---|
| 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
- Bounded by design.
sxnever injects arbitrary CSS. Values that don’t resolve to a known token, enum, sanitised length/color, or scalar are dropped — so a hostile or mistaken value can’t break out into the stylesheet. The per‑sink sanitisers and what each one accepts are documented on Security. - Tokens beat raw values. A raw color or length works, but
it won’t follow theme / dark‑mode changes. Prefer
"primary"over a hex literal and"lg"over"24px". - Responsive needs constructable stylesheets. In headless
DOMs that lack them, a breakpoint map gracefully falls back to emitting the
basevalue inline (no media queries) rather than failing. bgImageis scheme‑whitelisted. Onlyhttp(s), relative paths, anddata:image/*are allowed;javascript:/blob:/data:textare rejected.- State styling is bounded too.
statesonly accepts the listed style keys and the listed pseudo‑states — anything else is ignored. - Use the escape hatches sparingly.
className/styleexist for the rare gap, but they skip the token system; reach for them last.
Cheat sheet
| Goal | Snippet |
|---|---|
| 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
Layout
Structure first: Column, Row, Grid, cards, and the primitives sx polishes.
Themes & tokens
Every token an sx value resolves to, and how to rebrand them in one call.
Component catalog
Every component and prop, with a live preview — all of them accept sx.