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
| Prop | Type | Default | Description |
|---|---|---|---|
value* | string | — | Hex string: `#rrggbb`, or `#rrggbbaa` when `alpha` is on and the colour is translucent. Empty shows the placeholder. |
onChange* | (hex: string) => void | — | |
alpha | boolean | false | Adds an opacity slider and lets the emitted hex carry an alpha byte. |
presets | string[] | — | Hex swatches shown under the picker, for a house palette. |
inline | boolean | false | Render the picker panel in place, with no trigger or popover. |
placeholder | string | 'Pick a colour' | |
disabled | boolean | false | |
id | string | — | Lands on the trigger, so `<Field>`'s `<label for>` reaches it. |
className | string | — |