Theming

Themes.

Every Aktion component is already wired to 113 design tokens — colours, radii, spacing, type, shadows, gradients, z‑index layers, and motion. Pick one of six built‑in themes with a single attribute, or brand your own by overriding tokens in‑program with $theme({…}). No CSS files, no stylesheet plumbing — just a token map.

How theming works

A theme is nothing more than a bundle of CSS custom properties (--rui-color-primary, --rui-radius-md, …) written onto the <aktion-app> host element. Every component reads those variables, so changing a token reskins the whole tree instantly — without re‑rendering. There are three layers, and they compose:

LayerWhere it livesToken shapeScope
Host base theme attribute / setTheme() on the element A built‑in name ("dark") or a flat token map ({ colorPrimary: "…" }) Full replace — writes all 113 variables
Per‑response overlay A bare $theme({…}) inside the program Grouped tokens ({ colors: { primary } }) layered on top of the base Only the tokens you pass
Per‑node sx The universal sx prop on one component Token references (bg: "surface", bg: "gradient.brand") That element only — wins over both layers above

Because every layer resolves to the same --rui-* variables on one shadow host, themes never leak between embeds and sx keeps tracking the active theme instead of hard‑coding a colour.

Two token shapes, one system

The host paths (theme attribute, setTheme()) take flat keys like colorPrimary / radiusMd. The in‑program $theme({…}) takes grouped keys like colors.primary / radius.md. Both end up as the same --rui-* CSS variables — pick the shape that matches where you're setting the theme. Only the flat shape reaches all 113 tokens; see tokens with no $theme route.

Built-in themes

Eleven themes come in the box, under fifteen names. Select one with the theme attribute on the host element — light is the default if you omit it. Switching is instant: only the host's CSS variables change.

Four come in a light and a dark variant, and the bare name means the light one, so theme="shadcn" and theme="shadcn-light" are the same theme. Three of those are faithful re-creations of design systems you already know — shadcn/ui, Material UI and HeroUI. The fourth, Signal, is this library's own: an instrument console for the screens people watch rather than read.

<aktion-app theme="dark"></aktion-app>

An unknown name does not throw — it silently falls back to light, so a typo shows up as "my theme did nothing" rather than a crash.

ThemeFeelReach for it whenWeb fonts
light Default. White surfaces, indigo #4f46e5 primary, 6/10/16px radii, 1px #e2e8f0 hairlines, flat shadows. Neutral host embeds, docs, anything that must not have a personality of its own. None (system‑ui)
dark #0b1220 page over #111827 surfaces, #f8fafc text, indigo‑300 #818cf8 primary, One Dark syntax palette. Dark host pages. Pair it with app.setTheme(prefersDark ? "dark" : "light"). None
shadcn
shadcn-light
shadcn-dark
shadcn/ui, default neutral theme. White page, ink #171717 primary with a near‑white label, one flat #f5f5f5 wash doing secondary / muted / accent duty, rounded-md (8px) controls inside rounded-xl (14px) cards, a 3px 50%‑alpha focus ring, a segmented tab strip, a rounded-md badge and a tooltip painted in the primary colour — on 14px Geist. Dark inverts the primary to #e5e5e5 ink on #0a0a0a. Anything that should look like it was built with shadcn/ui — app dashboards, admin panels, developer tools. Yes — Geist + Geist Mono
mui
mui-light
mui-dark
Material UI, default theme. #1976d2 primary, #9c27b0 secondary, 4px radii everywhere, UPPERCASE 500‑weight buttons on 0.02857em tracking, borderless Paper separated by the real three‑layer elevation shadows, 56px outlined text fields, a 2px tab indicator and the charcoal 11px tooltip. Dark is #90caf9 on #121212 with elevation‑overlay surfaces. Enterprise apps, internal tools and anywhere the house style is Material. Yes — Roboto + Roboto Mono
heroui
heroui-light
heroui-dark
HeroUI. #006fee primary, 12px controls in 14px borderless cards on shadow-medium, filled #f4f4f5 fields, hover that dims to opacity: .8 instead of recolouring, press that scales to .97, a hard 2px offset focus outline and a light tooltip — on 16px Inter. Dark is pure black behind #18181b surfaces. Consumer products, marketing apps, anything that should feel rounded and modern. Yes — Inter
signal
signal-light
signal-dark
The instrument console. Built on one rule: colour is signal, chrome is not. The furniture is graphite end to end — there is no brand hue on a button — so the only saturated things on screen are status and data. Panels are ruled with hairlines and cast no resting shadow; every measured value is IBM Plex Mono and tabular; the page is 32px graph paper; tabs are channel selectors; a status chip wears an LED; the switch is rectangular. 13px body on a 3/6/10/14 ramp — the densest theme here. Observability dashboards, NOC and status walls, trading and ops desks, log and telemetry viewers, admin control planes. signal-dark is the one most control rooms run. Yes — IBM Plex Sans + IBM Plex Mono
soft Friendly and rounded. Lavender #7c4ddb primary with mint accent, #fdf6ff page, 12/20/28px radii, tinted shadows, cards lift on hover, a wider spacing ramp. Consumer, wellness and education UIs — anything that should read as gentle. Declares Quicksand/Nunito, does not load them

