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 |
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 |
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.
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.
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.
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()