Color Picker
Allows users to select a brand or theme colour from a curated palette or a custom hex value. Includes the accessibleForeground algorithm to ensure legible text on any chosen colour.
Demo
Select a swatch or type a hex value. The preview button updates in real time, demonstrating how the chosen colour renders with automatically-computed foreground text.
Brand Colour
Accessible foreground algorithm
The picker always computes a legible foreground (black or white) for the selected colour using the WCAG relative luminance formula. This ensures button labels and preview text are always readable regardless of brand colour choice.
// WCAG 2.1 relative luminance → choose black or white
function accessibleForeground(bgHex: string): "#000000" | "#ffffff" {
const { r, g, b } = hexToRgb(bgHex);
const linearize = (c: number) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
const lum = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
return lum > 0.179 ? "#000000" : "#ffffff";
}This is the web equivalent of Warren's accessibleForeground(on:) Swift function. Use it whenever rendering text or icons on a user-chosen colour.
Dynamic brand token
When the user selects a colour, write it to the --brand-user CSS variable on the document root. Other tokens resolve through it automatically:
// Apply user-chosen brand colour globally
document.documentElement.style.setProperty(
"--brand-user",
`${r} ${g} ${b}` // space-separated RGB triplet for composability
);
// In CSS — accent uses brand-user when set
:root {
--accent: var(--brand-user, 255 107 53); /* fallback to Sitka Coral */
}Implementation
"use client";
import { useState } from "react";
import { Check } from "lucide-react";
const PALETTE = [
{ label: "Coral", value: "#FF6B35" },
{ label: "Indigo", value: "#6366F1" },
{ label: "Blue", value: "#3B82F6" },
{ label: "Emerald", value: "#10B981" },
{ label: "Amber", value: "#F59E0B" },
// … add more as needed
];
function accessibleForeground(bgHex: string): string {
const { r, g, b } = hexToRgb(bgHex);
const lum = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
return lum > 0.179 ? "#000" : "#fff";
}
interface ColorPickerProps {
value: string;
onChange: (hex: string) => void;
palette?: Array<{ label: string; value: string }>;
allowCustom?: boolean;
}
export function ColorPicker({
value,
onChange,
palette = PALETTE,
allowCustom = true,
}: ColorPickerProps) {
const [hex, setHex] = useState(value);
function handleSwatchClick(color: string) {
setHex(color);
onChange(color);
}
function handleHexInput(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value;
setHex(raw);
if (/^#[0-9A-Fa-f]{6}$/.test(raw)) {
onChange(raw);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{/* Swatch grid */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 8 }}>
{palette.map((swatch) => (
<button
key={swatch.value}
title={swatch.label}
onClick={() => handleSwatchClick(swatch.value)}
style={{
width: 36,
height: 36,
borderRadius: 8,
background: swatch.value,
border: value === swatch.value
? `3px solid ${accessibleForeground(swatch.value)}`
: "2px solid transparent",
cursor: "pointer",
position: "relative",
// Specular highlight on selected swatch (sfPillSpecular equivalent)
boxShadow: value === swatch.value
? "inset 0 1px 0 rgba(255,255,255,0.25), 0 0 0 2px rgba(0,0,0,0.15)"
: "inset 0 1px 0 rgba(255,255,255,0.15)",
}}
>
{value === swatch.value && (
<span style={{ color: accessibleForeground(swatch.value), display: "flex", alignItems: "center", justifyContent: "center" }}>
✓
</span>
)}
</button>
))}
</div>
{/* Hex input */}
{allowCustom && (
<input
type="text"
value={hex}
onChange={handleHexInput}
placeholder="#FF6B35"
style={{
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgb(var(--border))",
background: "rgb(var(--surface-raised))",
color: "rgb(var(--text-primary))",
fontSize: 13,
fontFamily: "monospace",
}}
/>
)}
{/* Preview swatch */}
<div
style={{
height: 40,
borderRadius: 8,
background: value,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 600,
color: accessibleForeground(value),
letterSpacing: "0.04em",
}}
>
{value}
</div>
</div>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Current selected colour as a hex string (#RRGGBB). |
onChange | (hex: string) => void | — | Callback fired with a valid hex string when the selection changes. |
palette | Array<{ label: string; value: string }> | — | Predefined swatches shown in the grid. Defaults to the Sitka brand palette. |
allowCustom | boolean | true | Shows the hex input field for custom colours. |
size | "sm" | "md" | "lg" | "md" | Controls swatch and picker dimensions. |