formsclient

color picker

a field holding a chip of the colour. open it for a saturation area, a hue strip, a hex field and swatches.

accent colour

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add color-picker

or by hand: drop these files into components/ui and import from there. color picker leans on Floating.tsx, FieldMessage.tsx and useFloatingPanel.ts, so those come along too.

components/ui/ColorPicker.tsxtsx
"use client";
import React, { useId, useRef, useState } from "react";
import { Portal } from "./Floating";
import { FieldLabel, FieldMessage } from "./FieldMessage";
import { useFloatingPanel } from "./useFloatingPanel";
/* ── Colour maths ─────────────────────────────────────────────────── */
type HSV = { h: number; s: number; v: number };
const clamp = (n: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, n));
/** Accepts #rgb, #rrggbb, with or without the hash. Returns #rrggbb or null */
export function normalizeHex(input: string): string | null {
const s = input.trim().replace(/^#/, "").toLowerCase();
if (/^[0-9a-f]{3}$/.test(s)) return "#" + s.split("").map((c) => c + c).join("");
if (/^[0-9a-f]{6}$/.test(s)) return "#" + s;
return null;
}
function hexToHsv(hex: string): HSV {
const n = parseInt(hex.slice(1), 16);
const r = ((n >> 16) & 255) / 255;
const g = ((n >> 8) & 255) / 255;
const b = (n & 255) / 255;
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 };
}
function hsvToHex({ h, s, v }: HSV): string {
const f = (n: number) => {
const k = (n + h / 60) % 6;
return Math.round((v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255);
};
return "#" + [f(5), f(3), f(1)].map((x) => x.toString(16).padStart(2, "0")).join("");
}
const DEFAULT_SWATCHES = [
"#171717", "#737373", "#dc2626", "#ea580c", "#ca8a04",
"#16a34a", "#0891b2", "#2563eb", "#7c3aed", "#db2777",
];
/* ── The panel: area, hue strip, hex field, swatches ──────────────── */
interface ColorPanelProps {
value: string;
onChange: (hex: string) => void;
swatches?: string[];
/** Focus the saturation knob as soon as the panel mounts */
autoFocus?: boolean;
}
function ColorPanel({ value, onChange, swatches = DEFAULT_SWATCHES, autoFocus = false }: ColorPanelProps) {
// Hue and saturation are kept apart from the hex, so dragging through
// grey or black doesn't forget which colour you were on
const [hsv, setHsv] = useState<HSV>(() => hexToHsv(value));
const [draft, setDraft] = useState(value);
// A colour set from outside (not one we just sent up) resets the panel
const [seen, setSeen] = useState(value);
if (value !== seen) {
setSeen(value);
if (value !== hsvToHex(hsv)) {
setHsv(hexToHsv(value));
setDraft(value);
}
}
const update = (next: HSV) => {
setHsv(next);
const hex = hsvToHex(next);
setDraft(hex);
if (hex !== value) onChange(hex);
};
const pickHex = (hex: string) => {
setHsv(hexToHsv(hex));
setDraft(hex);
if (hex !== value) onChange(hex);
};
return (
<div className="flex w-[236px] flex-col gap-3">
<SaturationArea hsv={hsv} onChange={update} autoFocus={autoFocus} />
<HueStrip hue={hsv.h} onChange={(h) => update({ ...hsv, h })} />
<div className="flex items-center gap-2">
<span
aria-hidden
className="h-9 w-9 shrink-0 rounded-[10px]"
style={{ background: hsvToHex(hsv), boxShadow: "var(--tile-shadow), inset 0 0 0 1px var(--hairline)" }}
/>
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 z-20 -translate-y-1/2 text-[12.5px] text-ink-faint">#</span>
<input
aria-label="hex colour"
value={draft.replace(/^#/, "")}
maxLength={7}
spellCheck={false}
autoComplete="off"
onChange={(e) => {
setDraft("#" + e.target.value.replace(/^#/, ""));
const hex = normalizeHex(e.target.value);
if (hex && e.target.value.replace(/^#/, "").length === 6) pickHex(hex);
}}
onBlur={() => {
const hex = normalizeHex(draft);
if (hex) pickHex(hex);
else setDraft(hsvToHex(hsv));
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
className="field relative z-10 w-full rounded-[10px] py-2 pl-6 pr-3 font-mono text-[12.5px] uppercase text-ink caret-ink"
/>
</div>
</div>
{swatches.length > 0 && (
<div role="group" aria-label="swatches" className="grid grid-cols-10 gap-1.5 border-t border-line-strong pt-3">
{swatches.map((s) => {
const hex = normalizeHex(s) ?? s;
const on = hex === hsvToHex(hsv);
return (
<button
key={s}
type="button"
aria-label={hex}
aria-pressed={on}
onClick={() => pickHex(hex)}
className="relative aspect-square cursor-pointer rounded-full transition-transform duration-200 ease-[var(--ease-spring)] hover:scale-115 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
style={{
background: hex,
boxShadow: on
? "0 0 0 2px var(--panel-face), 0 0 0 3.5px var(--ink)"
: "inset 0 0 0 1px var(--hairline), inset 0 1px 1px rgba(255,255,255,0.3), var(--dot-shadow)",
}}
/>
);
})}
</div>
)}
</div>
);
}
/* Drag anywhere on an element and get back where you are, 0…1 on each axis */
function useDrag(onMove: (x: number, y: number) => void) {
const ref = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const at = (e: React.PointerEvent) => {
const r = ref.current!.getBoundingClientRect();
onMove(clamp((e.clientX - r.left) / r.width), clamp((e.clientY - r.top) / r.height));
};
const handlers = {
onPointerDown: (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
(e.currentTarget.querySelector("[data-thumb]") as HTMLElement | null)?.focus();
setDragging(true);
at(e);
},
onPointerMove: (e: React.PointerEvent) => dragging && at(e),
onPointerUp: () => setDragging(false),
onPointerCancel: () => setDragging(false),
};
return { ref, dragging, handlers };
}
/* A raised white ring that rides on the area and the strip */
const thumbStyle = (color: string, dragging: boolean): React.CSSProperties => ({
background: color,
boxShadow:
"0 0 0 2.5px #fff, 0 0 0 3px rgba(17,17,17,0.1), 0 2px 6px rgba(17,17,17,0.3), inset 0 0 0 1px rgba(17,17,17,0.1)",
transform: `translate(-50%, -50%) scale(${dragging ? 1.15 : 1})`,
transition: "transform 200ms var(--ease-spring)",
});
function SaturationArea({ hsv, onChange, autoFocus }: { hsv: HSV; onChange: (hsv: HSV) => void; autoFocus?: boolean }) {
const { ref, dragging, handlers } = useDrag((x, y) => onChange({ ...hsv, s: x, v: 1 - y }));
const onKeyDown = (e: React.KeyboardEvent) => {
const d = e.shiftKey ? 0.1 : 0.01;
const moves: Record<string, Partial<HSV>> = {
ArrowLeft: { s: clamp(hsv.s - d) },
ArrowRight: { s: clamp(hsv.s + d) },
ArrowUp: { v: clamp(hsv.v + d) },
ArrowDown: { v: clamp(hsv.v - d) },
};
if (moves[e.key]) {
e.preventDefault();
onChange({ ...hsv, ...moves[e.key] });
}
};
return (
<div
ref={ref}
{...handlers}
className="relative h-[148px] cursor-crosshair touch-none rounded-[10px]"
style={{
background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hsv.h} 100% 50%))`,
/* Cut into the face like a field: shade inside, lit lip below */
boxShadow: "inset 0 0 0 1px var(--hairline), inset 0 1px 3px rgba(0,0,0,0.25)",
}}
>
<span
data-thumb
ref={autoFocus ? (el) => el?.focus({ preventScroll: true }) : undefined}
role="slider"
tabIndex={0}
aria-label="saturation and brightness"
aria-valuetext={`saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
aria-valuenow={Math.round(hsv.s * 100)}
aria-valuemin={0}
aria-valuemax={100}
onKeyDown={onKeyDown}
className="absolute h-3.5 w-3.5 rounded-full focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-ink"
style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, ...thumbStyle(hsvToHex(hsv), dragging) }}
/>
</div>
);
}
function HueStrip({ hue, onChange }: { hue: number; onChange: (h: number) => void }) {
const { ref, dragging, handlers } = useDrag((x) => onChange(Math.min(359.9, x * 360)));
const onKeyDown = (e: React.KeyboardEvent) => {
const d = e.shiftKey ? 10 : 1;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); onChange((hue - d + 360) % 360); }
if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); onChange((hue + d) % 360); }
};
return (
<div
ref={ref}
{...handlers}
className="relative mx-[7px] h-3 cursor-pointer touch-none"
>
<span
aria-hidden
className="absolute -inset-x-[7px] inset-y-0 rounded-full"
style={{
background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
boxShadow: "inset 0 0 0 1px var(--hairline), inset 0 1px 2px rgba(0,0,0,0.3)",
}}
/>
<span
data-thumb
role="slider"
tabIndex={0}
aria-label="hue"
aria-valuenow={Math.round(hue)}
aria-valuemin={0}
aria-valuemax={360}
onKeyDown={onKeyDown}
className="absolute top-1/2 h-3.5 w-3.5 rounded-full focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-ink"
style={{ left: `${(hue / 360) * 100}%`, ...thumbStyle(`hsl(${hue} 100% 50%)`, dragging) }}
/>
</div>
);
}
/* ── ColorPicker ──────────────────────────────────────────────────── */
interface ColorPickerProps {
/** A hex colour, like "#2563eb" */
value?: string;
defaultValue?: string;
onChange?: (hex: string) => void;
label?: string;
hint?: string;
/** Preset colours under the picker; pass [] to hide them */
swatches?: string[];
/** Render the panel straight onto the page instead of behind a field */
inline?: boolean;
/** Submit the colour with a form */
name?: string;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
}
/* Closed, it's a sunken field holding a small raised chip of the colour.
Open, a floating panel with a saturation area cut into its face, a hue
strip, a hex field and a row of swatches. */
export function ColorPicker({
value,
defaultValue = "#2563eb",
onChange,
label,
hint,
swatches,
inline = false,
name,
disabled = false,
fullWidth = false,
className = "",
}: ColorPickerProps) {
const uid = useId();
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [internal, setInternal] = useState(normalizeHex(defaultValue) ?? "#000000");
const color = (value !== undefined ? normalizeHex(value) : null) ?? internal;
const panel = useFloatingPanel(ref, { onEscape: () => buttonRef.current?.focus() });
const { setFloating, panelStyle } = panel;
const set = (hex: string) => {
setInternal(hex);
onChange?.(hex);
};
const labelEl = label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>;
const hintEl = <FieldMessage hint={hint} />;
const hidden = name && <input type="hidden" name={name} value={color} />;
if (inline) {
return (
<div role="group" aria-labelledby={label ? `${uid}-label` : undefined} className={["flex flex-col gap-1.5", className].join(" ")}>
{labelEl}
<div className="w-fit rounded-[16px] p-1.5" style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}>
<div className="rounded-[11px] bg-panel-face p-3" style={{ boxShadow: "var(--panel-face-shadow)" }}>
<ColorPanel value={color} onChange={set} swatches={swatches} />
</div>
</div>
{hintEl}
{hidden}
</div>
);
}
return (
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
{labelEl}
<div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-48", className].join(" ")}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={panel.toggle}
aria-haspopup="dialog"
aria-expanded={panel.open}
aria-controls={panel.open ? `${uid}-panel` : undefined}
aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
data-open={panel.open || undefined}
className="field group/color relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-2.5 pl-2.5 pr-4 text-left focus:outline-none disabled:opacity-40 disabled:pointer-events-none"
>
<span
aria-hidden
className="h-6 w-6 shrink-0 rounded-[7px] transition-transform duration-300 ease-[var(--ease-spring)] group-hover/color:scale-105"
style={{
background: color,
boxShadow:
"var(--key-shadow), inset 0 1px 1px rgba(255,255,255,0.3), inset 0 0 0 1px var(--hairline)",
}}
/>
<span id={`${uid}-value`} className="flex-1 font-mono text-[13px] uppercase tracking-[0.02em] text-ink">
{color}
</span>
<svg
aria-hidden
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className={[
"shrink-0 transition-[transform,color] duration-300 ease-[var(--ease-spring)]",
panel.open ? "rotate-180 text-ink" : "text-ink-faint group-hover/color:text-ink-label",
].join(" ")}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{hidden}
</div>
{panel.mounted && (
<Portal>
<div
ref={setFloating}
id={`${uid}-panel`}
role="dialog"
aria-label={label ? `choose ${label}` : "choose a colour"}
className="rounded-[16px] p-1.5"
style={panelStyle}
>
<div className="rounded-[11px] bg-panel-face p-3" style={{ boxShadow: "var(--panel-face-shadow)" }}>
<ColorPanel value={color} onChange={set} swatches={swatches} autoFocus />
</div>
</div>
</Portal>
)}
{hintEl}
</div>
);
}
components/ui/Floating.tsxtsx
"use client";
import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
/* ── Portal ───────────────────────────────────────────────────────── */
/* Anything that floats over the page is rendered at the end of <body>.
Left where it was declared, a panel lives inside whatever stacking
context its parents made (a transform, a filter, an isolate), and a
later sibling on the page can paint straight over it, whatever its
z-index says. */
const noop = () => () => {};
export function Portal({ children }: { children: React.ReactNode }) {
const client = useSyncExternalStore(noop, () => true, () => false);
return client ? createPortal(children, document.body) : null;
}
/* Layers, lowest first: dialogs, then toasts, then anything anchored to
a control (so a select inside a modal opens over the modal), then
tooltips over all of it. */
export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;
/* ── Anchored position ────────────────────────────────────────────── */
export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
const PAD = 8;
interface AnchorOptions {
open: boolean;
side?: Side;
align?: Align;
/** Space between the anchor and the panel */
gap?: number;
/** Make the panel at least as wide as the anchor (selects, comboboxes) */
matchWidth?: boolean;
}
/* Places a fixed-position panel next to its anchor: on the side asked
for unless the other side has more room, then nudged back inside the
viewport. Follows the anchor while the page scrolls or resizes. The
position is written straight onto the element, so scrolling never
re-renders the component. */
export function useAnchoredPosition(
anchor: RefObject<HTMLElement | null>,
{ open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,
) {
const [placed, setPlaced] = useState<Side>(side);
const floating = useRef<HTMLElement | null>(null);
const update = useCallback(() => {
const a = anchor.current?.getBoundingClientRect();
const el = floating.current;
if (!a || !el) return;
if (matchWidth) el.style.minWidth = `${a.width}px`;
// offset sizes ignore the enter transform, so these are the settled size
const w = el.offsetWidth;
const h = el.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };
const vertical = side === "top" || side === "bottom";
const need = (vertical ? h : w) + gap + PAD;
let s = side;
if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];
let top: number;
let left: number;
if (s === "top" || s === "bottom") {
top = s === "bottom" ? a.bottom + gap : a.top - gap - h;
left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;
} else {
left = s === "right" ? a.right + gap : a.left - gap - w;
top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;
}
left = Math.max(PAD, Math.min(left, vw - w - PAD));
top = Math.max(PAD, Math.min(top, vh - h - PAD));
el.style.top = `${Math.round(top)}px`;
el.style.left = `${Math.round(left)}px`;
el.dataset.side = s;
setPlaced(s);
}, [anchor, side, align, gap, matchWidth]);
/* Attach this as the panel's ref: it's placed before its first paint */
const setFloating = useCallback(
(el: HTMLElement | null) => {
floating.current = el;
if (el) update();
},
[update],
);
useEffect(() => {
if (!open) return;
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
window.addEventListener("scroll", schedule, true);
window.addEventListener("resize", schedule);
// The panel can change size while open (a filtered list, a new month)
const ro = new ResizeObserver(schedule);
if (floating.current) ro.observe(floating.current);
if (anchor.current) ro.observe(anchor.current);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", schedule, true);
window.removeEventListener("resize", schedule);
ro.disconnect();
};
}, [open, update, anchor]);
return { setFloating, side: placed, floatingRef: floating, update };
}
/* Where the panel grows from, and where it slides in from, for each side */
export const ORIGIN: Record<Side, string> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
export const ENTER_FROM: Record<Side, string> = {
top: "translateY(4px)",
bottom: "translateY(-4px)",
left: "translateX(4px)",
right: "translateX(-4px)",
};
components/ui/FieldMessage.tsxtsx
"use client";
import React from "react";
/* The label above a field and the line under it, shared by every form
control so they read as one family. */
export function FieldLabel({
id,
htmlFor,
children,
}: {
id?: string;
htmlFor?: string;
children: React.ReactNode;
}) {
const className =
"text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";
return htmlFor ? (
<label id={id} htmlFor={htmlFor} className={className}>
{children}
</label>
) : (
<span id={id} className={className}>
{children}
</span>
);
}
/* An error wins over a hint; either is announced through `id` */
export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {
if (error) {
return (
<p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
</svg>
{error}
</p>
);
}
if (hint) {
return (
<p id={id} className="text-[11.5px] text-ink-muted mt-0.5">
{hint}
</p>
);
}
return null;
}
/* A small raised key that sits inside a field or a panel: steppers,
month arrows, clear buttons. It sinks flush while held. */
export function FieldKey({
label,
disabled,
onClick,
onPress,
onRelease,
focusable = true,
size = 28,
children,
}: {
label: string;
disabled?: boolean;
onClick?: () => void;
/** Pointer went down (for hold-to-repeat) */
onPress?: () => void;
onRelease?: () => void;
focusable?: boolean;
size?: number;
children: React.ReactNode;
}) {
const [pressed, setPressed] = React.useState(false);
const release = () => {
setPressed(false);
onRelease?.();
};
return (
<button
type="button"
aria-label={label}
tabIndex={focusable ? undefined : -1}
disabled={disabled}
onClick={onClick}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (!focusable) e.preventDefault(); // keep focus in the field
setPressed(true);
onPress?.();
}}
onPointerUp={release}
onPointerLeave={() => pressed && release()}
onPointerCancel={release}
className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"
style={{
width: size,
height: size,
background: "var(--face)",
boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",
}}
>
<svg
aria-hidden
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
style={{
transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",
transition: "transform 150ms var(--ease-spring)",
}}
>
{children}
</svg>
</button>
);
}
components/ui/useFloatingPanel.tstsx
"use client";
import { CSSProperties, RefObject, useCallback, useEffect, useRef, useState } from "react";
import { Align, ENTER_FROM, LAYER, ORIGIN, Side, useAnchoredPosition } from "./Floating";
interface FloatingPanelOptions {
side?: Side;
align?: Align;
gap?: number;
matchWidth?: boolean;
/** Called after Escape closes the panel, to hand focus back */
onEscape?: () => void;
}
/* The open/close choreography every field-anchored panel shares (select,
date picker, colour picker, combobox): the panel is portalled to the
body and pinned next to its anchor, stays mounted while it folds away,
unfolds only once painted, and a click outside or Escape puts it back.
Render the panel inside <Portal> with ref={setFloating} and
style={panelStyle}. */
export function useFloatingPanel(anchor: RefObject<HTMLElement | null>, options: FloatingPanelOptions = {}) {
const { side = "bottom", align = "start", gap = 8, matchWidth = false, onEscape } = options;
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [entered, setEntered] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const escape = useRef(onEscape);
useEffect(() => {
escape.current = onEscape;
});
const position = useAnchoredPosition(anchor, { open: mounted, side, align, gap, matchWidth });
const { floatingRef } = position;
const close = useCallback(() => {
setOpen(false);
setEntered(false);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setMounted(false), 200);
}, []);
const show = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
setOpen(true);
setMounted(true);
requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
}, []);
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (anchor.current?.contains(t) || floatingRef.current?.contains(t)) return;
close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
close();
escape.current?.();
}
};
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey, true);
};
}, [open, anchor, floatingRef, close]);
/* Unfolds out of the field: a clip reveals it from the anchor's side
while it fades and settles. The clip reaches past the panel so its
shadow is never cut off. */
const s = position.side;
const hidden: Record<Side, string> = {
bottom: "inset(-48px -48px 55% -48px)",
top: "inset(55% -48px -48px -48px)",
left: "inset(-48px -48px -48px 55%)",
right: "inset(-48px 55% -48px -48px)",
};
const panelStyle: CSSProperties = {
position: "fixed",
top: 0,
left: 0,
zIndex: LAYER.floating,
background: "var(--panel-bezel)",
boxShadow: "var(--overlay-shadow)",
transformOrigin: ORIGIN[s],
opacity: entered ? 1 : 0,
transform: entered ? "none" : `${ENTER_FROM[s]} scale(0.98)`,
clipPath: entered ? "inset(-48px -48px -48px -48px)" : hidden[s],
transition: entered
? "opacity 180ms ease-out, transform 360ms var(--ease-out-soft), clip-path 360ms var(--ease-out-soft)"
: "opacity 160ms ease-in, transform 200ms ease-in, clip-path 200ms ease-in",
};
return {
open,
mounted,
entered,
show,
close,
toggle: open ? close : show,
side: s,
setFloating: position.setFloating,
panelStyle,
};
}

usage

tsx
import { ColorPicker } from "@blurrow/ui";
tsx
<ColorPicker label="accent colour" value={color} onChange={setColor} />

examples

inline

inline puts the panel straight on the page, with no field in front.

#

custom swatches

pass your own palette as swatches, or [] to hide the row.

label colour

pick from the brand or go your own way

props

straight from the source, so they're always up to date.

propsts
interface ColorPanelProps {
value: string;
onChange: (hex: string) => void;
swatches?: string[];
/** Focus the saturation knob as soon as the panel mounts */
autoFocus?: boolean;
}
interface ColorPickerProps {
/** A hex colour, like "#2563eb" */
value?: string;
defaultValue?: string;
onChange?: (hex: string) => void;
label?: string;
hint?: string;
/** Preset colours under the picker; pass [] to hide them */
swatches?: string[];
/** Render the panel straight onto the page instead of behind a field */
inline?: boolean;
/** Submit the colour with a form */
name?: string;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
}