Colors

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.

The scale

$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.

Swatches

Light Mode

text-lc-1
surface-lc-1
text-lc-2
surface-lc-2
text-lc-3
surface-lc-3
text-lc-4
surface-lc-4
text-lc-5
surface-lc-5
text-lc-6
surface-lc-6
text-lc-7
surface-lc-7
text-lc-8
surface-lc-8
text-lc-9
surface-lc-9

Dark Mode

text-lc-1
surface-lc-1
text-lc-2
surface-lc-2
text-lc-3
surface-lc-3
text-lc-4
surface-lc-4
text-lc-5
surface-lc-5
text-lc-6
surface-lc-6
text-lc-7
surface-lc-7
text-lc-8
surface-lc-8
text-lc-9
surface-lc-9

Contrast

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:

PairingUsed byLightDark
text-lc-1 / surface-lc-1body copy18.518.5
text-lc-1 / surface-lc-2article, dialog14.315.5
text-lc-3 / surface-lc-1.secondary, .ghost, small11.010.0
text-lc-3 / surface-lc-2code, pre8.68.7
text-lc-3 / surface-lc-3small.chip6.46.4
text-lc-4 / surface-lc-1links6.56.5
text-lc-4 / surface-lc-2links inside articles5.15.7
primary-text-lc / primary-surface-lcprimary buttons6.56.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.

Scoping a color

Rebind --color on any element and its whole subtree recolors. This is how --accent-color and --error-color are applied internally.

Teal

Scoped with --color: teal.

Crimson

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 and error

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

text-lc-1
surface-lc-1
text-lc-2
surface-lc-2
text-lc-3
surface-lc-3
text-lc-4
surface-lc-4
text-lc-5
surface-lc-5
text-lc-6
surface-lc-6
text-lc-7
surface-lc-7
text-lc-8
surface-lc-8
text-lc-9
surface-lc-9

Dark Mode

text-lc-1
surface-lc-1
text-lc-2
surface-lc-2
text-lc-3
surface-lc-3
text-lc-4
surface-lc-4
text-lc-5
surface-lc-5
text-lc-6
surface-lc-6
text-lc-7
surface-lc-7
text-lc-8
surface-lc-8
text-lc-9
surface-lc-9

Light Mode

text-lc-1
surface-lc-1
text-lc-2
surface-lc-2
text-lc-3
surface-lc-3
text-lc-4
surface-lc-4
text-lc-5
surface-lc-5
text-lc-6
surface-lc-6
text-lc-7
surface-lc-7
text-lc-8
surface-lc-8
text-lc-9
surface-lc-9

Dark Mode

text-lc-1
surface-lc-1
text-lc-2
surface-lc-2
text-lc-3
surface-lc-3
text-lc-4
surface-lc-4
text-lc-5
surface-lc-5
text-lc-6
surface-lc-6
text-lc-7
surface-lc-7
text-lc-8
surface-lc-8
text-lc-9
surface-lc-9
:root {
  --color: rebeccapurple;
  --accent-color: oklch(from var(--color) l c calc(h + 180));
  --error-color: maroon;
  --fallback-color: white;
}

Greyscale and disabled

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>

Sass API

@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.