Light Mode
surface-lc-1
surface-lc-2
surface-lc-3
surface-lc-4
surface-lc-5
surface-lc-6
surface-lc-7
surface-lc-8
surface-lc-9
Every color in the framework is derived from one variable, --color, using
oklch() relative color syntax. A nine-step scale of lightness and chroma pairs is
applied to that hue. Light and dark mode use the same scale, mirrored.
$lcArray holds nine (lightness, chroma) pairs. Each becomes a
--swatch-lc-N. Surfaces read the scale forward, text reads it backward:
--swatch-lc-1: 99% 0.018; /* lightest */
--swatch-lc-9: 17% 0.055; /* darkest */
--surface-lc-N: var(--swatch-lc-N);
--text-lc-N: var(--swatch-lc-[10 - N]);
In dark mode the swatch indices are reversed before the same mapping is applied, so
surface-lc-1 is always the page background and text-lc-1 is always the
highest-contrast text, in both schemes.
The consequence worth internalizing: a pairing that reads well as
text-lc-N on surface-lc-M in light mode does not automatically read
well in dark mode, because the two schemes resolve it to different swatch pairs. Light
resolves to stops (M, 10 - N); dark resolves to stops (10 - M, N). Use
the table below rather than guessing.
Light Mode
Dark Mode
The scale is tuned so every pairing the framework itself uses clears WCAG AA (4.5:1) for normal text, at every hue, in both schemes. Worst-case ratios:
| Pairing | Used by | Light | Dark |
|---|---|---|---|
text-lc-1 / surface-lc-1 | body copy | 18.5 | 18.5 |
text-lc-1 / surface-lc-2 | article, dialog | 14.3 | 15.5 |
text-lc-3 / surface-lc-1 | .secondary, .ghost, small | 11.0 | 10.0 |
text-lc-3 / surface-lc-2 | code, pre | 8.6 | 8.7 |
text-lc-3 / surface-lc-3 | small.chip | 6.4 | 6.4 |
text-lc-4 / surface-lc-1 | links | 6.5 | 6.5 |
text-lc-4 / surface-lc-2 | links inside articles | 5.1 | 5.7 |
primary-text-lc / primary-surface-lc | primary buttons | 6.5 | 6.5 |
Two pairings to avoid. text-lc-5 on surface-lc-5 resolves to the same
color in both schemes. text-lc-4 on surface-lc-3 lands near 3.6:1,
below AA for normal text.
Rebind --color on any element and its whole subtree recolors. This is how
--accent-color and --error-color are applied internally.
Scoped with --color: teal.
Scoped with --color: crimson.
<div class="flex flip">
<article class="card" style="--color: teal; flex: 1">
<h3>Teal</h3>
<p>Scoped with <code>--color: teal</code>.</p>
<button>Action</button>
</article>
<article class="card" style="--color: crimson; flex: 1">
<h3>Crimson</h3>
<p>Scoped with <code>--color: crimson</code>.</p>
<button>Action</button>
</article>
</div>
--accent-color defaults to the complement of the base hue and is what links use.
--error-color drives small.error and invalid inputs.
Light Mode
Dark Mode
Light Mode
Dark Mode
:root {
--color: rebeccapurple;
--accent-color: oklch(from var(--color) l c calc(h + 180));
--error-color: maroon;
--fallback-color: white;
}
A parallel chroma-free scale backs --grey-surface-lc-* and
--grey-text-lc-*. The .disabled class and the :disabled
pseudo-class swap the live scale for it via the apply-greyscale() mixin.
An entire subtree can be desaturated with .disabled.
<div class="flex">
<button>Enabled</button>
<button disabled>Disabled</button>
<article class="card disabled" style="flex: 1">
<p>An entire subtree can be desaturated with <code>.disabled</code>.</p>
</article>
</div>
@use "@sparkstone/css/src/vars.scss" as *;
.thing {
/* the ambient --color, at a given stop */
background: cur-color(var(--surface-lc-2));
color: cur-color(var(--text-lc-1));
border: var(--border-width) solid get-border-color();
}
.fixed-hue {
/* an explicit color instead of --color */
background: get-color(var(--surface-lc-2), tomato);
/* the same stop, chroma forced to zero */
border-color: get-grey(var(--surface-lc-4), tomato);
}
.inert {
@include apply-greyscale();
}
get-border-color() is shorthand for cur-color(var(--surface-lc-4)).
cur-color() reads whatever --color is in scope where the rule
applies, so scoping still works.