New

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

#FF6B35
Button preview

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

ColorPicker.tsx
tsx
"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>
  );
}
PropTypeDefaultDescription
valuestringCurrent selected colour as a hex string (#RRGGBB).
onChange(hex: string) => voidCallback fired with a valid hex string when the selection changes.
paletteArray<{ label: string; value: string }>Predefined swatches shown in the grid. Defaults to the Sitka brand palette.
allowCustombooleantrueShows the hex input field for custom colours.
size"sm" | "md" | "lg""md"Controls swatch and picker dimensions.