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

PropMaps toValues
accentColorruntime --spark-*a curated name (blue, violet, …) or a hex
backgroundColorruntime --spark-* + --neutral-*any hex — the page canvas
neutralColordata-neutral-colorauto slate mauve gray sage olive sand
radiusdata-radiusnone small medium large full
spacingdata-spacingdense default roomy
scalingdata-scaling90% 95% 100% 105% 110%
styleVariantdata-stylesolid soft surface ghost
highContrastdata-high-contrastboolean; orthogonal, strengthens any style toward higher-contrast steps
appearance.light / .dark classlight dark
fontHeadingruntime --spark-*any CSS font-family value, applied to h1h6
fontBodyruntime --spark-*any CSS font-family value, applied to everything else
renderreplace 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 (solidghost, 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 / style cascade to components too (their color tokens are @theme inline, so bg-spark-* / bg-gray-* re-resolve per scope), as does radius="full". The factor radii (nonelarge) and scaling / spacing stay 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:

blue
123456789101112
a1a2a3a4a5a6a7a8a9a10a11a12

Understanding the scale

Spark follows Radix's 12-step scale semantics, so every color decision picks the step designed for that use case:

StepsUse caseSpark examples
1–2App & subtle backgrounds--spark-background, base-*-surface
3–5Component backgrounds (rest / hover / active)input-base (a3), input-base-hover (a4), selection (a5)
6–8Borders: 7 = UI borders & focus rings, 8 = hover/stronginput (a7), input-active + input focus rings (a8)
9–10Solid backgroundsbase-*-solid (9), global focus-root ring (10)
11–12Text: 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 backgroundColor on 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 h1h6; 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):

blue (theme)
red (override)

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

On this page