Tailwind v4 OKLCH Zero Dependencies MIT

tailwind-hue-theme

Remap your entire Tailwind color palette to perceptually-uniform OKLCH values, all controlled by a single CSS custom property: --brand-h.

Change one number — a hue angle between 0 and 360 — and your entire UI updates: backgrounds, borders, text, accents, and everything in between. No JavaScript. No build step. No re-running Tailwind.

Why This Exists

Tailwind has excellent color semantics, but its palette is static. You pick your colors at build time and ship them. If a client needs a rebrand — or if you're building a tool where every user gets their own color — you're either regenerating a CSS file, maintaining parallel configs, or reaching for a CSS-in-JS solution that adds runtime overhead.

The deeper problem is that it's not just one color. A brand palette needs a full range — backgrounds, borders, text, interactive states, accents. Getting those to look consistent across hues is where traditional approaches fall apart.

White-label products

Each customer picks their brand color. One deployed stylesheet serves every tenant.

Design systems

Validate components at any hue without maintaining separate themes or Tailwind configs.

Demos & prototypes

An interactive color picker that respects visual consistency — not just a raw hue slider.

The OKLCH Advantage

Traditional hsl() is convenient but perceptually inconsistent. A yellow at hsl(60 100% 50%) looks blazingly bright; a blue at hsl(240 100% 50%) looks comparatively dim — same "lightness", very different perception. OKLCH fixes this.

HSL — same values, inconsistent brightness

OKLCH — same values, consistent perceived brightness

Parameter Meaning Range
L Perceived lightness (calibrated) 0 (black) → 1 (white)
C Chroma — colorfulness 0 (gray) → ~0.4 (vivid)
H Hue angle in degrees 0360

Because L in OKLCH is perceptually calibrated, pinning lightness and chroma and sweeping H across the spectrum produces colors of equal perceived brightness and saturation — that's what makes the single-variable approach work correctly.

How It Works

The plugin calls Tailwind's addBase() API to inject 33 CSS custom properties into :root. Every token is an oklch() expression that references --brand-h. The L and C values are fixed per-scale; only the hue is variable.

:root {
  --brand-h: 250; /* default indigo hue */

  /* slate — low chroma, dark backgrounds stay dark */
  --color-slate-50:  oklch(0.984 0.004 var(--brand-h, 250));
  --color-slate-950: oklch(0.13  0.028 var(--brand-h, 250));

  /* indigo — full chroma, follows brand hue directly */
  --color-indigo-500: oklch(0.585 0.18  var(--brand-h, 250));

  /* cyan — offset hue for a complementary secondary */
  --color-cyan-500:   oklch(0.585 0.18  calc(var(--brand-h, 250) + 40));
}

Slate scale

Chroma 0.004–0.036. Dark backgrounds pick up just enough brand temperature to feel cohesive without looking tinted.

Indigo scale

Chroma 0.03–0.18. Fully expressive — buttons, links, focus rings, highlights. Follows --brand-h directly.

Cyan scale

Mirrors indigo's chroma but offsets the hue by +40°. Always complements the primary without clashing.

One Variable

Set --brand-h anywhere in CSS and your entire palette updates. No JavaScript, no re-builds, no flash of wrong color on load.

:root {
  --brand-h: 155; /* emerald */
}

Zero-JS Server Rendering

Inject the hue from your server before the page loads. No hydration delay, no color flash.

<style>
  :root {
    --brand-h: <%= user.brandHue %>;
  }
</style>

Optional HuePicker Widget

A self-contained floating UI: rainbow trigger button (bottom-right), draggable circular color wheel, preset swatches, and a reset button. Saves to localStorage automatically.

  • Pointer events with setPointerCapture — drag stays accurate outside the circle
  • Keyboard accessible: arrow keys ±5°, Escape closes
  • role="slider", aria-valuenow, aria-modal — fully accessible

Perceptual Consistency

The same chroma value at hue 250 (indigo) and hue 155 (emerald) will look equally colorful to the human eye. OKLCH's perceptually-uniform model means you never have to manually tweak brightness across palette scales when switching themes.

