Skip to content

Color System

remCSS uses oklch() for all colors and light-dark() for automatic theme switching. No hex values. No rgb(). No @media (prefers-color-scheme) inside components.

Why oklch()?

oklch (OK Lightness Chroma Hue) is a perceptually uniform color space. Equal numeric steps in oklch look like equal visual steps to human eyes — unlike hsl where equal numeric steps can produce wildly different perceptual jumps.

css
/* oklch(lightness% chroma hue) */
--color-accent: oklch(62% 0.22 27); /* remCSS orange */
ParameterRangeEffect
Lightness0–100%Black → White
Chroma0–0.4Gray → Saturated
Hue0–360Color wheel degrees

The light-dark() Function

Instead of separate @media (prefers-color-scheme: dark) blocks, remCSS uses light-dark():

css
/* One declaration handles both modes */
color: light-dark(var(--color-on-surface-light), var(--color-on-surface-dark));
background: light-dark(var(--color-surface-light), var(--color-surface-dark));

light-dark() is activated by color-scheme: light dark on :root, set in the reset. The browser reads the user's OS preference and picks the correct value automatically.

Token Structure

Palette (raw oklch values)

css
:root {
	/* Neutral */
	--palette-neutral-0: oklch(98% 0 0); /* near white */
	--palette-neutral-100: oklch(10% 0 0); /* near black */

	/* Accent — remCSS orange (#fa572a ≈ oklch(62% 0.22 27)) */
	--palette-accent-50: oklch(72% 0.22 27); /* lighter */
	--palette-accent: oklch(62% 0.22 27); /* base */
	--palette-accent-70: oklch(52% 0.22 27); /* darker */

	/* Semantic states */
	--palette-success: oklch(60% 0.15 145);
	--palette-warning: oklch(75% 0.17 85);
	--palette-danger: oklch(55% 0.22 22);
	--palette-info: oklch(60% 0.18 250);
}

Semantic Tokens (light-dark pairs)

css
:root {
	--color-surface: light-dark(var(--palette-neutral-0), var(--palette-neutral-100));
	--color-on-surface: light-dark(var(--palette-neutral-100), var(--palette-neutral-0));
	--color-accent: light-dark(var(--palette-accent), var(--palette-accent-50));
	--color-border: light-dark(oklch(85% 0 0), oklch(25% 0 0));
}

Named Color Palette

remCSS ships 12 named color families, each with 6 steps. All values are oklch().

10
30
50
70
90
95
red 22°
amber 55°
yellow 90°
lime 125°
green 145°
teal 175°
sky 210°
blue 230°
indigo 265°
violet 285°
purple 300°
pink 340°

Step semantics:

StepLightnessRole
10~12–18%Dark background tint / dark-mode text
30~35–40%Dark-mode foreground, WCAG AA on white
50~60–78%Vivid base — the "brand" step
70~75–82%Light-mode foreground, subtle on dark bg
90~90–93%Soft tinted surface
95~94–96%Near-white tint, barely-there background

Semantic tokens per color

For every named family, three semantic tokens are available:

css
--color-{name}          /* theme-aware: dark text in light mode, light text in dark mode */
--color-{name}-subtle   /* theme-aware tinted surface */
--color-on-{name}       /* foreground on a step-50 (vivid) background */

Color utility classes

html
<span class="text-blue">Blue text</span>
<div class="bg-green-subtle">Tinted surface</div>
<button class="button bg-red text-on-red">Delete</button>

Available for all 12 families + accent, success, warning, danger, info, muted:

Class prefixProperty
.text-{name}color
.bg-{name}background-color
.border-{name}border-color

Dynamic Palette Slots

remCSS includes 3 ready-made slots for custom colors. Set a single CSS variable and the framework derives a full 6-step scale automatically at runtime — no build step, no script.

css
/* In your own CSS, after importing remcss.css */
:root {
	--palette-custom-1-base: oklch(62% 0.22 200); /* one line → 6 steps */
}

This automatically gives you:

css
--palette-custom-1-10  /* darkest — ~15% L */
--palette-custom-1-30  /* dark    — ~38% L */
--palette-custom-1-50  /* = your base color */
--palette-custom-1-70  /* light   — ~76% L */
--palette-custom-1-90  /* lighter — ~92% L */
--palette-custom-1-95  /* lightest — ~95% L */

