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-pickeror 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
1"use client";2
3import React, { useId, useRef, useState } from "react";4import { Portal } from "./Floating";5import { FieldLabel, FieldMessage } from "./FieldMessage";6import { useFloatingPanel } from "./useFloatingPanel";7
8/* ── Colour maths ─────────────────────────────────────────────────── */9type HSV = { h: number; s: number; v: number };10
11const clamp = (n: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, n));12
13/** Accepts #rgb, #rrggbb, with or without the hash. Returns #rrggbb or null */14export function normalizeHex(input: string): string | null {15 const s = input.trim().replace(/^#/, "").toLowerCase();16 if (/^[0-9a-f]{3}$/.test(s)) return "#" + s.split("").map((c) => c + c).join("");17 if (/^[0-9a-f]{6}$/.test(s)) return "#" + s;18 return null;19}20
21function hexToHsv(hex: string): HSV {22 const n = parseInt(hex.slice(1), 16);23 const r = ((n >> 16) & 255) / 255;24 const g = ((n >> 8) & 255) / 255;25 const b = (n & 255) / 255;26 const max = Math.max(r, g, b);27 const d = max - Math.min(r, g, b);28 let h = 0;29 if (d) {30 if (max === r) h = ((g - b) / d) % 6;31 else if (max === g) h = (b - r) / d + 2;32 else h = (r - g) / d + 4;33 h = (h * 60 + 360) % 360;34 }35 return { h, s: max ? d / max : 0, v: max };36}37
38function hsvToHex({ h, s, v }: HSV): string {39 const f = (n: number) => {40 const k = (n + h / 60) % 6;41 return Math.round((v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255);42 };43 return "#" + [f(5), f(3), f(1)].map((x) => x.toString(16).padStart(2, "0")).join("");44}45
46const DEFAULT_SWATCHES = [47 "#171717", "#737373", "#dc2626", "#ea580c", "#ca8a04",48 "#16a34a", "#0891b2", "#2563eb", "#7c3aed", "#db2777",49];50
51/* ── The panel: area, hue strip, hex field, swatches ──────────────── */52interface ColorPanelProps {53 value: string;54 onChange: (hex: string) => void;55 swatches?: string[];56 /** Focus the saturation knob as soon as the panel mounts */57 autoFocus?: boolean;58}59
60function ColorPanel({ value, onChange, swatches = DEFAULT_SWATCHES, autoFocus = false }: ColorPanelProps) {61 // Hue and saturation are kept apart from the hex, so dragging through62 // grey or black doesn't forget which colour you were on63 const [hsv, setHsv] = useState<HSV>(() => hexToHsv(value));64 const [draft, setDraft] = useState(value);65
66 // A colour set from outside (not one we just sent up) resets the panel67 const [seen, setSeen] = useState(value);68 if (value !== seen) {69 setSeen(value);70 if (value !== hsvToHex(hsv)) {71 setHsv(hexToHsv(value));72 setDraft(value);73 }74 }75
76 const update = (next: HSV) => {77 setHsv(next);78 const hex = hsvToHex(next);79 setDraft(hex);80 if (hex !== value) onChange(hex);81 };82
83 const pickHex = (hex: string) => {84 setHsv(hexToHsv(hex));85 setDraft(hex);86 if (hex !== value) onChange(hex);87 };88
89 return (90 <div className="flex w-[236px] flex-col gap-3">91 <SaturationArea hsv={hsv} onChange={update} autoFocus={autoFocus} />92 <HueStrip hue={hsv.h} onChange={(h) => update({ ...hsv, h })} />93
94 <div className="flex items-center gap-2">95 <span96 aria-hidden97 className="h-9 w-9 shrink-0 rounded-[10px]"98 style={{ background: hsvToHex(hsv), boxShadow: "var(--tile-shadow), inset 0 0 0 1px var(--hairline)" }}99 />100 <div className="relative flex-1">101 <span className="pointer-events-none absolute left-3 top-1/2 z-20 -translate-y-1/2 text-[12.5px] text-ink-faint">#</span>102 <input103 aria-label="hex colour"104 value={draft.replace(/^#/, "")}105 maxLength={7}106 spellCheck={false}107 autoComplete="off"108 onChange={(e) => {109 setDraft("#" + e.target.value.replace(/^#/, ""));110 const hex = normalizeHex(e.target.value);111 if (hex && e.target.value.replace(/^#/, "").length === 6) pickHex(hex);112 }}113 onBlur={() => {114 const hex = normalizeHex(draft);115 if (hex) pickHex(hex);116 else setDraft(hsvToHex(hsv));117 }}118 onKeyDown={(e) => {119 if (e.key === "Enter") {120 e.preventDefault();121 (e.target as HTMLInputElement).blur();122 }123 }}124 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"125 />126 </div>127 </div>128
129 {swatches.length > 0 && (130 <div role="group" aria-label="swatches" className="grid grid-cols-10 gap-1.5 border-t border-line-strong pt-3">131 {swatches.map((s) => {132 const hex = normalizeHex(s) ?? s;133 const on = hex === hsvToHex(hsv);134 return (135 <button136 key={s}137 type="button"138 aria-label={hex}139 aria-pressed={on}140 onClick={() => pickHex(hex)}141 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"142 style={{143 background: hex,144 boxShadow: on145 ? "0 0 0 2px var(--panel-face), 0 0 0 3.5px var(--ink)"146 : "inset 0 0 0 1px var(--hairline), inset 0 1px 1px rgba(255,255,255,0.3), var(--dot-shadow)",147 }}148 />149 );150 })}151 </div>152 )}153 </div>154 );155}156
157/* Drag anywhere on an element and get back where you are, 0…1 on each axis */158function useDrag(onMove: (x: number, y: number) => void) {159 const ref = useRef<HTMLDivElement>(null);160 const [dragging, setDragging] = useState(false);161 const at = (e: React.PointerEvent) => {162 const r = ref.current!.getBoundingClientRect();163 onMove(clamp((e.clientX - r.left) / r.width), clamp((e.clientY - r.top) / r.height));164 };165 const handlers = {166 onPointerDown: (e: React.PointerEvent) => {167 if (e.button !== 0) return;168 e.preventDefault();169 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);170 (e.currentTarget.querySelector("[data-thumb]") as HTMLElement | null)?.focus();171 setDragging(true);172 at(e);173 },174 onPointerMove: (e: React.PointerEvent) => dragging && at(e),175 onPointerUp: () => setDragging(false),176 onPointerCancel: () => setDragging(false),177 };178 return { ref, dragging, handlers };179}180
181/* A raised white ring that rides on the area and the strip */182const thumbStyle = (color: string, dragging: boolean): React.CSSProperties => ({183 background: color,184 boxShadow:185 "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)",186 transform: `translate(-50%, -50%) scale(${dragging ? 1.15 : 1})`,187 transition: "transform 200ms var(--ease-spring)",188});189
190function SaturationArea({ hsv, onChange, autoFocus }: { hsv: HSV; onChange: (hsv: HSV) => void; autoFocus?: boolean }) {191 const { ref, dragging, handlers } = useDrag((x, y) => onChange({ ...hsv, s: x, v: 1 - y }));192 const onKeyDown = (e: React.KeyboardEvent) => {193 const d = e.shiftKey ? 0.1 : 0.01;194 const moves: Record<string, Partial<HSV>> = {195 ArrowLeft: { s: clamp(hsv.s - d) },196 ArrowRight: { s: clamp(hsv.s + d) },197 ArrowUp: { v: clamp(hsv.v + d) },198 ArrowDown: { v: clamp(hsv.v - d) },199 };200 if (moves[e.key]) {201 e.preventDefault();202 onChange({ ...hsv, ...moves[e.key] });203 }204 };205 return (206 <div207 ref={ref}208 {...handlers}209 className="relative h-[148px] cursor-crosshair touch-none rounded-[10px]"210 style={{211 background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hsv.h} 100% 50%))`,212 /* Cut into the face like a field: shade inside, lit lip below */213 boxShadow: "inset 0 0 0 1px var(--hairline), inset 0 1px 3px rgba(0,0,0,0.25)",214 }}215 >216 <span217 data-thumb218 ref={autoFocus ? (el) => el?.focus({ preventScroll: true }) : undefined}219 role="slider"220 tabIndex={0}221 aria-label="saturation and brightness"222 aria-valuetext={`saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}223 aria-valuenow={Math.round(hsv.s * 100)}224 aria-valuemin={0}225 aria-valuemax={100}226 onKeyDown={onKeyDown}227 className="absolute h-3.5 w-3.5 rounded-full focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-ink"228 style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, ...thumbStyle(hsvToHex(hsv), dragging) }}229 />230 </div>231 );232}233
234function HueStrip({ hue, onChange }: { hue: number; onChange: (h: number) => void }) {235 const { ref, dragging, handlers } = useDrag((x) => onChange(Math.min(359.9, x * 360)));236 const onKeyDown = (e: React.KeyboardEvent) => {237 const d = e.shiftKey ? 10 : 1;238 if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); onChange((hue - d + 360) % 360); }239 if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); onChange((hue + d) % 360); }240 };241 return (242 <div243 ref={ref}244 {...handlers}245 className="relative mx-[7px] h-3 cursor-pointer touch-none"246 >247 <span248 aria-hidden249 className="absolute -inset-x-[7px] inset-y-0 rounded-full"250 style={{251 background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",252 boxShadow: "inset 0 0 0 1px var(--hairline), inset 0 1px 2px rgba(0,0,0,0.3)",253 }}254 />255 <span256 data-thumb257 role="slider"258 tabIndex={0}259 aria-label="hue"260 aria-valuenow={Math.round(hue)}261 aria-valuemin={0}262 aria-valuemax={360}263 onKeyDown={onKeyDown}264 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"265 style={{ left: `${(hue / 360) * 100}%`, ...thumbStyle(`hsl(${hue} 100% 50%)`, dragging) }}266 />267 </div>268 );269}270
271/* ── ColorPicker ──────────────────────────────────────────────────── */272interface ColorPickerProps {273 /** A hex colour, like "#2563eb" */274 value?: string;275 defaultValue?: string;276 onChange?: (hex: string) => void;277 label?: string;278 hint?: string;279 /** Preset colours under the picker; pass [] to hide them */280 swatches?: string[];281 /** Render the panel straight onto the page instead of behind a field */282 inline?: boolean;283 /** Submit the colour with a form */284 name?: string;285 disabled?: boolean;286 fullWidth?: boolean;287 className?: string;288}289
290/* Closed, it's a sunken field holding a small raised chip of the colour.291 Open, a floating panel with a saturation area cut into its face, a hue292 strip, a hex field and a row of swatches. */293export function ColorPicker({294 value,295 defaultValue = "#2563eb",296 onChange,297 label,298 hint,299 swatches,300 inline = false,301 name,302 disabled = false,303 fullWidth = false,304 className = "",305}: ColorPickerProps) {306 const uid = useId();307 const ref = useRef<HTMLDivElement>(null);308 const buttonRef = useRef<HTMLButtonElement>(null);309 const [internal, setInternal] = useState(normalizeHex(defaultValue) ?? "#000000");310 const color = (value !== undefined ? normalizeHex(value) : null) ?? internal;311 const panel = useFloatingPanel(ref, { onEscape: () => buttonRef.current?.focus() });312 const { setFloating, panelStyle } = panel;313
314 const set = (hex: string) => {315 setInternal(hex);316 onChange?.(hex);317 };318
319 const labelEl = label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>;320 const hintEl = <FieldMessage hint={hint} />;321 const hidden = name && <input type="hidden" name={name} value={color} />;322
323 if (inline) {324 return (325 <div role="group" aria-labelledby={label ? `${uid}-label` : undefined} className={["flex flex-col gap-1.5", className].join(" ")}>326 {labelEl}327 <div className="w-fit rounded-[16px] p-1.5" style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}>328 <div className="rounded-[11px] bg-panel-face p-3" style={{ boxShadow: "var(--panel-face-shadow)" }}>329 <ColorPanel value={color} onChange={set} swatches={swatches} />330 </div>331 </div>332 {hintEl}333 {hidden}334 </div>335 );336 }337
338 return (339 <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>340 {labelEl}341 <div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-48", className].join(" ")}>342 <button343 ref={buttonRef}344 type="button"345 disabled={disabled}346 onClick={panel.toggle}347 aria-haspopup="dialog"348 aria-expanded={panel.open}349 aria-controls={panel.open ? `${uid}-panel` : undefined}350 aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}351 data-open={panel.open || undefined}352 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"353 >354 <span355 aria-hidden356 className="h-6 w-6 shrink-0 rounded-[7px] transition-transform duration-300 ease-[var(--ease-spring)] group-hover/color:scale-105"357 style={{358 background: color,359 boxShadow:360 "var(--key-shadow), inset 0 1px 1px rgba(255,255,255,0.3), inset 0 0 0 1px var(--hairline)",361 }}362 />363 <span id={`${uid}-value`} className="flex-1 font-mono text-[13px] uppercase tracking-[0.02em] text-ink">364 {color}365 </span>366 <svg367 aria-hidden368 width="14"369 height="14"370 fill="none"371 stroke="currentColor"372 strokeWidth="2"373 viewBox="0 0 24 24"374 className={[375 "shrink-0 transition-[transform,color] duration-300 ease-[var(--ease-spring)]",376 panel.open ? "rotate-180 text-ink" : "text-ink-faint group-hover/color:text-ink-label",377 ].join(" ")}378 >379 <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />380 </svg>381 </button>382
383 {hidden}384 </div>385 {panel.mounted && (386 <Portal>387 <div388 ref={setFloating}389 id={`${uid}-panel`}390 role="dialog"391 aria-label={label ? `choose ${label}` : "choose a colour"}392 className="rounded-[16px] p-1.5"393 style={panelStyle}394 >395 <div className="rounded-[11px] bg-panel-face p-3" style={{ boxShadow: "var(--panel-face-shadow)" }}>396 <ColorPanel value={color} onChange={set} swatches={swatches} autoFocus />397 </div>398 </div>399 </Portal>400 )}401 {hintEl}402 </div>403 );404}components/ui/Floating.tsxtsx
1"use client";2
3import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";4import { createPortal } from "react-dom";5
6/* ── Portal ───────────────────────────────────────────────────────── */7/* Anything that floats over the page is rendered at the end of <body>.8 Left where it was declared, a panel lives inside whatever stacking9 context its parents made (a transform, a filter, an isolate), and a10 later sibling on the page can paint straight over it, whatever its11 z-index says. */12const noop = () => () => {};13
14export function Portal({ children }: { children: React.ReactNode }) {15 const client = useSyncExternalStore(noop, () => true, () => false);16 return client ? createPortal(children, document.body) : null;17}18
19/* Layers, lowest first: dialogs, then toasts, then anything anchored to20 a control (so a select inside a modal opens over the modal), then21 tooltips over all of it. */22export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;23
24/* ── Anchored position ────────────────────────────────────────────── */25export type Side = "top" | "bottom" | "left" | "right";26export type Align = "start" | "center" | "end";27
28const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };29const PAD = 8;30
31interface AnchorOptions {32 open: boolean;33 side?: Side;34 align?: Align;35 /** Space between the anchor and the panel */36 gap?: number;37 /** Make the panel at least as wide as the anchor (selects, comboboxes) */38 matchWidth?: boolean;39}40
41/* Places a fixed-position panel next to its anchor: on the side asked42 for unless the other side has more room, then nudged back inside the43 viewport. Follows the anchor while the page scrolls or resizes. The44 position is written straight onto the element, so scrolling never45 re-renders the component. */46export function useAnchoredPosition(47 anchor: RefObject<HTMLElement | null>,48 { open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,49) {50 const [placed, setPlaced] = useState<Side>(side);51 const floating = useRef<HTMLElement | null>(null);52
53 const update = useCallback(() => {54 const a = anchor.current?.getBoundingClientRect();55 const el = floating.current;56 if (!a || !el) return;57 if (matchWidth) el.style.minWidth = `${a.width}px`;58
59 // offset sizes ignore the enter transform, so these are the settled size60 const w = el.offsetWidth;61 const h = el.offsetHeight;62 const vw = document.documentElement.clientWidth;63 const vh = window.innerHeight;64 const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };65 const vertical = side === "top" || side === "bottom";66 const need = (vertical ? h : w) + gap + PAD;67
68 let s = side;69 if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];70
71 let top: number;72 let left: number;73 if (s === "top" || s === "bottom") {74 top = s === "bottom" ? a.bottom + gap : a.top - gap - h;75 left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;76 } else {77 left = s === "right" ? a.right + gap : a.left - gap - w;78 top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;79 }80 left = Math.max(PAD, Math.min(left, vw - w - PAD));81 top = Math.max(PAD, Math.min(top, vh - h - PAD));82
83 el.style.top = `${Math.round(top)}px`;84 el.style.left = `${Math.round(left)}px`;85 el.dataset.side = s;86 setPlaced(s);87 }, [anchor, side, align, gap, matchWidth]);88
89 /* Attach this as the panel's ref: it's placed before its first paint */90 const setFloating = useCallback(91 (el: HTMLElement | null) => {92 floating.current = el;93 if (el) update();94 },95 [update],96 );97
98 useEffect(() => {99 if (!open) return;100 let frame = 0;101 const schedule = () => {102 cancelAnimationFrame(frame);103 frame = requestAnimationFrame(update);104 };105 window.addEventListener("scroll", schedule, true);106 window.addEventListener("resize", schedule);107 // The panel can change size while open (a filtered list, a new month)108 const ro = new ResizeObserver(schedule);109 if (floating.current) ro.observe(floating.current);110 if (anchor.current) ro.observe(anchor.current);111 return () => {112 cancelAnimationFrame(frame);113 window.removeEventListener("scroll", schedule, true);114 window.removeEventListener("resize", schedule);115 ro.disconnect();116 };117 }, [open, update, anchor]);118
119 return { setFloating, side: placed, floatingRef: floating, update };120}121
122/* Where the panel grows from, and where it slides in from, for each side */123export const ORIGIN: Record<Side, string> = {124 top: "bottom",125 bottom: "top",126 left: "right",127 right: "left",128};129export const ENTER_FROM: Record<Side, string> = {130 top: "translateY(4px)",131 bottom: "translateY(-4px)",132 left: "translateX(4px)",133 right: "translateX(-4px)",134};components/ui/FieldMessage.tsxtsx
1"use client";2
3import React from "react";4
5/* The label above a field and the line under it, shared by every form6 control so they read as one family. */7
8export function FieldLabel({9 id,10 htmlFor,11 children,12}: {13 id?: string;14 htmlFor?: string;15 children: React.ReactNode;16}) {17 const className =18 "text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";19 return htmlFor ? (20 <label id={id} htmlFor={htmlFor} className={className}>21 {children}22 </label>23 ) : (24 <span id={id} className={className}>25 {children}26 </span>27 );28}29
30/* An error wins over a hint; either is announced through `id` */31export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {32 if (error) {33 return (34 <p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">35 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">36 <circle cx="12" cy="12" r="10" />37 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />38 </svg>39 {error}40 </p>41 );42 }43 if (hint) {44 return (45 <p id={id} className="text-[11.5px] text-ink-muted mt-0.5">46 {hint}47 </p>48 );49 }50 return null;51}52
53/* A small raised key that sits inside a field or a panel: steppers,54 month arrows, clear buttons. It sinks flush while held. */55export function FieldKey({56 label,57 disabled,58 onClick,59 onPress,60 onRelease,61 focusable = true,62 size = 28,63 children,64}: {65 label: string;66 disabled?: boolean;67 onClick?: () => void;68 /** Pointer went down (for hold-to-repeat) */69 onPress?: () => void;70 onRelease?: () => void;71 focusable?: boolean;72 size?: number;73 children: React.ReactNode;74}) {75 const [pressed, setPressed] = React.useState(false);76 const release = () => {77 setPressed(false);78 onRelease?.();79 };80 return (81 <button82 type="button"83 aria-label={label}84 tabIndex={focusable ? undefined : -1}85 disabled={disabled}86 onClick={onClick}87 onPointerDown={(e) => {88 if (e.button !== 0) return;89 if (!focusable) e.preventDefault(); // keep focus in the field90 setPressed(true);91 onPress?.();92 }}93 onPointerUp={release}94 onPointerLeave={() => pressed && release()}95 onPointerCancel={release}96 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"97 style={{98 width: size,99 height: size,100 background: "var(--face)",101 boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",102 }}103 >104 <svg105 aria-hidden106 width="12"107 height="12"108 fill="none"109 stroke="currentColor"110 strokeWidth="2.25"111 strokeLinecap="round"112 strokeLinejoin="round"113 viewBox="0 0 24 24"114 style={{115 transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",116 transition: "transform 150ms var(--ease-spring)",117 }}118 >119 {children}120 </svg>121 </button>122 );123}components/ui/useFloatingPanel.tstsx
1"use client";2
3import { CSSProperties, RefObject, useCallback, useEffect, useRef, useState } from "react";4import { Align, ENTER_FROM, LAYER, ORIGIN, Side, useAnchoredPosition } from "./Floating";5
6interface FloatingPanelOptions {7 side?: Side;8 align?: Align;9 gap?: number;10 matchWidth?: boolean;11 /** Called after Escape closes the panel, to hand focus back */12 onEscape?: () => void;13}14
15/* The open/close choreography every field-anchored panel shares (select,16 date picker, colour picker, combobox): the panel is portalled to the17 body and pinned next to its anchor, stays mounted while it folds away,18 unfolds only once painted, and a click outside or Escape puts it back.19 Render the panel inside <Portal> with ref={setFloating} and20 style={panelStyle}. */21export function useFloatingPanel(anchor: RefObject<HTMLElement | null>, options: FloatingPanelOptions = {}) {22 const { side = "bottom", align = "start", gap = 8, matchWidth = false, onEscape } = options;23 const [open, setOpen] = useState(false);24 const [mounted, setMounted] = useState(false);25 const [entered, setEntered] = useState(false);26 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);27 const escape = useRef(onEscape);28 useEffect(() => {29 escape.current = onEscape;30 });31
32 const position = useAnchoredPosition(anchor, { open: mounted, side, align, gap, matchWidth });33 const { floatingRef } = position;34
35 const close = useCallback(() => {36 setOpen(false);37 setEntered(false);38 if (timer.current) clearTimeout(timer.current);39 timer.current = setTimeout(() => setMounted(false), 200);40 }, []);41
42 const show = useCallback(() => {43 if (timer.current) clearTimeout(timer.current);44 setOpen(true);45 setMounted(true);46 requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));47 }, []);48
49 useEffect(() => () => {50 if (timer.current) clearTimeout(timer.current);51 }, []);52
53 useEffect(() => {54 if (!open) return;55 const onDown = (e: PointerEvent) => {56 const t = e.target as Node;57 if (anchor.current?.contains(t) || floatingRef.current?.contains(t)) return;58 close();59 };60 const onKey = (e: KeyboardEvent) => {61 if (e.key === "Escape") {62 e.stopPropagation();63 close();64 escape.current?.();65 }66 };67 document.addEventListener("pointerdown", onDown);68 document.addEventListener("keydown", onKey, true);69 return () => {70 document.removeEventListener("pointerdown", onDown);71 document.removeEventListener("keydown", onKey, true);72 };73 }, [open, anchor, floatingRef, close]);74
75 /* Unfolds out of the field: a clip reveals it from the anchor's side76 while it fades and settles. The clip reaches past the panel so its77 shadow is never cut off. */78 const s = position.side;79 const hidden: Record<Side, string> = {80 bottom: "inset(-48px -48px 55% -48px)",81 top: "inset(55% -48px -48px -48px)",82 left: "inset(-48px -48px -48px 55%)",83 right: "inset(-48px 55% -48px -48px)",84 };85 const panelStyle: CSSProperties = {86 position: "fixed",87 top: 0,88 left: 0,89 zIndex: LAYER.floating,90 background: "var(--panel-bezel)",91 boxShadow: "var(--overlay-shadow)",92 transformOrigin: ORIGIN[s],93 opacity: entered ? 1 : 0,94 transform: entered ? "none" : `${ENTER_FROM[s]} scale(0.98)`,95 clipPath: entered ? "inset(-48px -48px -48px -48px)" : hidden[s],96 transition: entered97 ? "opacity 180ms ease-out, transform 360ms var(--ease-out-soft), clip-path 360ms var(--ease-out-soft)"98 : "opacity 160ms ease-in, transform 200ms ease-in, clip-path 200ms ease-in",99 };100
101 return {102 open,103 mounted,104 entered,105 show,106 close,107 toggle: open ? close : show,108 side: s,109 setFloating: position.setFloating,110 panelStyle,111 };112}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;}