@crob/color-pair
v0.1.0
Published
Adaptive foreground/background color pairs (light + dark) from Tailwind colors or any hex, via OKLCH.
Maintainers
Readme
@crob/color-pair
Adaptive foreground/background color pairs (light + dark) from Tailwind colors or any hex, via OKLCH.
Why
Badges, tags, status pills, and icon chips all need the same thing: a foreground color that reads
cleanly over a tinted background, in both light and dark mode. Doing this by hand for every color
is tedious, and doing it consistently across an app is harder. color-pair produces that pairing
for you from either a named Tailwind color or any hex value, and returns a single, uniform shape so
your UI never has to branch on where the color came from. It is pure math with zero runtime
dependencies, so it runs the same in Node and the browser.
Install
npm i @crob/color-pairpnpm add @crob/color-pairyarn add @crob/color-pairQuick start
There are two ways to ask for a pair. Both return the identical shape.
import { buildTailwindColorPair, buildColorPair } from '@crob/color-pair'
// 1. Exact Tailwind v4 utility classes for a named palette color.
buildTailwindColorPair('red')
// {
// light: {
// foreground: { class: 'text-red-700', hex: '#c10007' },
// background: { class: 'bg-red-50', hex: '#fef2f2' },
// },
// dark: {
// foreground: { class: 'text-red-400', hex: '#ff6467' },
// background: { class: 'bg-red-400/10', hex: '#ff64671a' },
// },
// }
// 2. A Tailwind-shaped pair derived from any hex (arbitrary-value classes).
buildColorPair('#7c5cff')
// {
// light: {
// foreground: { class: 'text-[#644acf]', hex: '#644acf' },
// background: { class: 'bg-[#f6f6ff]', hex: '#f6f6ff' },
// },
// dark: {
// foreground: { class: 'text-[#a696ff]', hex: '#a696ff' },
// background: { class: 'bg-[#a696ff]/10', hex: '#a696ff1a' },
// },
// }Each token carries both a ready-to-use class string and the resolved hex, so you can apply
whichever fits your styling approach.
Prefer an instance when you want to reuse configuration:
import { ColorPairService } from '@crob/color-pair'
const colors = new ColorPairService({ darkBgAlpha: 0.12 })
colors.buildFromTailwind('blue')
colors.buildFromHex('#7c5cff')Using the result
Each ColorToken gives you two ways to apply the color. The hex is always safe; the class
needs a Tailwind build that can see it (see the safelist caveat).
Framework-neutral, via hex (always safe):
const pair = buildColorPair('#7c5cff')
element.style.color = pair.light.foreground.hex
element.style.backgroundColor = pair.light.background.hexVue, via class:
<script setup lang="ts">
import { buildTailwindColorPair } from '@crob/color-pair'
const pair = buildTailwindColorPair('emerald')
</script>
<template>
<span :class="[pair.light.foreground.class, pair.light.background.class]">Active</span>
</template>React, via hex (safest for dynamic/stored colors):
import { buildColorPair } from '@crob/color-pair'
function Badge({ color, children }: { color: string; children: React.ReactNode }) {
const { light } = buildColorPair(color)
return (
<span style={{ color: light.foreground.hex, backgroundColor: light.background.hex }}>
{children}
</span>
)
}API reference
buildColorPair(hex, overrides?) => AdaptiveColorPair
Derive a pair from any hex using the default config. Emits arbitrary-value classes
(text-[#…]). Throws InvalidHexColorError on malformed input.
buildTailwindColorPair(name, overrides?) => AdaptiveColorPair
Build a pair from a named Tailwind color using the default config. Emits exact utility classes
(text-red-700). Throws UnknownTailwindColorError for an unknown name.
class ColorPairService
new ColorPairService(overrides?: ColorPairOverrides)buildFromTailwind(name, overrides?) => AdaptiveColorPair— exact Tailwind v4 classes for a family (orblack/white). ThrowsUnknownTailwindColorErrorfor unknown names.buildFromHex(hex, overrides?) => AdaptiveColorPair— derives an arbitrary-value pair from a hex. ThrowsInvalidHexColorErrorfor malformed hex.deriveShades(hex, overrides?) => { background, mid, foreground }— the three raw derived hex shades, with no class assembly. Useful when you only need the colors.
Pure helpers
normalizeHex(input) => string— expands#rgbshorthand, lowercases, returns#rrggbb. ThrowsInvalidHexColorErrorotherwise.hexToOklch(hex) => Oklch— convert a hex color to{ L, C, H }.oklchToHex(L, C, H) => string— convert OKLCH back to a 6-digit hex, clamping out-of-gamut channels into sRGB.
Constants
TAILWIND_PALETTE— 28PaletteFamilyentries: 26 chromatic/neutral families (11 hexes each) followed byblackandwhite(each a singleswatch,hexes: []).TAILWIND_STEPS—['50', '100', …, '950'].TAILWIND_COLOR_NAMES— every selectable name (the source of theTailwindColorNametype).DEFAULT_COLOR_PAIR_CONFIG— the default anchors,referenceChroma, anddarkBgAlpha.
Errors
InvalidHexColorError(name === 'InvalidHexColorError') — thrown for a malformed hex.UnknownTailwindColorError(name === 'UnknownTailwindColorError') — thrown for an unknown Tailwind name.
How derivation works
The hex path treats your input as hue only. Lightness and chroma come from three fixed anchors, and the input's chroma is used solely to scale the anchors' chroma so muted picks stay muted and grays stay gray.
- Convert the input hex to OKLCH and read its hue
Hand chromaC. - Compute
vividness = min(1, C / referenceChroma). - For each anchor, derive a hex at
oklch(anchor.lightness, anchor.chroma * vividness, H).
The anchors and their roles:
| Anchor | ~Tailwind step | Used for |
| ------------ | -------------- | --------------------------------- |
| background | 50 | light-mode background |
| mid | 400 | dark-mode foreground and dark-mode background (with alpha) |
| foreground | 700 | light-mode foreground |
The named path (buildFromTailwind) skips the math entirely and reads the exact Tailwind hexes at
steps 700 / 50 / 400, assembling the matching utility classes.
Configuration
interface ColorPairConfig {
anchors: {
background: { lightness: number; chroma: number }
mid: { lightness: number; chroma: number }
foreground: { lightness: number; chroma: number }
}
referenceChroma: number
darkBgAlpha: number
}Defaults (DEFAULT_COLOR_PAIR_CONFIG):
{
anchors: {
background: { lightness: 0.977, chroma: 0.016 }, // ~50
mid: { lightness: 0.746, chroma: 0.189 }, // ~400
foreground: { lightness: 0.514, chroma: 0.195 }, // ~700
},
referenceChroma: 0.219,
darkBgAlpha: 0.1,
}Config can be set per instance (constructor) and overridden per call. Per-call overrides win, and
anchors merge shallowly one level deep, so you can override just mid without redefining the rest:
const colors = new ColorPairService({ darkBgAlpha: 0.12 })
colors.buildFromHex('#7c5cff', {
anchors: { mid: { lightness: 0.7, chroma: 0.2 } }, // background & foreground keep their defaults
darkBgAlpha: 0.2, // beats the 0.12 from the constructor
})darkBgAlpha drives both the class suffix (bg-…/{pct}) and the 8-digit hex alpha, so they
always agree.
Palette reference
The palette is the 26 Tailwind v4 families plus black and white (28 entries total). The newer
v4 colors (taupe, mauve, mist, olive) are included. For families, buildFromTailwind
returns the exact Tailwind utility classes. black and white have no 50/400/700 steps, so
they use a fixed neutral recipe.
TypeScript
The package is written in TypeScript and ships full type declarations. Every interface from the API
is exported, along with the TailwindColorName union, so unknown names are caught at compile time.
It is authored against strict, noUncheckedIndexedAccess, and exactOptionalPropertyTypes.
FAQ / caveats
Tailwind safelisting
The class strings from buildFromTailwind (text-red-700, bg-red-400/10, …) are real
Tailwind utilities assembled at runtime. Tailwind's JIT only generates classes it can actually see
in your source, so names built dynamically will not exist in the final CSS unless you either:
- safelist the relevant families/steps in your Tailwind config,
- ensure those literal classes appear somewhere scannable, or
- simply bind the
hexinstead, which never touches the Tailwind build.
The buildFromHex arbitrary-value classes (text-[#…]) are self-describing and generally
JIT-safe, but for stored or fully dynamic colors the universally safe path is the hex.
Runtime support
Pure math, no DOM or Node APIs. Works in any modern browser and in Node >=18.
No Tailwind dependency
The package emits Tailwind class strings but does not import or depend on Tailwind. There are no
runtime dependencies and nothing in peerDependencies.
Contributing
This package is released with Changesets. When you make a change that should ship, add a changeset describing it:
npm run changesetOn every push to main, CI opens a Version Packages PR that bumps the version and updates
CHANGELOG.md. Merging that PR tags the release, creates a GitHub release, and publishes to npm.
The golden fixtures in test/fixtures.test.ts are the semver guardrail: any change to a fixture
value is a breaking change.
Changelog
See CHANGELOG.md.
License
MIT © Robert-Cristian Chiribuc
