v1.0

InputColor

Preview

Basic

Loading…

Preview

Code

ts
import InputColor from '@/components/form/InputColor';

src/components/form/InputColor.tsx

AI prompt

text
Build a colour picker input component in React + TypeScript + Tailwind CSS: a saturation/brightness square, hue slider, optional opacity slider, hex field and preset swatches, shown inline or in a popover behind a swatch trigger.

## Look
- Trigger (default): a full-width text-input-styled button, `flex items-center gap-2`: a 16px swatch (`rounded border border-black/10 dark:border-white/15`, checkerboard behind the colour), the hex in `font-mono uppercase` (or the placeholder "Pick a colour" in slate-400), and a 14px `ChevronDown` that rotates 180° while open. Disabled: 60% opacity, `cursor-not-allowed`.
- Popover: `absolute left-0 mt-1 z-50`, opaque floating surface `p-3`, 0.2s fade-in. Inline mode renders the same panel in an opaque card (`inline-block p-3`) with no trigger.
- Panel body: `w-56 flex flex-col gap-3`:
  - Square `h-36 rounded-lg cursor-crosshair`, background `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), hsl(H 100% 50%)`.
  - Hue track `h-3 rounded-full` with `linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)`.
  - Opacity track (only with `alpha`): `linear-gradient(to right, transparent, <opaque colour>)` over a checkerboard.
  - Thumbs: 14px circles, `border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)]`, centred on the point, filled with the colour they represent (square: the opaque colour; hue: the pure hue; alpha: the colour with alpha).
  - A row with a 28px checkered preview chip (`rounded-md border border-slate-200 dark:border-slate-700`) and the hex text input (`py-1.5 font-mono uppercase`).
  - Presets: `flex flex-wrap gap-1.5` of 20px `rounded-md` swatches (checkerboard behind, `border-black/10 dark:border-white/15`); the one matching the current value gets `ring-2 ring-indigo-500 ring-offset-1 dark:ring-offset-slate-800`.
- Checkerboard (a transparency indicator, same in both themes): `repeating-conic-gradient(#cbd5e1 0 25%, #f8fafc 0 50%) 0 0 / 8px 8px`.
- Focus on square and tracks: `focus-visible:ring-2 ring-indigo-400 ring-offset-1 dark:ring-offset-slate-800`.

