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 |
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 also accepts a custom hex alongside the 6 named 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.
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.
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". The factor radii (none…large) andscaling/spacingstay root-only for now.
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.
Accents (runtime-generated)
Each accentColor (a curated name or a custom hex) generates the full Radix-style
scale: the solid steps (--spark-1…12) and the alpha steps (--spark-a1…a12).
These ramps read the raw vars, so they update live per accent:
Understanding the scale
Spark follows Radix's 12-step scale semantics, so every color decision picks the step designed for that use case:
| Steps | Use case | Spark examples |
|---|---|---|
| 1–2 | App & subtle backgrounds | --spark-background, base-*-surface |
| 3–5 | Component backgrounds (rest / hover / active) | input-base (a3), input-base-hover (a4), selection (a5) |
| 6–8 | Borders: 7 = UI borders & focus rings, 8 = hover/strong | input (a7), input-active + input focus rings (a8) |
| 9–10 | Solid backgrounds | base-*-solid (9), global focus-root ring (10) |
| 11–12 | Text: low contrast (11), high contrast (12) | gray-light / accent-light (a11), gray / text (a12) |
The global focus ring (buttons, checkboxes, …) deliberately uses step 10 instead of Radix's step 8 (step 8 is ~2.3:1 on light surfaces, below WCAG 1.4.11's 3:1). Inputs follow the border steps instead: their border or underline steps from 7 to 8 on focus, with a soft step-4 halo.
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):
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()