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:
| Layer | Where it lives | Token shape | Scope |
|---|---|---|---|
| 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.
| Theme | Feel | Reach for it when | Web 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 |
shadcnshadcn-lightshadcn-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 |
muimui-lightmui-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 |
herouiheroui-lightheroui-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 |
signalsignal-lightsignal-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):
| Token | light | dark | shadcn | shadcn-dark | mui | mui-dark | heroui | heroui-dark | signal | signal-dark | soft |
|---|---|---|---|---|---|---|---|---|---|---|---|
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 | #fafafa | rgba(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/Lg | 6/10/16 | 6/10/16 | 6/10/14 | 6/10/14 | 4/4/4 | 4/4/4 | 8/12/14 | 8/12/14 | 4/6/8 | 4/6/8 | 12/20/28 |
radiusButton | 6px | 6px | 8px | 8px | 4px | 4px | 12px | 12px | 4px | 4px | 16px |
fontSizeBase | 14 | 14 | 14 | 14 | 16 | 16 | 16 | 16 | 13 | 13 | 14 |
buttonTextTransform | none | none | none | none | uppercase | uppercase | none | none | none | none | none |
spacing xs…3xl | 4/8/12/20/32/48/80 | same | 4/8/12/24/36/56/88 | same | 4/8/16/24/32/48/80 | same | 4/8/12/16/28/48/80 | same | 3/6/10/14/24/40/64 | same | 6/12/18/28/44/66/110 |
buttonPaddingY/X | 8/14 | 8/14 | 8/16 | 8/16 | 6/16 | 6/16 | 10/16 | 10/16 | 5/12 | 5/12 | 8/14 |
transitionDuration | 120ms | 120ms | 150ms | 150ms | 150ms | 150ms | 250ms | 250ms | 90ms | 90ms | 120ms |
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.
$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:
$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:
$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:
| Group | CSS variable | Keys |
|---|---|---|
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 |
fonts | — | import: ["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.
| Key | CSS variable | Default (light) | Role |
|---|---|---|---|
bg | --rui-color-bg | #ffffff | Page background. |
bgSubtle | --rui-color-bg-subtle | #f8fafc | Subtle page wash. |
surface | --rui-color-surface | #ffffff | Cards, sheets, panels. |
surfaceMuted | --rui-color-surface-muted | #f1f5f9 | Inset / secondary surfaces. |
surfaceHover | --rui-color-surface-hover | derived — color-mix(…) of 6% text over surface | Hover tint for rows, menu items and clickable cards. |
border | --rui-color-border | #e2e8f0 | Hairlines, dividers, table row separators. Deliberately faint. |
borderSubtle | --rui-color-border-subtle | rgba(15,23,42,.08) | The faintest divider. |
borderControl | --rui-color-border-control | #767f8c | Interactive control boundary — inputs, selects, textareas, checkboxes, radios, switch tracks, picker triggers. Holds 3:1 against the surface. |
text | --rui-color-text | #0f172a | Primary body text. |
textMuted | --rui-color-text-muted | #475569 | Secondary / caption text. |
primary | --rui-color-primary | #4f46e5 | Brand colour — primary buttons, active states, and body text in 120+ rules. |
primaryHover | --rui-color-primary-hover | #4338ca | Hover / pressed brand colour. |
primaryText | --rui-color-primary-text | #ffffff | Text and icons on a primary fill. |
accent | --rui-color-accent | #4f46e5 | Secondary accent — chips, callouts, accent fills. |
accentHover | --rui-color-accent-hover | #4338ca | Hover accent. |
accentText | --rui-color-accent-text | #ffffff | Text on an accent fill. |
link | --rui-color-link | derived from accent | Interactive text — links, link/ghost buttons, the selected tab label, pagination digits. |
linkHover | --rui-color-link-hover | derived from accentHover | Hover state for the above. |
focusRing | --rui-color-focus-ring | #4f46e5 | Keyboard focus outline and border. Holds 3:1 against the surface. |
success | --rui-color-success | #10b981 | Positive status fill. |
warning | --rui-color-warning | #f59e0b | Caution status fill. |
danger | --rui-color-danger | #ef4444 | Error / destructive status fill. |
info | --rui-color-info | #06b6d4 | Informational status fill. |
successText | --rui-color-success-text | #047857 | The success hue painted as text. |
warningText | --rui-color-warning-text | #a35a00 | The warning hue painted as text. |
dangerText | --rui-color-danger-text | #d92d20 | The danger hue painted as text. |
infoText | --rui-color-info-text | #0e7490 | The info hue painted as text. |
onSuccess | --rui-color-on-success | #04291e | Ink painted on a success fill. |
onWarning | --rui-color-on-warning | #451a03 | Ink painted on a warning fill. |
onDanger | --rui-color-on-danger | #2c0606 | Ink painted on a danger fill. |
onInfo | --rui-color-on-info | #0c2b3a | Ink 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:
| Token | Job | Contrast bar | Example |
|---|---|---|---|
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.
$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.
| Group | Key → default |
|---|---|
radius | xs 4px · sm 6px · md 10px · lg 16px · pill 999px · button 6px · input 6px |
spacing | 3xs 1px · 2xs 2px · xs 4px · sm 8px · md 12px · lg 20px · xl 32px · 2xl 48px · 3xl 80px |
font sizes | sizeBase 14px · sizeSm 12px · sizeLg 16px · sizeHeading 16px · sizeTitle 22px |
font numeric rungs | size10 10px · size11 11px · size13 13px · size15 15px · size18 18px · size20 20px · size24 24px · size32 32px |
font weights | weightBody 400 · weightHeading 700 |
font families | family / familyHeading system‑ui stack · familyMono ui‑monospace stack |
shadows | sm 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:
| Scale | Canonical values | Also accepted |
|---|---|---|
Spacing (gap, padding, sx) |
none · 3xs · 2xs · xs · sm · md · lg · xl · 2xl · 3xl |
s→sm · m→md · l→lg |
Component size |
xs · sm · md · lg · xl |
s/small→sm · m/normal→md · l/large→lg |
$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:
| Family | Flat tokens | Default |
|---|---|---|
| Buttons (5) | buttonFontWeight · buttonTextTransform · buttonLetterSpacing · buttonPaddingY · buttonPaddingX | 600 · none · 0 · 8px · 14px |
| Charts (6) | chart1 … chart6 | #6366f1 #10b981 #f59e0b #ef4444 #06b6d4 #8b5cf6 |
| Syntax highlighting (8) | hlKeyword · hlString · hlNumber · hlComment · hlFn · hlTag · hlAttr · hlPunct | A light palette; dark swaps in One Dark |
| Type metrics (4) | lineHeightBody · lineHeightHeading · letterSpacingHeading · headingTextTransform | 1.5 · 1.2 · 0 · none |
| Misc (2) | borderWidth · transitionDuration | 1px · 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:
$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%).
| Rule | Behaviour |
|---|---|
| Stop count | Fewer 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 syntax | Each stop is ≤64 chars and must match [a-zA-Z0-9#%.,()\s+-] — hex, rgb(), hsl() and named colours all pass. |
| Raw string | Must start with linear-, radial- or conic-gradient( and stay ≤256 chars. |
| Always rejected | url(, 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.
| Part | Accepted | Otherwise |
|---|---|---|
| Family name | Letters, digits and spaces, 1–48 chars | Dropped |
| Weight | An integer 100–900, optionally suffixed i | Dropped |
| Result | One …/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:
$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:
$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 key | CSS variable | Default | Used by |
|---|---|---|---|
base | --rui-z-base | 0 | Normal flow. |
raised | --rui-z-raised | 10 | Hover lifts, focused cards. |
sticky | --rui-z-sticky | 100 | Sticky headers, FAB, speed dial. |
banner | --rui-z-banner | 300 | Reading progress, page‑top bars. |
overlay | --rui-z-overlay | 900 | Scrims — must sit under what they dim. |
dropdown | --rui-z-dropdown | 1000 | Menus, listboxes, autocompletes. |
modal | --rui-z-modal | 1100 | Modal, sheet, drawer, command palette. |
popover | --rui-z-popover | 1350 | Popovers anchored above a modal. |
toast | --rui-z-toast | 1400 | Toast stack. |
tooltip | --rui-z-tooltip | 1500 | Tooltips. |
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
ease — motion.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 want | Use | Why |
|---|---|---|
| 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.
| Surface | Signature | What 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:
| Export | Signature | Use 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
Theme generator
Tune tokens visually against a live UI, then copy the $theme({…}) statement.
Brand themes
One program, six real brand palettes — and the flat token map behind each one.
Compare brands → QualityAccessibility
The contrast and focus guarantees every built-in theme is tested against.
Check contrast → Core conceptsThe sx prop
Reference theme tokens from any component instead of hard-coding a value.
Learn sx → ReferenceComponent catalog
Every component and prop, with live previews that respond to the active theme.
Browse components →