Theme
Optional React provider that writes the Spark theming attributes onto a wrapper.
Usage
import { Theme, useTheme } from '@epilot/spark-ui/theme'<Theme accentColor="violet" radius="large">
<App />
</Theme>API Reference
| Prop | Maps to | Values |
|---|---|---|
accentColor | runtime --spark-* | a curated name (blue, violet, …) or a hex |
backgroundColor | runtime --spark-* + --neutral-* | any hex — the page canvas |
neutralColor | data-neutral-color | auto slate mauve gray sage olive sand, or a custom hex |
radius | data-radius | none small medium large full |
spacing | data-spacing | dense default roomy |
scaling | data-scaling | 90% 95% 100% 105% 110% |
styleVariant | data-style | solid soft surface ghost |
highContrast | data-high-contrast | boolean; orthogonal, strengthens any style toward higher-contrast steps |
inverted | data-inverted | boolean; orthogonal, re-casts the accent for controls on a solid accent surface |
buttonStyles | React context + --spark-button-* | per Button style: another style (Remap) or { fill?, border? } levels (Restyle) |
buttonColors | React context + --spark-button-* | per Button colour: another colour (Remap) or { light, dark? } (Restyle) |
appearance | .light / .dark class | light dark |
fontHeading | runtime --spark-* | any CSS font-family value, applied to h1–h6 |
fontBody | runtime --spark-* | any CSS font-family value, applied to everything else |
render | — | replace the rendered element / compose with your own component |
accentColor is the one runtime knob: the accent scale is generated into --spark-*
variables at render time (no static CSS), so curated names and custom hex both work.
neutralColor picks which gray scale feeds the semantic gray / ink tokens.
Product default is slate (or later auto). The six named families (mauve,
sage, olive, sand, gray, plus slate) and custom hex are advanced /
parity options for the knob — not the everyday paint vocabulary. Components
still paint with gray / ink roles, not family step utilities.
A custom hex is also accepted alongside those families and auto. The DOM
attribute stays one of the 6 family names either way — a raw color isn't one of the small
set of values CSS can select on, so it falls back to its accent-matched family there — but
generation itself uses the real color you passed. That generation only happens alongside a
backgroundColor, though: without one, nothing regenerates the neutral scale at all, so a
custom neutralColor with no backgroundColor set is a no-op — the static family the
attribute falls back to is what actually paints.
spacing and scaling both change size but are independent. scaling is a global zoom
that resizes everything — padding, control sizes, type, and gaps. spacing (density) is
narrower: it adjusts only the space between components in layout and collection primitives
(PageContainer, FieldSet, List, RadioGroup, …) and never resizes a component's own
internals. The one exception is Table, whose row height steps with spacing — 2 / 2.5 /
3rem (times scaling) for dense / default / roomy, with a 44px floor on coarse
pointers for interactive rows — because a row is the between-space of a collection, not
a control's internals.
Spacing says which gap to use for which purpose.
styleVariant sets the emphasis of the primary / accent action (solid → ghost,
via --spark-style-*). Buttons with an explicit styleVariant/color don't change with
the ambient style.
buttonStyles and buttonColors redraw every <Button> in scope by its own
styleVariant and color; see Button styles and colours.
Where knobs take effect.
- At your app root (
<html>, or a<Theme>around your app): every knob applies. - Lower in the tree:
accent/neutral/stylecascade to components too (their color tokens are@theme inline, sobg-spark-*/bg-gray-*re-resolve per scope), as doesradius="full".spacingcascades too, to whatever reads--spark-layout-gap— gaps in the layout / collection primitives, andTablerow height — via a nestedTheme. The factor radii (none…large) andscalingstay root-only.
The provider applies accent as inline styles, so it's sRGB-only. Wide-gamut P3 needs the scale emitted into a <style> (via the library's accentSparkVarsP3), which inline styles can't carry.
What to paint with
Semantic roles are the author API. Prefer Spark components and role tokens
(primary / soft / hover pairs, destructive vs error, success /
warning, gray / ink) over picking Radix step numbers. The frozen role set
lives with the theming docs in-repo (docs/issues/theming/project-context.md
· docs/issues/theming/internal/naming-vocabulary-freeze.md). Raw
bg-spark-N / gray-aN utilities are an escape hatch (charts, one-offs)
— not the happy path for app chrome.
Accents (runtime-generated)
Each accentColor (a curated name or a custom hex) generates the full Radix-style
ladder into --spark-1…12 / --spark-a1…a12 (portal wire / engine). Authors
normally do not pick those steps by hand — components and semantic roles do.
These ramps read the raw vars, so they update live per accent:
Engine reference (ladder / escape hatch)
The generator follows Radix's 12-step scale semantics. Use this table when debugging the engine, writing chart series, or reading parity — not as the everyday author checklist:
| Steps | Engine use | Mapped examples (via roles / style tokens) |
|---|---|---|
| 1–2 | App & subtle backgrounds | --spark-background, base-*-surface |
| 3–5 | Component backgrounds (rest / hover / active) | soft / soft-hover fills, selection |
| 6–8 | Borders: 7 = UI borders, 8 = hover/strong | input borders; input focus borders |
| 9–10 | Solid backgrounds | solid / solid-hover (dedicated solid tokens when needed) |
| 11–12 | Text: low contrast (11), high contrast (12) | muted / high-contrast ink; focus outline → step 11 |
The focus outline (every focusable Spark part, inputs included) is 2px in
--color-focus-root → --spark-11, with a 2px see-through gap — not Radix’s
step 8 and not step 10. Steps 9/10 are solved as solid fills, so their canvas
contrast is incidental; step 11 is the accent text step and already clears
WCAG 1.4.11 (3:1) across accents in both appearances. Boxed inputs also step
their border from 7 to 8 on focus; ghost and inset inputs also step their
underline to 8.
Background
backgroundColor sets the page canvas. It is not just a paint value — every alpha step in
the scale is solved against the canvas (target = bg·(1-α) + fg·α), so a custom canvas
regenerates both the accent scale and the neutral scale against it. That second half is
what makes a saturated colour work: --neutral-a* paints nearly all body text and borders,
and the static Radix neutrals are pre-baked against white, so on yellow they would tint and
lose their contrast guarantee.
#FFD400
Neutral ink, regenerated against the canvas.
#F0EAD6
Neutral ink, regenerated against the canvas.
#0B1020
Neutral ink, regenerated against the canvas.
Two behaviours worth knowing:
- A canvas has a polarity. A yellow page is a light page. So the custom canvas applies
to the appearance it belongs to and the other keeps its default — set
backgroundColoron a<Theme appearance="dark">scope to give dark mode its own canvas. Handing a scope a canvas of the wrong polarity logs a warning rather than painting unreadable ink. - Ink steps go opaque where they must. From a saturated canvas, a desaturated ink is unreachable with a translucent overlay, so those steps fall back to their opaque value rather than silently rendering unreadable text. Low steps (subtle fills, borders) stay translucent, because they still solve exactly.
- The accent scale tints toward the canvas too. Steps 1–5 — the
soft-card/checkbox fill territory — blend toward the canvas, fading out by step 6; the solid brand colour (step 9) and everything above it never moves. This keeps the accent and neutral scales agreeing on the same tinted page instead of one following the canvas and the other staying a flat wash of itself. Accent and canvas far apart in hue desaturate toward each other rather than sweeping through an unrelated vivid hue along the way. Those same 5 steps' translucent alpha versions fall back to opaque too if reproducing them there needs more than roughly half-opaque — at that point it's no longer a usable tint, just a solve that happens to hold over one specific canvas. neutralColor="auto"prefers the canvas's own hue. WhenbackgroundColoris set and perceptibly tinted (OKLCH chroma ≥ 0.04),autoseeds the neutral scale directly from that colour instead of bucketing it into one of the 6 named families — a warm canvas visibly warms the neutrals this way, where the nearest bucketed family barely would. With nobackgroundColor, or one too close to grey to register,autofalls back to pairing with the accent, same as before.
No neutralColor set
Neutral ink stays cool — the blue accent's own family.
neutralColor="auto"
Neutral ink warms to match the canvas instead.
Production portals generate their own tokens (ECP's generator, pushed over postMessage), so
a custom canvas reaches live tenants only once that generator supports it — the same staging
that already applies to a custom accent hex.
Inverted
A solid control on a solid accent surface paints the same --spark-9 as the surface, so it
disappears. inverted re-casts the accent scale for that surface: step 9 swaps with its contrast
ink, so the solid fill and its label are a proven pair.
The quieter styles need a second idea, because that ink is also the text colour — a fill blended toward it drags the background into the foreground. So fills step the other way, toward the lightness pole opposite the ink, while borders keep stepping toward it.
default
inverted
It's orthogonal to styleVariant and composes with highContrast, because it re-points the
accent scale itself rather than adding style recipes — every data-style variant re-colours from
it for free.
Because --spark-contrast is already the WCAG-checked ink for step 9, and every other fill moves
away from that ink rather than toward it, each style's label is contrast-safe by construction for
any accent — measured worst case 4.81:1 for text and 3.01:1 for borders across the curated set.
It needs no separate dark-mode values either: the generator pins step 9 to the accent itself, so
the surface — and therefore the inverted scale — is identical in both appearances.
Reach for it when you've deliberately put controls on an accent fill. On a neutral solid card the normal scale already reads fine, so leave it off.
An accent Card/Box nested inside inverted re-colours too — its label re-points to the
accent ink for the same reason the styles above do, so nesting one accent card inside another
still reads correctly.
Typography
fontHeading and fontBody write --spark-font-heading / --spark-font-body as
inline styles, the same mechanism accentColor uses — free-form font stacks, not a
fixed set of presets. fontHeading applies to h1–h6; fontBody to everything
else. Neither is set by default, so an app that never touches them keeps whatever
font it already had — spark-ui itself has no opinion on typography beyond these two
hooks.
This heading uses fontHeading
This paragraph uses fontBody.
Nesting & the cascade
Only the props you pass are written, so a nested Theme overrides just those
knobs and inherits the rest. The override flows through normal CSS inheritance (here
the inner scope re-tints just the accent):
Overlays (Dialog, Popover, Select, Tooltip, Toast and friends) portal to
document.body, outside that inheritance, so they re-emit what the enclosing Themes
rendered (data attributes, accent and neutral variables, appearance class) on one
display: contents element inside their portal. inverted is not carried, and
useTheme() inside an overlay reports inverted: false. Only Theme props carry over,
not its className or style. Wrap your own createPortal content in PortalTheme to
get the same behaviour.
useTheme()
Returns the theme knobs from enclosing <Theme> providers (this one merged over
its ancestors). It reflects only props passed to a <Theme>. It does not read
data-* attributes or the appearance class set outside a provider. Returns {} when
there is no <Theme> ancestor.
const { accentColor, radius } = useTheme()