## Behaviour
- Internal model is HSV + alpha (h 0–360, s/v/a 0–1). Keep it as OWN state rather than deriving from `value` every render — hex can't hold hue at zero saturation or brightness, so a derived picker would snap the hue to red when the square is dragged to a corner. Re-read `value` only when it changes to something the picker didn't just emit (adjust state during render, no syncing effect).
- Emit lower-case hex: `#rrggbb`, or `#rrggbbaa` only when `alpha` is on and a < 1. Without `alpha`, alpha is forced to 1.
- Parse `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` (`#` optional); anything else is ignored. The hex box keeps its own draft while focused (so typing "#1a" isn't rewritten), commits whenever the draft parses, Enter normalises the text without submitting a form, blur drops the draft. Presets compare normalised, so `#fff` lights up for `#ffffff`. Fallback colour when value is empty/invalid: a medium indigo.
- Dragging: pointer capture on press (keeps dragging off the edge), `touch-none`, clamp 0–1, focus the element by hand on press. Square: x = saturation, y = 1 − brightness.
- Keys: square arrows ←/→ saturation, ↑/↓ brightness; tracks arrows ±, Home/End to ends; step 1%, Shift for 10%.
- Popover: ArrowDown on the closed trigger opens it; Escape closes and returns focus to the trigger.

## API
`value: string`, `onChange(hex)`, `alpha = false`, `presets?: string[]`, `inline = false`, `placeholder = 'Pick a colour'`, `disabled`, `id` (lands on the trigger for an external label), `className`. Also export `hexToHsv` and `hsvToHex`.

## Accessibility
- Square, hue and opacity are each `role="slider"` with `tabIndex=0`: "Saturation and brightness" (`aria-valuetext` "Saturation 70%, brightness 90%"), "Hue" (0–360), "Opacity" (0–100, "50%"). Trigger: `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`; popover `role="dialog"` "Colour picker". Hex input "Hex colour". Presets are a group "Preset colours" of `aria-pressed` buttons labelled with their hex.

## Demo
Three variants: popover with presets (#4f46e5, #0ea5e9, #10b981, #f59e0b, #f43f5e, #64748b, #0f172a, #ffffff) at a 176px width; alpha on, starting at #0ea5e980; and inline with the first six presets.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useId, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';

/** Hue 0–360, saturation / value / alpha 0–1. */
export type Hsv = { h: number; s: number; v: number; a: number };

const clamp01 = (n: number) => Math.min(1, Math.max(0, n));

/**
 * Parse `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` (the `#` optional) into HSV.
 * Returns null for anything else, so a half-typed hex in the text box is simply
 * ignored rather than snapping the picker to black.
 */
export function hexToHsv(hex: string): Hsv | null {
  let body = hex.trim().replace(/^#/, '');
  if (!/^[0-9a-f]+$/i.test(body) || ![3, 4, 6, 8].includes(body.length)) return null;
  if (body.length <= 4) body = [...body].map((c) => c + c).join('');
  const n = (i: number) => parseInt(body.slice(i, i + 2), 16) / 255;
  const r = n(0);
  const g = n(2);
  const b = n(4);
  const a = body.length === 8 ? n(6) : 1;

  const max = Math.max(r, g, b);
  const d = max - Math.min(r, g, b);
  let h = 0;
  if (d) {
    if (max === r) h = ((g - b) / d) % 6;
    else if (max === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h = (h * 60 + 360) % 360;
  }
  return { h, s: max ? d / max : 0, v: max, a };
}

/**
 * HSV back to lower-case hex. Alpha is written only when it is below 1 (or
 * when `withAlpha` forces it), so an opaque colour stays the familiar six
 * digits every other system accepts.
 */
export function hsvToHex({ h, s, v, a }: Hsv, withAlpha = a < 1): string {
  const f = (k: number) => {
    const x = (k + h / 60) % 6;
    return v - v * s * Math.max(0, Math.min(x, 4 - x, 1));
  };
  const byte = (n: number) => Math.round(clamp01(n) * 255).toString(16).padStart(2, '0');
  return `#${byte(f(5))}${byte(f(3))}${byte(f(1))}${withAlpha ? byte(a) : ''}`;
}

// Grey-on-white checks behind anything translucent. Inline rather than a
// utility because it is a transparency INDICATOR, not a themed surface — it has
// to read as "see-through" identically in both colour schemes.
const CHECKER = 'repeating-conic-gradient(#cbd5e1 0 25%, #f8fafc 0 50%) 0 0 / 8px 8px';

const FALLBACK: Hsv = { h: 239, s: 0.7, v: 0.9, a: 1 };

/**
 * Pointer handling shared by the square and both sliders. Pointer capture keeps
 * the drag alive when the cursor leaves the element (dragging hue past the end
 * of the bar is the normal way to reach 0 or 360), and `touch-none` on the
 * element stops a touch drag from scrolling the page instead.
 */
function drag(update: (fx: number, fy: number) => void) {
  const at = (e: React.PointerEvent<HTMLElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    update(clamp01((e.clientX - r.left) / r.width), clamp01((e.clientY - r.top) / r.height));
  };
  return {
    onPointerDown: (e: React.PointerEvent<HTMLElement>) => {
      if (e.button !== 0) return;
      // preventDefault suppresses text selection mid-drag, which also skips the
      // browser's focus-on-press — so focus by hand to keep arrow keys working.
      e.preventDefault();
      e.currentTarget.setPointerCapture(e.pointerId);
      e.currentTarget.focus();
      at(e);
    },
    onPointerMove: (e: React.PointerEvent<HTMLElement>) => {
      if (e.currentTarget.hasPointerCapture(e.pointerId)) at(e);
    },
  };
}

/** Arrow-key step for a 0–1 channel: 1%, or 10% with Shift. */
const step = (e: React.KeyboardEvent) => (e.shiftKey ? 0.1 : 0.01);

const THUMB = 'pointer-events-none absolute h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)]';
const TRACK = 'relative h-3 rounded-full cursor-pointer touch-none outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-slate-800';

function ColorPanel({
  hsv,
  onChange,
  alpha,
  presets,
}: {
  hsv: Hsv;
  onChange: (next: Hsv) => void;
  alpha: boolean;
  presets?: string[];
}) {
  const hex = hsvToHex(hsv, alpha && hsv.a < 1);
  // The text box keeps its own draft while focused: re-deriving it from `hsv`
  // on every keystroke would rewrite "#1a" to "#11aa…" under the cursor.
  const [draft, setDraft] = useState<string | null>(null);
  const pure = `hsl(${hsv.h} 100% 50%)`;
  const opaque = hsvToHex({ ...hsv, a: 1 }, false);

  const svKeys = (e: React.KeyboardEvent) => {
    const d = step(e);
    const moves: Record<string, Partial<Hsv>> = {
      ArrowLeft: { s: clamp01(hsv.s - d) },
      ArrowRight: { s: clamp01(hsv.s + d) },
      ArrowDown: { v: clamp01(hsv.v - d) },
      ArrowUp: { v: clamp01(hsv.v + d) },
    };
    if (!moves[e.key]) return;
    e.preventDefault();
    onChange({ ...hsv, ...moves[e.key] });
  };

  // One handler for both 1-D sliders; `value` and `set` speak 0–1, so hue
  // scales by 360 at the call site.
  const sliderKeys = (value: number, set: (n: number) => void) => (e: React.KeyboardEvent) => {
    const d = step(e);
    const next: Record<string, number> = {
      ArrowLeft: value - d, ArrowDown: value - d, ArrowRight: value + d, ArrowUp: value + d, Home: 0, End: 1,
    };
    if (!(e.key in next)) return;
    e.preventDefault();
    set(clamp01(next[e.key]));
  };

  return (
    <div className="flex w-56 flex-col gap-3">
      <div
        role="slider"
        tabIndex={0}
        aria-label="Saturation and brightness"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(hsv.s * 100)}
        aria-valuetext={`Saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
        onKeyDown={svKeys}
        {...drag((fx, fy) => onChange({ ...hsv, s: fx, v: 1 - fy }))}
        className="relative h-36 cursor-crosshair touch-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-slate-800"
        // White fades in from the left (saturation), black from the bottom
        // (value), over the fully-saturated hue — the standard HSV square.
        style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), ${pure}` }}
      >
        <span className={THUMB} style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: opaque }} />
      </div>

      <div
        role="slider"
        tabIndex={0}
        aria-label="Hue"
        aria-valuemin={0}
        aria-valuemax={360}
        aria-valuenow={Math.round(hsv.h)}
        onKeyDown={sliderKeys(hsv.h / 360, (n) => onChange({ ...hsv, h: n * 360 }))}
        {...drag((fx) => onChange({ ...hsv, h: fx * 360 }))}
        className={TRACK}
        style={{ background: 'linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)' }}
      >
        <span className={cn(THUMB, 'top-1/2')} style={{ left: `${(hsv.h / 360) * 100}%`, background: pure }} />
      </div>

      {alpha && (
        <div
          role="slider"
          tabIndex={0}
          aria-label="Opacity"
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={Math.round(hsv.a * 100)}
          aria-valuetext={`${Math.round(hsv.a * 100)}%`}
          onKeyDown={sliderKeys(hsv.a, (a) => onChange({ ...hsv, a }))}
          {...drag((fx) => onChange({ ...hsv, a: fx }))}
          className={TRACK}
          style={{ background: `linear-gradient(to right, transparent, ${opaque}), ${CHECKER}` }}
        >
          <span className={cn(THUMB, 'top-1/2')} style={{ left: `${hsv.a * 100}%`, background: hex }} />
        </div>
      )}

      <div className="flex items-center gap-2">
        <span className="h-7 w-7 shrink-0 overflow-hidden rounded-md border border-slate-200 dark:border-slate-700" style={{ background: CHECKER }}>
          <span className="block h-full w-full" style={{ background: hex }} />
        </span>
        <input
          aria-label="Hex colour"
          spellCheck={false}
          autoComplete="off"
          value={draft ?? hex}
          onFocus={() => setDraft(hex)}
          onBlur={() => setDraft(null)}
          onChange={(e) => {
            setDraft(e.target.value);
            const parsed = hexToHsv(e.target.value);
            if (parsed) onChange(parsed);
          }}
          onKeyDown={(e) => {
            // Enter normalises the draft to what was committed; without
            // preventDefault it would also submit the surrounding form.
            if (e.key === 'Enter') {
              e.preventDefault();
              setDraft(hex);
            }
          }}
          className="field-input py-1.5 font-mono uppercase"
        />
      </div>

      {presets && presets.length > 0 && (
        <div className="flex flex-wrap gap-1.5" role="group" aria-label="Preset colours">
          {presets.map((p) => {
            // Compare normalised, so a preset written `#fff` still lights up.
            const parsed = hexToHsv(p);
            const active = !!parsed && hsvToHex(parsed, alpha && parsed.a < 1) === hex;
            return (
              <button
                key={p}
                type="button"
                title={p}
                aria-label={p}
                aria-pressed={active}
                onClick={() => parsed && onChange(parsed)}
                className={cn(
                  'h-5 w-5 overflow-hidden rounded-md border border-black/10 dark:border-white/15 outline-none focus-visible:ring-2 focus-visible:ring-indigo-400',
                  active && 'ring-2 ring-indigo-500 ring-offset-1 dark:ring-offset-slate-800',
                )}
                style={{ background: CHECKER }}
              >
                <span className="block h-full w-full" style={{ background: p }} />
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

export interface InputColorProps {
  /** Hex string: `#rrggbb`, or `#rrggbbaa` when `alpha` is on and the colour is translucent. Empty shows the placeholder. */
  value: string;
  onChange: (hex: string) => void;
  /** Adds an opacity slider and lets the emitted hex carry an alpha byte. */
  alpha?: boolean;
  /** Hex swatches shown under the picker, for a house palette. */
  presets?: string[];
  /** Render the picker panel in place, with no trigger or popover. */
  inline?: boolean;
  placeholder?: string;
  disabled?: boolean;
  /** Lands on the trigger, so `<Field>`'s `<label for>` reaches it. */
  id?: string;
  className?: string;
}

/**
 * Colour picker: a swatch-and-hex trigger opening a saturation/value square,
 * hue slider, optional opacity slider, hex box and preset row.
 *
 * The picker keeps its OWN hsv state rather than re-deriving from `value`
 * every render. Hex cannot represent hue at zero saturation or zero brightness,
 * so a derived picker would fling the hue slider to red the moment the square
 * is dragged into a corner. The incoming value is re-read only when it changes
 * to something this picker did not just emit.
 */
export default function InputColor({
  value,
  onChange,
  alpha = false,
  presets,
  inline = false,
  placeholder = 'Pick a colour',
  disabled = false,
  id,
  className,
}: InputColorProps) {
  const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(value) ?? FALLBACK);
  const [seen, setSeen] = useState(value);
  // Adjust-state-during-render (React's documented alternative to a syncing
  // effect): no extra paint with the stale colour, no effect loop.
  if (value !== seen) {
    setSeen(value);
    const next = hexToHsv(value);
    if (next && hsvToHex(next, alpha) !== hsvToHex(hsv, alpha)) setHsv(next);
  }

  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelId = useId();

  const close = () => setOpen(false);
  useDismiss(ref, open, close, () => {
    close();
    triggerRef.current?.focus();
  });

  const commit = (next: Hsv) => {
    const n = alpha ? next : { ...next, a: 1 };
    setHsv(n);
    const hex = hsvToHex(n);
    setSeen(hex);
    onChange(hex);
  };

  const panel = <ColorPanel hsv={hsv} onChange={commit} alpha={alpha} presets={presets} />;

  if (inline) {
    return <div className={cn('panel panel-solid inline-block p-3', className)}>{panel}</div>;
  }

  const valid = hexToHsv(value) !== null;

  return (
    <div ref={ref} className={cn('relative', className)}>
      <button
        ref={triggerRef}
        id={id}
        type="button"
        disabled={disabled}
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? panelId : undefined}
        onClick={() => setOpen((o) => !o)}
        onKeyDown={(e) => {
          if (e.key === 'ArrowDown' && !open) {
            e.preventDefault();
            setOpen(true);
          }
        }}
        className="field-input flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-60"
      >
        <span className="h-4 w-4 shrink-0 overflow-hidden rounded border border-black/10 dark:border-white/15" style={{ background: CHECKER }}>
          {valid && <span className="block h-full w-full" style={{ background: value }} />}
        </span>
        <span className={cn('flex-1 truncate', valid ? 'font-mono uppercase' : 'text-slate-400 dark:text-slate-500')}>
          {valid ? value : placeholder}
        </span>
        <ChevronDown aria-hidden className={cn('h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform', open && 'rotate-180')} />
      </button>

      {open && (
        <div id={panelId} role="dialog" aria-label="Colour picker" className="absolute left-0 z-50 mt-1 panel panel-solid p-3 animate-fade-in">
          {panel}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
value*string—Hex string: `#rrggbb`, or `#rrggbbaa` when `alpha` is on and the colour is translucent. Empty shows the placeholder.
onChange*(hex: string) => void—
alphabooleanfalseAdds an opacity slider and lets the emitted hex carry an alpha byte.
presetsstring[]—Hex swatches shown under the picker, for a house palette.
inlinebooleanfalseRender the picker panel in place, with no trigger or popover.
placeholderstring'Pick a colour'
disabledbooleanfalse
idstring—Lands on the trigger, so `<Field>`'s `<label for>` reaches it.
classNamestring—