Renamed in 0.7. modern, glass and corporate were replaced by shadcn, mui and heroui. The old spellings still resolve — the resolver rewrites them, so theme="modern" renders shadcn/ui and the host reports data-rui-theme="shadcn-light" — but they are no longer listed anywhere and will be removed in a future release.

Names retired earlier are gone for good: neon, brutalist and skyline were removed and pastel became soft. Each of those now resolves to light.

What each theme actually changes

Every theme shares the same token names, so a component never has to know which one is active. These are the values that give each theme its character (the -dark variants share their sibling's shape and type and differ only in colour):

Tokenlightdarkshadcnshadcn-darkmuimui-darkherouiheroui-darksignalsignal-darksoft
colorBg#ffffff#0b1220#ffffff#0a0a0a#ffffff#121212#ffffff#000000#f6f7f8#0b0e11#fdf6ff
colorSurface#ffffff#111827#ffffff#171717#ffffff#1e1e1e#ffffff#18181b#ffffff#14181d#ffffff
colorText#0f172a#f8fafc#0a0a0a#fafafargba(0,0,0,.87)#ffffff#11181c#ecedee#11181f#e6edf3#3b1f56
colorPrimary#4f46e5#818cf8#171717#e5e5e5#1976d2#90caf9#006fee#006fee#1f272e#c9d4de#7c4ddb
colorAccent#4f46e5#818cf8#171717#e5e5e5#9c27b0#ce93d8#7828c8#9353d3#0f6f7a#4bc3d4#5eead4
radiusSm/Md/Lg6/10/166/10/166/10/146/10/144/4/44/4/48/12/148/12/144/6/84/6/812/20/28
radiusButton6px6px8px8px4px4px12px12px4px4px16px
fontSizeBase1414141416161616131314
buttonTextTransformnonenonenonenoneuppercaseuppercasenonenonenonenonenone
spacing xs…3xl4/8/12/20/32/48/80same4/8/12/24/36/56/88same4/8/16/24/32/48/80same4/8/12/16/28/48/80same3/6/10/14/24/40/64same6/12/18/28/44/66/110
buttonPaddingY/X8/148/148/168/166/166/1610/1610/165/125/128/14
transitionDuration120ms120ms150ms150ms150ms150ms250ms250ms90ms90ms120ms

Tokens alone cannot say "hover dims instead of recolouring" or "Paper has no border", so shadcn, mui, heroui, signal and soft each also ship a block of theme‑specific CSS on top of their tokens — that is where MUI's uppercase tabs and elevation shadows, HeroUI's opacity hover and offset focus outline, shadcn's segmented tab strip and 3px focus ring, and Signal's graph-paper page, monospace readouts and double-hairline focus ring live. light and dark are purely token‑driven. That distinction matters for how you select a theme.

Fonts a theme brings with it

Selecting a theme by name also loads the web fonts it declares. The four themes that need a specific face each declare their own, because the typeface is half of what makes them recognisable:

builtInThemeFonts = {
  shadcn: { import: ["Geist:400,500,600,700", "Geist Mono:400,500"] },
  mui:    { import: ["Roboto:300,400,500,700", "Roboto Mono:400,500"] },
  heroui: { import: ["Inter:400,500,600,700"] },
  signal: { import: ["IBM Plex Sans:400,500,600,700", "IBM Plex Mono:400,500"] },
  // …the -light and -dark spellings of each declare the same families
}

So <aktion-app theme="mui"> and $theme({ name: "mui" }) both inject one de‑duplicated Google Fonts <link> into document.head before painting the tokens. Without it the theme would render in system-ui and its whole type ladder would be invisible.

Three caveats worth planning for

1. setTheme("mui") does not load the fonts. Only the theme attribute and $theme({ name }) take that path. If you switch themes imperatively and want the families too, set the attribute (app.setAttribute("theme", "mui")) or add a fonts group of your own.

2. soft names families it does not load. Its fontFamily starts with Quicksand, but that family is not in builtInThemeFonts, so it silently falls through to system-ui unless the host page loads it or the program adds $theme({ fonts: { import: ["Quicksand:400,600"] } }).

3. It is an outbound request. Allow fonts.googleapis.com in style-src and fonts.gstatic.com in font-src in your CSP. Offline or air‑gapped? Self‑host the families and set font.family / font.familyHeading yourself instead of relying on the automatic load.

Set the theme imperatively too — handy for a user‑facing theme switcher in the host page:

const app = document.querySelector("aktion-app");
app.setTheme("dark");                       // a built-in name
app.setTheme({ colorPrimary: "#7c3aed" });  // flat token overrides

Both calls repaint the base layer, which also resets the per‑response overlay tracker — the next render reapplies any $theme({…}) from the active program on top. This docs site has its own light/dark toggle in the top bar, and the live demos below follow it.

Branding in‑program with $theme({…})

Inside a program, write a bare $theme({…}) statement (anywhere at the top level, alongside your $app(…) root). The runtime writes those tokens to the host and reskins the UI instantly. Tokens live in groups: override just colors.primary and every button, badge, link, and focus ring follows.

Live
$theme({ colors: { primary: "#7c3aed", primaryHover: "#6d28d9" } })
$app(Column([
  Card([
    CardHeader("Purple brand", { subtitle: "Only colors.primary changed" }),
    Row([
      Button("Primary", { variant: "primary" }),
      Button("Ghost", { variant: "ghost" }),
      Badge("New", { tone: "primary" })
    ], { gap: "sm" })
  ])
], { gap: "md" }))

Two tokens changed; everything else — surfaces, type, radii, shadows — still comes from the host theme.

Combine groups. Here colors plus radius give a mint‑accented look with a bigger card radius and fully‑rounded buttons:

Live
$theme({
  colors: { primary: "#0d9488", primaryHover: "#0f766e", accent: "#f59e0b" },
  radius: { md: "18px", button: "999px" }
})
$app(Column([
  Card([
    CardHeader("Mint & rounded"),
    Text("Bigger card radius, pill-shaped buttons."),
    Row([
      Button("Save", { variant: "primary" }),
      Button("Cancel", { variant: "secondary" }),
      Badge("Beta", { tone: "warning" })
    ], { gap: "sm" })
  ])
], { gap: "md" }))

Typography is a group too. Override font.family and font.familyHeading for a different voice — here a serif editorial look with a crimson accent and sharp corners:

Live
$theme({
  colors: { primary: "#dc2626", primaryHover: "#b91c1c" },
  font:   { family: "Georgia, 'Times New Roman', serif", familyHeading: "Georgia, serif" },
  radius: { md: "2px", button: "2px" }
})
$app(Column([
  Card([
    CardHeader("Editorial", { subtitle: "Serif type, crimson accent, sharp corners" }),
    Text("$theme({...}) overrides only the tokens you pass — everything else inherits the host theme."),
    Row([Button("Read more", { variant: "primary" }), Badge("Featured", { tone: "danger" })], { gap: "sm" })
  ])
], { gap: "md" }))

Start from a built‑in palette instead of light by adding the name metadata key. It seeds the full token set from that theme, then your groups layer on top:

$theme({ name: "heroui", colors: { primary: "#0f766e" } })
$app(Column([
  Card([CardHeader("Console", { subtitle: "heroui base, one override" })])
], { gap: "md" }))

An unknown name is ignored and seeds nothing, so the active base theme survives a typo rather than being wiped.

$theme({ name }) is not the same as theme="…"

The theme attribute and setTheme() write a data-rui-theme marker onto the host, and that marker is what gates each theme's extra CSS block. $theme({ name }) copies the theme's token values (and loads its fonts) but never touches the marker.

So $theme({ name: "mui" }) gives you Material's colours, radii and elevation shadows, but not the uppercase tab strip, the 56px outlined fields or the borderless Paper — those live in the [data-rui-theme^="mui"] block. Same for shadcn's focus ring and segmented tabs, HeroUI's dimming hover, and soft's hover lift. A flat token map resolves to the name "custom" and gets no block either. light and dark are unaffected: they have no override block at all. Want the full personality? Select the theme on the host, then layer $theme({…}) on top.

Token group reference

Inside $theme({…}) the top‑level keys are structured groups. Each group's camel‑case keys flatten to a predictable CSS variable: colors.primary--rui-color-primary, radius.md--rui-radius-md, font.familyHeading--rui-font-family-heading. Here is every group:

GroupCSS variableKeys
colors--rui-color-*31 keys — see the colour table below.
radius--rui-radius-*xs · sm · md · lg · pill · button · input
spacing--rui-spacing-*3xs · 2xs · xs · sm · md · lg · xl · 2xl · 3xl
font--rui-font-*family · familyHeading · familyMono · sizeBase · sizeSm · sizeLg · sizeHeading · sizeTitle · size10 · size11 · size13 · size15 · size18 · size20 · size24 · size32 · weightBody · weightHeading
shadows--rui-shadow-*sm · md · lg
gradients--rui-gradient-*brand · accent · warm · cool · success · danger
zIndex--rui-z-*base · raised · dropdown · sticky · banner · overlay · modal · popover · toast · tooltip
motion--rui-motion-*fast · base · slow · ease
fontsimport: ["Inter:400,700"] — web‑font loader (side effect, not a variable). import is the only accepted key.
icons{ name: "<svg…>" } — register inline SVG icons (side effect).

Two metadata keys are accepted at the top level: name selects one of the six built‑in themes as the base palette before your overrides layer on top, and direction ("ltr"/"rtl") is advisory metadata. Neither is emitted as a CSS variable. Flat keys (colorPrimary) and free‑form CSS variables are dropped at runtime and reported by the schema validator with a migration hint — inside $theme({…}) always use the grouped shape.

fonts loads families, font assigns them

The two group names are one letter apart and do different jobs. fonts: { import: […] } is a side effect that injects the Google Fonts link; font: { family: … } sets the CSS variable. fonts: { family: … } is silently dropped — it flattens to a token that does not exist. Real brands usually need both keys together:

$theme({
  fonts: { import: ["Inter:400,600", "Space Grotesk:600"] },
  font: { family: "'Inter', system-ui, sans-serif", familyHeading: "'Space Grotesk', 'Inter', sans-serif" }
})
$app(Text("Corporate-like type", { variant: "title" }))

That pair is exactly what theme="shadcn" or theme="mui" does for you.

Colour tokens

The colors group is the one you'll reach for most: 31 keys, of which the light theme sets 28 and derives the last three. Defaults shown are light's.

KeyCSS variableDefault (light)Role
bg--rui-color-bg#ffffffPage background.
bgSubtle--rui-color-bg-subtle#f8fafcSubtle page wash.
surface--rui-color-surface#ffffffCards, sheets, panels.
surfaceMuted--rui-color-surface-muted#f1f5f9Inset / secondary surfaces.
surfaceHover--rui-color-surface-hoverderivedcolor-mix(…) of 6% text over surfaceHover tint for rows, menu items and clickable cards.
border--rui-color-border#e2e8f0Hairlines, dividers, table row separators. Deliberately faint.
borderSubtle--rui-color-border-subtlergba(15,23,42,.08)The faintest divider.
borderControl--rui-color-border-control#767f8cInteractive control boundary — inputs, selects, textareas, checkboxes, radios, switch tracks, picker triggers. Holds 3:1 against the surface.
text--rui-color-text#0f172aPrimary body text.
textMuted--rui-color-text-muted#475569Secondary / caption text.
primary--rui-color-primary#4f46e5Brand colour — primary buttons, active states, and body text in 120+ rules.
primaryHover--rui-color-primary-hover#4338caHover / pressed brand colour.
primaryText--rui-color-primary-text#ffffffText and icons on a primary fill.
accent--rui-color-accent#4f46e5Secondary accent — chips, callouts, accent fills.
accentHover--rui-color-accent-hover#4338caHover accent.
accentText--rui-color-accent-text#ffffffText on an accent fill.
link--rui-color-linkderived from accentInteractive text — links, link/ghost buttons, the selected tab label, pagination digits.
linkHover--rui-color-link-hoverderived from accentHoverHover state for the above.
focusRing--rui-color-focus-ring#4f46e5Keyboard focus outline and border. Holds 3:1 against the surface.
success--rui-color-success#10b981Positive status fill.
warning--rui-color-warning#f59e0bCaution status fill.
danger--rui-color-danger#ef4444Error / destructive status fill.
info--rui-color-info#06b6d4Informational status fill.
successText--rui-color-success-text#047857The success hue painted as text.
warningText--rui-color-warning-text#a35a00The warning hue painted as text.
dangerText--rui-color-danger-text#d92d20The danger hue painted as text.
infoText--rui-color-info-text#0e7490The info hue painted as text.
onSuccess--rui-color-on-success#04291eInk painted on a success fill.
onWarning--rui-color-on-warning#451a03Ink painted on a warning fill.
onDanger--rui-color-on-danger#2c0606Ink painted on a danger fill.
onInfo--rui-color-on-info#0c2b3aInk painted on an info fill.

link, linkHover and surfaceHover are the three optional keys light leaves out: the stylesheet derives them from accent, accentHover and surface. Set them when the derivation is wrong for your palette — soft, mui and heroui all ship an explicit link (HeroUI's #006fee is only 3.8:1 as text on its dark card, so the dark variant's link steps up to #66aaf9), and dark overrides only linkHover because hover has to brighten on a dark surface.

Status colours come in threes

Each status hue is really three tokens, because the same green cannot legally do all three jobs. This is the part of the token set people most often get wrong, so it is worth learning once:

TokenJobContrast barExample
colors.success The fill or shape 3:1 against the surface (WCAG 1.4.11) The bar of a success Progress, a filled step dot
colors.successText The hue used as text 4.5:1 against the surface Badge, Tag, Pill, Callout copy, a StatCard value, field errors
colors.onSuccess The ink on the fill 4.5:1 against the fill The ✓ inside a completed Step, a Toast icon disc, a danger Button label

The same triple exists for warning, danger and info. Why it matters numerically: #10b981 measures 2.54:1 on white, so it is a fine bar colour and an illegal text colour — and the stylesheet paints status text in roughly 45 places.

Rule for theme authors

Retint a status hue → retint its *Text and on* partners. Set colors.success alone and your fills go brand‑coloured while every success label stays on the built‑in teal. The partners are optional keys, so nothing breaks — it just looks half‑rebranded.

Live
$theme({
  colors: { success: "#0d9488", successText: "#0f766e", onSuccess: "#04291e" }
})
$app(Column([
  Row([Badge("Passing", { tone: "success" }), Pill("SSL active", "success")], { gap: "sm" }),
  Callout("Deploy finished", { tone: "success", description: "All checks green." })
], { gap: "md" }))

All three tokens move together, so the badge text, the pill tint and the callout icon disc all land on the same teal.

borderControl follows the same logic one level up. colors.border is intentionally faint (1.17–1.70:1 in every built‑in theme) because it also draws dividers and table separators; darkening it would make the whole library heavy. colors.borderControl carries the accessible 3:1 boundary for controls a user has to find. Retint both when you rebrand. See Accessibility for the contrast guarantees the themes are tested against.

Radius, spacing, type & shadows

These shape and rhythm tokens drive corner rounding, the spacing scale behind gap/padding props and sx, the type ramp, and elevation. Defaults are from the light theme.

GroupKey → default
radiusxs 4px · sm 6px · md 10px · lg 16px · pill 999px · button 6px · input 6px
spacing3xs 1px · 2xs 2px · xs 4px · sm 8px · md 12px · lg 20px · xl 32px · 2xl 48px · 3xl 80px
font sizessizeBase 14px · sizeSm 12px · sizeLg 16px · sizeHeading 16px · sizeTitle 22px
font numeric rungssize10 10px · size11 11px · size13 13px · size15 15px · size18 18px · size20 20px · size24 24px · size32 32px
font weightsweightBody 400 · weightHeading 700
font familiesfamily / familyHeading system‑ui stack · familyMono ui‑monospace stack
shadowssm 0 1px 2px rgba(15,23,42,.06) · md 0 6px 24px rgba(15,23,42,.08) · lg 0 18px 60px rgba(15,23,42,.12)

The eight numeric rungs exist so a theme can retune the whole type scale, not just the five named sizes. They carry the values the stylesheet used to hardcode, so adopting them is a visual no‑op until you change one.

spacing advertises t‑shirt names but stores sm/md/lg in the historical short variables, so spacing: { md: "16px" } and spacing: { m: "16px" } both write --rui-spacing-m. The stylesheet also publishes read‑only aliases --rui-spacing-sm|md|lg and --rui-spacing-none: 0px if you need to var() them directly.

The size scale and its legacy spellings

One scale backs every spacing‑ish prop (gap, padding, sx.p, sx.m) and a shorter one backs every component size prop. Six older spellings still work but are no longer advertised in prop enums:

ScaleCanonical valuesAlso accepted
Spacing (gap, padding, sx) none · 3xs · 2xs · xs · sm · md · lg · xl · 2xl · 3xl ssm · mmd · llg
Component size xs · sm · md · lg · xl s/smallsm · m/normalmd · l/largelg
$app(Column([
  Row([
    Button("Small", { size: "sm" }),
    Button("Medium", { size: "md" }),   // md is the default
    Button("Large", { size: "lg" })
  ], { gap: "xs" }),
  Column([Text("3xs gap"), Text("between these")], { gap: "3xs" }),
  Column([Text("2xl gap"), Text("between these")], { gap: "2xl" })
], { gap: "lg" }))

none always resolves to 0, and an unrecognised value falls back to the prop's default rather than leaking through as an unmatched attribute — size: "enormous" renders as md.

Tokens with no $theme route

25 of the 113 tokens have no structured group, because there is no buttons, charts or syntax group to put them in. Passing them inside $theme({…}) does nothing. Reach them with the flat host paths instead:

FamilyFlat tokensDefault
Buttons (5)buttonFontWeight · buttonTextTransform · buttonLetterSpacing · buttonPaddingY · buttonPaddingX600 · none · 0 · 8px · 14px
Charts (6)chart1chart6#6366f1 #10b981 #f59e0b #ef4444 #06b6d4 #8b5cf6
Syntax highlighting (8)hlKeyword · hlString · hlNumber · hlComment · hlFn · hlTag · hlAttr · hlPunctA light palette; dark swaps in One Dark
Type metrics (4)lineHeightBody · lineHeightHeading · letterSpacingHeading · headingTextTransform1.5 · 1.2 · 0 · none
Misc (2)borderWidth · transitionDuration1px · 120ms
// Flat keys reach every token, including the 25 with no group.
app.setTheme({ chart1: "#7c3aed", buttonPaddingX: "20px", transitionDuration: "80ms" });

The same map works on the attribute as JSON (theme='{"buttonPaddingX":"20px"}'). If an override of yours is being ignored, check this list first — a groupless token inside $theme({…}) is dropped without an error.

Gradients, fonts & custom icons

Three groups go beyond colours and radii. Gradients are named and reusable, and they power GradientText and the sx.bg: "gradient.brand" intent. Fonts load on demand from a shorthand list. Icons register inline SVG under a name you can use anywhere an icon name is accepted.

Gradients

A gradient value can take three shapes. All three are sanitised before they reach CSS:

Live
$theme({
  gradients: {
    brand: ["#111827", "#7c5cfc"],                            // ≥2 stops → 120deg
    cool: { stops: ["#2563eb", "#22d3ee"], angle: 135 },       // your own angle
    warm: "linear-gradient(90deg, #f59e0b, #ec4899)"           // a raw gradient string
  }
})
$app(Column([
  Display([GradientText("Three gradient shapes", { gradient: "brand" })], { size: "lg" }),
  Row([
    Card([Text("cool")], { sx: { bg: "gradient.cool", p: "lg", radius: "lg", color: "#fff" } }),
    Card([Text("warm")], { sx: { bg: "gradient.warm", p: "lg", radius: "lg", color: "#fff" } })
  ], { gap: "md" })
], { gap: "md" }))

The six gradient names are fixed — brand, accent, warm, cool, success, danger — and light's brand is linear-gradient(120deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%).

RuleBehaviour
Stop countFewer than 2 valid stops collapses the gradient to "" and the token is dropped.
Angle{ angle } is rounded to a whole degree; the default is 120deg.
Stop syntaxEach stop is ≤64 chars and must match [a-zA-Z0-9#%.,()\s+-] — hex, rgb(), hsl() and named colours all pass.
Raw stringMust start with linear-, radial- or conic-gradient( and stay ≤256 chars.
Always rejectedurl(, expression(, javascript:, @import, and anything that looks like a tag.

A rejected stop is dropped silently rather than escaping into the stylesheet — see Security for the wider sanitiser story.

Web fonts

Add a fonts group with an import list of "Family:weights" shorthands. Aktion injects one sanitised Google Fonts <link rel="stylesheet">, after which the family is available to font.family:

$theme({
  fonts: { import: ["Inter:400,600,700", "JetBrains Mono", "Inter:400,500i"] },
  font:  { family: "Inter, system-ui, sans-serif", familyMono: "'JetBrains Mono', ui-monospace, monospace" }
})

Weights are optional ("JetBrains Mono" loads the default face) and an i suffix means italic ("Inter:400,500i"). The loader is idempotent and de‑duplicates by URL, so repeating an import across renders costs nothing.

PartAcceptedOtherwise
Family nameLetters, digits and spaces, 1–48 charsDropped
WeightAn integer 100–900, optionally suffixed iDropped
ResultOne …/css2?…&display=swap link tagged data-rui-font

The strict grammar is deliberate: it means a family name coming from model output cannot smuggle a foreign origin or a CSS payload into your page.

Custom icons

Custom icons register inline SVG under a name. Once registered, that name works anywhere an icon name is accepted — the icon prop on Button, Icon("spark"), list bullets, and so on:

Live
$theme({ icons: { spark: "<svg viewBox='0 0 24 24'><path d='M12 2l2 7 7 2-7 2-2 7-2-7-7-2 7-2z'/></svg>" } })
$app(Row([Button("Custom icon", { icon: "spark", variant: "primary" }), Icon("spark")], { gap: "sm" }))

A registered name wins over the Font Awesome lookup, so you can also replace a built‑in icon. Custom SVG is sized to 1em so it matches the surrounding text. The host equivalent is app.registerIcons({…}).

Using theme tokens in your UI

You rarely touch raw colours — components already speak the active theme. When you do need to style something directly, reference tokens through the universal sx prop so it tracks the theme instead of hard‑coding values. Colour tokens (primary, surface, text-muted, …), gradient refs (gradient.brand), spacing tokens (md, lg), and radii all resolve to the same --rui-* variables your theme defines:

Live
$theme({ colors: { primary: "#0d9488" } })
$app(Row([
  Card([Text("Token-styled", { sx: { color: "primary", weight: "700" } })],
    { sx: { bg: "surface", p: "lg", radius: "lg", shadow: "md" } }),
  Card([Text("Gradient bg")],
    { sx: { bg: "gradient.brand", p: "lg", radius: "lg", color: "#fff" } })
], { gap: "md" }))

Because these are tokens, one $theme({…}) override reskins every usage at once. $util.style.token("colors.primary") gives you the same var(--rui-color-primary) string if you need it in an expression. See the sx reference and the universal props reference for the full surface.

Layers, z-index & motion

Two specialised groups make app‑wide behaviour brandable. zIndex tokens feed the sx.zIndex prop and every overlay in the library, so one override re‑orders your whole layering stack:

zIndex keyCSS variableDefaultUsed by
base--rui-z-base0Normal flow.
raised--rui-z-raised10Hover lifts, focused cards.
sticky--rui-z-sticky100Sticky headers, FAB, speed dial.
banner--rui-z-banner300Reading progress, page‑top bars.
overlay--rui-z-overlay900Scrims — must sit under what they dim.
dropdown--rui-z-dropdown1000Menus, listboxes, autocompletes.
modal--rui-z-modal1100Modal, sheet, drawer, command palette.
popover--rui-z-popover1350Popovers anchored above a modal.
toast--rui-z-toast1400Toast stack.
tooltip--rui-z-tooltip1500Tooltips.

Five more layer variables exist in the stylesheet but are not themeable, because a program should not be able to reorder them: --rui-z-header: 200, --rui-z-modal-top: 1200 (a dialog that must beat another modal), --rui-z-top: 1600 (lightbox, tour, confetti), --rui-z-skip-link: 1700 and --rui-z-max: 2147483000. zIndex: { header: … } is silently dropped.

motion tokens brand durations and easing. They are optional, and set only by the three framework themes — shadcn 100/150/300ms on cubic-bezier(0.4, 0, 0.2, 1), MUI 150/250/300ms on the same curve, HeroUI 150/250/300ms on cubic-bezier(0, 0, 0.2, 1); when a token is absent, components fall back to their built‑in timings.

$theme({
  zIndex: { modal: 2000, toast: 2100 },
  motion: { fast: "100ms", base: "160ms", slow: "260ms", ease: "cubic-bezier(.2,.8,.2,1)" }
})

The four keys are fast, base, slow and easemotion.duration is not one of them and does nothing. The related transitionDuration token (the shared hover/focus timing, 120ms) has no group either; set it flat.

Per-response vs host theme

The layers compose cleanly. The host sets a base with the theme="…" attribute (one of the six built‑ins, or a flat JSON token map) or with setTheme(). A program then layers a per‑response overlay by writing a bare $theme({…}) statement — it applies only the tokens you pass, so everything else still inherits the host theme.

Delete the $theme line and the UI snaps straight back to the base with no reload: the overlay's keys are tracked and cleared before every render. Repainting the base (changing the attribute, or calling setTheme()) resets that tracker, and the next render reapplies the overlay on top. Because everything is scoped to one <aktion-app> shadow host, themes never leak across embeds or responses.

You wantUseWhy
An app‑wide look, including a theme's own CSS personality theme="mui" or setTheme("mui") Writes data-rui-theme, so the per‑theme CSS block engages. The attribute also loads the theme's fonts.
A single response branded differently $theme({ colors: {…} }) Partial overlay on the current base; reverts when the line goes away.
A groupless token (chart1, buttonPaddingX, hl*) setTheme({…}) or JSON on the attribute Only the flat shape covers all 113 tokens.
One element styled differently sx on that component Inline styles win over both theme layers, and still reference tokens.

Host and JavaScript API

Everything above is reachable from the host page. These are the attributes and methods that matter for theming, plus the exported helpers if you are building your own theme tooling.

SurfaceSignatureWhat it does
theme attribute theme="dark" · theme='{"colorPrimary":"#7c3aed"}' Observed, so changing it live re‑applies. Accepts a built‑in name or a flat JSON token map. Loads the theme's web fonts.
setTheme() app.setTheme(name | flatTokens) Same base layer, imperatively. Does not load built‑in theme fonts.
registerIcons() app.registerIcons({ spark: "<svg…" }) Host equivalent of $theme({ icons }). A registered name beats the Font Awesome lookup.
dir attribute dir="ltr" | "rtl" | "auto" The only thing that actually flips layout. See below.
margin attribute margin="12" · margin="1rem" · margin="0" Sets --rui-app-margin (the app shell's padding, default 20px). A bare number means pixels; a malformed value clears the override.
transparent attribute <aktion-app transparent> Drops the host background so the app inherits the surrounding page's. Cards keep their own surface colours.
import type { ThemeTokens } from "aktion-runtime";

const app = document.querySelector("aktion-app")!;
app.setTheme("mui-dark");                                      // built-in name
app.setTheme({ colorPrimary: "#7c3aed", chart1: "#7c3aed" });  // flat keys reach chart*/button*/hl*

The bundle also exports the theme primitives themselves, so a theme editor or a design‑token pipeline can use the same code paths the element does:

ExportSignatureUse it for
resolveTheme (input: string | Partial<ThemeTokens> | null) => { name, tokens } Turn whatever the host passed into a full token set. A name looks up builtInThemes; a string starting with { is parsed as JSON; an object merges over light and comes back as name: "custom". Unknown or malformed input returns light — it never throws.
applyTheme (host, ResolvedTheme | ThemeTokens) => void The full‑replace layer. Writes all 113 variables (removing any the theme omits so var(--x, fallback) still resolves) and sets data-rui-theme.
applyPartialTheme (host, partial) => ReadonlyArray<keyof ThemeTokens> The composable overlay. Writes only the keys present, skips unknown keys, non‑strings and "", and returns the keys it applied so you can undo exactly those. This is what $theme({…}) uses.
clearTokenOverrides (host, keys) => void Remove the listed variables so the next paint falls back to the base theme. The other half of an applyPartialTheme round trip.
sanitiseThemeTokens (input: unknown) => Partial<ThemeTokens> Filter an arbitrary object down to real token keys before applying it. Stringifies numbers (buttonFontWeight: 700"700") and drops null, empty strings, arrays and unknown keys. The typo guard between model output and your host.
builtInThemes · builtInThemeFonts Record<string, ThemeTokens> · Record<string, { import: string[] }> Enumerate the built‑in themes and their font declarations — how a theme picker builds its own list. Individual objects (lightTheme, shadcnLightTheme, muiDarkTheme, …) are exported too, along with deprecatedThemeAliases and canonicalThemeName() for the retired names.

Server‑side rendering does not emit token variables: renderToString has no theme layer, so the browser element applies them on hydration.

tone vs variant

Two prop vocabularies show up all over the library, and they are aliases of each other almost everywhere. tone means semantic colour (success, warning, danger, …) and variant means visual style (outline, ghost, elevated, …). 44 of the 282 components declare one or both.

$app(Row([
  Badge("Live", { variant: "success" }),      // variant → the tone slot
  Button("Save", { tone: "primary" }),        // tone → the variant slot
  Callout("Deployed", { variant: "success" })
], { gap: "sm" }))

All three validate and render: every tone prop carries variant as an alias and vice versa, so guessing the other spelling is no longer a hard error. The alias never invents enum values, though — Button still rejects variant: "success" because success is not one of its variants.

The exception: Text and TextContent

On those two components the props are deliberately not bridged, because they mean genuinely different things: variant is the typographic role (small, small-heavy, body, body-heavy, large, large-heavy, heading, title) and tone is the colour (default, muted, primary, success, warning, danger). You often want both at once:

$app(Column([
  Text("Caption", { variant: "small", tone: "muted" }),
  Text("Section title", { variant: "heading" })
], { gap: "xs" }))

Dark mode

There is a first‑class built‑in dark theme — no token authoring required. Set it on the host and your whole tree picks up the dark surfaces, text, borders, shadows and the One Dark syntax palette:

<aktion-app theme="dark"></aktion-app>

Layer a per‑response $theme({…}) on top of dark to brand it — e.g. override colors.primary while keeping the dark surfaces underneath. To follow the OS preference, read it in the host page and call app.setTheme(prefersDark ? "dark" : "light"). Neither theme has a per‑theme CSS block, so this is one of the two cases where $theme({ name: "dark" }) is equivalent to the attribute.

Right-to-left & direction

Theming and layout are direction‑aware. Set dir="rtl" (or "auto") on <aktion-app> to flip the whole tree; logical sx props like px/mx and ps/pe mirror automatically. The direction key inside $theme({…}) is advisory metadata only — use the dir attribute to actually flip the layout.

<aktion-app theme="light" dir="rtl"></aktion-app>

Next