--color-custom-1        /* light-dark() semantic token */
--color-custom-1-subtle /* light-dark() subtle surface */
--color-on-custom-1     /* foreground on vivid background */

And the utility classes .text-custom-1, .bg-custom-1, .border-custom-1.

Three slots are available: custom-1, custom-2, custom-3. Defaults:

  • Slot 1: oklch(60% 0.2 240) — blue
  • Slot 2: oklch(60% 0.2 340) — rose
  • Slot 3: oklch(60% 0.2 145) — green

How it works

The derivation uses color-mix() in oklch space against near-black and near-white anchors with a none hue — which causes the base hue to propagate through all steps:

css
--palette-custom-1-10: color-mix(in oklch, var(--palette-custom-1-base) 8%, oklch(12% 0 none));
--palette-custom-1-30: color-mix(in oklch, var(--palette-custom-1-base) 55%, oklch(12% 0 none));
--palette-custom-1-50: var(--palette-custom-1-base);
--palette-custom-1-70: color-mix(in oklch, var(--palette-custom-1-base) 55%, oklch(97% 0 none));
--palette-custom-1-90: color-mix(in oklch, var(--palette-custom-1-base) 15%, oklch(97% 0 none));
--palette-custom-1-95: color-mix(in oklch, var(--palette-custom-1-base) 5%, oklch(97% 0 none));

The none hue on oklch(12% 0 none) means the channel is "missing" — color-mix() uses the other color's hue. Result: all steps share the exact hue of your base color.

Bright bases

If your base color is very light (> 72% L), override the on-color token with dark text:

css
:root {
	--color-on-custom-1: oklch(10% 0 0);
}

Palette Generator Script

For permanent named palettes — or if you need more than 3 custom slots — use scripts/gen-palette.js to generate ready-to-paste CSS from one oklch value:

bash
# From an oklch string
node scripts/gen-palette.js coral "oklch(65% 0.19 22)"

# From individual L C H values
node scripts/gen-palette.js brand 62 0.22 200

Output (two copy-paste blocks):

css
/* ── Block 1: paste into src/tokens/colors.css ──────────────────── */
/* coral — generated 2026-05-12 */
--palette-coral-10: oklch(16.1% 0.036 22);
--palette-coral-30: oklch(37.6% 0.105 22);
--palette-coral-50: oklch(65% 0.19 22);
--palette-coral-70: oklch(77.3% 0.105 22);
--palette-coral-90: oklch(91.8% 0.029 22);
--palette-coral-95: oklch(95.6% 0.01 22);

--color-coral: light-dark(var(--palette-coral-30), var(--palette-coral-70));
--color-coral-subtle: light-dark(var(--palette-coral-95), var(--palette-coral-10));
--color-on-coral: oklch(98% 0 0);

/* ── Block 2: paste into src/utilities/colors.css ───────────────── */
.text-coral {
	color: var(--color-coral);
}
.bg-coral {
	background-color: var(--color-coral-subtle);
}
.border-coral {
	border-color: var(--color-coral);
}

The script uses the same mix percentages as the CSS slots, so static and dynamic palettes look identical at equivalent base colors.


Defining Custom Colors (Manual)

For full control without the generator script, follow the two-layer pattern directly:

css
:root {
	/* 1. Define your palette */
	--palette-brand-30: oklch(38% 0.19 195);
	--palette-brand-50: oklch(58% 0.19 195);
	--palette-brand-70: oklch(76% 0.1 195);
	--palette-brand-95: oklch(95% 0.03 195);

	/* 2. Create semantic tokens */
	--color-brand: light-dark(var(--palette-brand-30), var(--palette-brand-70));
	--color-brand-subtle: light-dark(var(--palette-brand-95), var(--palette-brand-30));
	--color-on-brand: oklch(98% 0 0);
}

Forcing a Color Scheme

You can force light or dark mode on any element:

html
<!-- Force dark regardless of OS preference -->
<div style="color-scheme: dark">
	<!-- light-dark() here returns the dark value -->
</div>

Or globally in CSS:

css
:root {
	color-scheme: dark; /* or: light */
}