Hue values like -10350 and 40040 are normalised automatically.

API Reference

Two exports from the main entry point.

createHuePlugin(options?)

Returns a Tailwind plugin instance. This is the default export — invoked automatically when you write @plugin "tailwind-hue-theme" in CSS.

Option Type Default Description
defaultHue number 250 Hue written to --brand-h in :root
secondaryOffset number 40 Degrees added to --brand-h for the cyan scale

buildTokens(options?)

Returns the raw CSS token map as a plain object without registering a Tailwind plugin. Use it in tests or build scripts.

import { buildTokens } from 'tailwind-hue-theme'

const tokens = buildTokens({ defaultHue: 155 })
// tokens['--color-indigo-500']
// → 'oklch(0.585 0.18 var(--brand-h, 155))'

Integration Patterns

Four ways to use the plugin — pick the one that fits your stack.

1. Tailwind v4 CSS-first (recommended)

@import "tailwindcss";
@plugin "tailwind-hue-theme";

Inject --brand-h from your server or add the HuePicker widget for interactive switching.

2. Tailwind v3 / v4 JS config

// tailwind.config.js
import { createHuePlugin } from 'tailwind-hue-theme'

export default {
  plugins: [createHuePlugin({ defaultHue: 155 })],
}

3. HuePicker for interactive demos

<script type="module">
  import { HuePicker } from 'tailwind-hue-theme/widget'
  new HuePicker()
</script>

4. Multi-tenant: per-user hue from the server

<style>
  :root { --brand-h: <%= user.brandHue %>; }
</style>
<!-- No JS, no flash, no extra request -->

Every user sees their own palette, served from a single stylesheet.

HuePicker Options

Imported from tailwind-hue-theme/widget. Completely optional — the plugin itself has no DOM dependency.

Option Type Description
defaultHue number Initial hue if no localStorage value exists
presets HuePreset[] Array of { label, hue } swatches — defaults to 8 built-in presets
storageKey string LocalStorage key ("tailwind-hue-theme" by default)
onChange (hue: number) => void Callback fired whenever the hue changes — sync to your API here
import { HuePicker, DEFAULT_PRESETS } from 'tailwind-hue-theme/widget'

const picker = new HuePicker({
  defaultHue: 250,
  presets: [
    ...DEFAULT_PRESETS,
    { label: 'Brand Blue', hue: 220 },
  ],
  onChange: (hue) => {
    fetch('/api/preferences', {
      method: 'POST',
      body: JSON.stringify({ brandHue: hue }),
    })
  },
})

TypeScript

The package is fully typed. All interfaces are exported from their respective entry points.

import {
  createHuePlugin,
  buildTokens,
  type HuePluginOptions,
} from 'tailwind-hue-theme'

import {
  HuePicker,
  DEFAULT_PRESETS,
  getWidgetCSS,
  type HuePickerOptions,
  type HuePreset,
} from 'tailwind-hue-theme/widget'

getWidgetCSS() returns the raw CSS string injected by the widget — useful for SSR environments that pre-inject styles rather than letting the widget self-inject its <style> tag.

Built For

  • Multi-tenant SaaS where each customer sets their own brand color
  • Design system teams validating components across multiple hues
  • Demos and prototypes with a live color picker that respects perceptual consistency
  • Tailwind v4 projects that want semantic tokens responding to CSS variables at runtime

If your product has a fixed brand color and no need for runtime palette switching, use Tailwind's built-in tokens instead — you don't need this.

Distribution

Built with tsup. The widget is a separate entry point so the DOM-dependent code is never bundled for server-side environments.

File Format
dist/index.js CJS — Node / Webpack
dist/index.mjs ESM — Vite / Rollup
dist/widget.mjs ESM widget (DOM)
dist/index.d.ts TypeScript declarations

tailwindcss is a peer dependency and is externalized from the bundle.

Get Started

Install the package, add one line to your CSS, and every Tailwind color in your project responds to --brand-h.

npm install tailwind-hue-theme