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.
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.
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 | 0–360 |
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.
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.
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 */
}
Inject the hue from your server before the page loads. No hydration delay, no color flash.
<style>
:root {
--brand-h: <%= user.brandHue %>;
}
</style>
A self-contained floating UI: rainbow trigger button (bottom-right), draggable circular color
wheel, preset swatches, and a reset button. Saves to localStorage automatically.
setPointerCapture — drag stays accurate outside the circlerole="slider",
aria-valuenow, aria-modal — fully accessibleThe 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 -10 → 350 and 400 → 40 are
normalised automatically.
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))'
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.
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 }),
})
},
})
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.
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.
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.
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