formsclient
combobox
a select you can type into. the list narrows as you go, and the bit that matched turns bold.
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 comboboxor by hand: drop these files into components/ui and import from there. combobox leans on Floating.tsx, FieldMessage.tsx and useFloatingPanel.ts, so those come along too.
components/ui/Combobox.tsxtsx
1"use client";2
3import React, { useEffect, useId, useMemo, useRef, useState } from "react";4import { Portal } from "./Floating";5import { FieldLabel, FieldMessage } from "./FieldMessage";6import { useFloatingPanel } from "./useFloatingPanel";7
8export interface ComboboxOption {9 value: string;10 label: string;11 /** A second, quieter line under the label */12 description?: string;13 /** Extra words to match on that aren't shown */14 keywords?: string[];15 disabled?: boolean;16}17
18interface ComboboxProps {19 options: ComboboxOption[];20 value?: string | null;21 defaultValue?: string | null;22 onChange?: (value: string | null) => void;23 placeholder?: string;24 label?: string;25 hint?: string;26 error?: string;27 /** Shown when nothing matches */28 emptyText?: string;29 /** Replace the default "contains" match */30 filter?: (option: ComboboxOption, query: string) => boolean;31 /** Show a key that empties the field */32 clearable?: boolean;33 disabled?: boolean;34 fullWidth?: boolean;35 name?: string;36 className?: string;37}38
39const defaultFilter = (o: ComboboxOption, q: string) => {40 const hay = [o.label, o.description ?? "", ...(o.keywords ?? [])].join(" ").toLowerCase();41 return q42 .toLowerCase()43 .split(/\s+/)44 .filter(Boolean)45 .every((word) => hay.includes(word));46};47
48/* Bolds the part of the label that matched what was typed */49function Highlight({ text, query }: { text: string; query: string }) {50 const q = query.trim().toLowerCase();51 const at = q ? text.toLowerCase().indexOf(q) : -1;52 if (at < 0) return <>{text}</>;53 return (54 <>55 {text.slice(0, at)}56 <mark className="bg-transparent font-semibold text-ink">{text.slice(at, at + q.length)}</mark>57 {text.slice(at + q.length)}58 </>59 );60}61
62/* A Select you can type into. The field filters the list as you go,63 arrows walk the matches, Enter picks one, and leaving the field puts64 the chosen label back if what you typed didn't match anything. */65export function Combobox({66 options,67 value,68 defaultValue = null,69 onChange,70 placeholder = "search…",71 label,72 hint,73 error,74 emptyText = "nothing matches",75 filter = defaultFilter,76 clearable = false,77 disabled = false,78 fullWidth = false,79 name,80 className = "",81}: ComboboxProps) {82 const uid = useId();83 const ref = useRef<HTMLDivElement>(null);84 const inputRef = useRef<HTMLInputElement>(null);85 const listRef = useRef<HTMLDivElement>(null);86 const [internal, setInternal] = useState<string | null>(defaultValue);87 const selectedValue = value !== undefined ? value : internal;88 const selected = options.find((o) => o.value === selectedValue) ?? null;89 const [query, setQuery] = useState<string | null>(null); // null: not typing, show the selection90 const [active, setActive] = useState(0);91 const { open, mounted, show, close, setFloating, panelStyle } = useFloatingPanel(ref, {92 matchWidth: true,93 onEscape: () => setQuery(null),94 });95 const messageId = error || hint ? `${uid}-message` : undefined;96
97 const matches = useMemo(98 () => (query ? options.filter((o) => filter(o, query)) : options),99 [options, query, filter],100 );101
102 useEffect(() => {103 if (!open) return;104 listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });105 }, [open, active]);106
107 const pick = (o: ComboboxOption | null) => {108 if (o?.disabled) return;109 setInternal(o?.value ?? null);110 onChange?.(o?.value ?? null);111 setQuery(null);112 close();113 };114
115 const openList = () => {116 if (open || disabled) return;117 const i = matches.findIndex((o) => o.value === selectedValue);118 setActive(Math.max(0, i));119 show();120 };121
122 const step = (dir: 1 | -1) => {123 if (!matches.length) return;124 let i = active;125 for (let n = 0; n < matches.length; n++) {126 i = (i + dir + matches.length) % matches.length;127 if (!matches[i].disabled) break;128 }129 setActive(i);130 };131
132 const onKeyDown = (e: React.KeyboardEvent) => {133 switch (e.key) {134 case "ArrowDown":135 e.preventDefault();136 if (!open) openList();137 else step(1);138 break;139 case "ArrowUp":140 e.preventDefault();141 if (!open) openList();142 else step(-1);143 break;144 case "Enter":145 if (open && matches[active]) {146 e.preventDefault();147 pick(matches[active]);148 }149 break;150 case "Tab":151 if (open) close();152 break;153 }154 };155
156 const shownText = query ?? selected?.label ?? "";157 const showClear = clearable && !!selected && !disabled;158
159 return (160 <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>161 {label && (162 <FieldLabel id={`${uid}-label`} htmlFor={`${uid}-input`}>163 {label}164 </FieldLabel>165 )}166 {/* Closed, it reads exactly like Select: the chosen label and a167 chevron. Focus it and it turns into a field you type into. */}168 <div ref={ref} className={["group/combo relative isolate", fullWidth ? "w-full" : "w-60", className].join(" ")}>169 <input170 ref={inputRef}171 id={`${uid}-input`}172 role="combobox"173 aria-expanded={open}174 aria-controls={`${uid}-list`}175 aria-autocomplete="list"176 aria-activedescendant={open && matches[active] ? `${uid}-opt-${active}` : undefined}177 aria-invalid={error ? true : undefined}178 aria-describedby={messageId}179 autoComplete="off"180 spellCheck={false}181 disabled={disabled}182 placeholder={selected?.label ?? placeholder}183 value={shownText}184 onChange={(e) => {185 setQuery(e.target.value);186 setActive(0);187 if (!open) show();188 }}189 onClick={openList}190 // typing replaces the chosen label instead of adding to it191 onFocus={(e) => e.target.select()}192 onKeyDown={onKeyDown}193 onBlur={() => {194 setQuery(null);195 if (open) close();196 }}197 data-open={open || undefined}198 className={[199 "field relative z-10 w-full rounded-xl py-3 pl-4 text-[13.5px] font-medium text-ink caret-ink",200 "placeholder:font-normal placeholder:text-ink-faint focus:placeholder:text-ink-ghost",201 "disabled:opacity-40 disabled:cursor-not-allowed",202 open ? "cursor-text" : "cursor-pointer",203 showClear ? "pr-[60px]" : "pr-10",204 ].join(" ")}205 />206
207 {/* Input's sweep of light as the field comes up */}208 <span aria-hidden className="pointer-events-none absolute inset-0 z-15 overflow-hidden rounded-xl">209 <span210 className="absolute inset-y-0 -left-[30%] block w-[30%] opacity-0 group-focus-within/combo:animate-input-shimmer"211 style={{ background: "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)" }}212 />213 </span>214
215 {showClear && (216 <button217 type="button"218 aria-label="clear"219 tabIndex={-1}220 onMouseDown={(e) => e.preventDefault()}221 onClick={() => pick(null)}222 className={[223 "absolute right-9 top-1/2 z-20 grid h-[18px] w-[18px] -translate-y-1/2 cursor-pointer place-items-center rounded-full",224 "text-ink-faint transition-[opacity,color,background-color] duration-150 hover:bg-surface-active hover:text-ink",225 "opacity-0 group-hover/combo:opacity-100 group-focus-within/combo:opacity-100",226 ].join(" ")}227 >228 <svg aria-hidden width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2.75" viewBox="0 0 24 24">229 <path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />230 </svg>231 </button>232 )}233
234 <svg235 aria-hidden236 width="14"237 height="14"238 fill="none"239 stroke="currentColor"240 strokeWidth="2"241 viewBox="0 0 24 24"242 className={[243 "pointer-events-none absolute right-4 top-1/2 z-20 -mt-[7px] transition-[transform,color] duration-300 ease-[var(--ease-spring)]",244 open ? "rotate-180 text-ink" : "text-ink-faint group-hover/combo:text-ink-label",245 ].join(" ")}246 >247 <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />248 </svg>249 {name && <input type="hidden" name={name} value={selectedValue ?? ""} />}250 </div>251
252 {mounted && (253 <Portal>254 <div255 ref={setFloating}256 className="rounded-[16px] p-1.5"257 style={{ ...panelStyle, maxWidth: "min(420px, calc(100vw - 16px))" }}258 >259 <div260 ref={listRef}261 id={`${uid}-list`}262 role="listbox"263 aria-labelledby={label ? `${uid}-label` : undefined}264 // keeps focus in the field, even when the scrollbar is dragged265 onMouseDown={(e) => e.preventDefault()}266 className="flex max-h-72 flex-col gap-0.5 overflow-y-auto rounded-[11px] bg-panel-face p-1 scroll-thin"267 style={{ boxShadow: "var(--panel-face-shadow)" }}268 >269 {matches.length === 0 && (270 <div role="presentation" className="px-2.5 py-2 text-[13px] text-ink-faint">271 {emptyText}272 </div>273 )}274 {matches.map((o, i) => {275 const isSel = o.value === selectedValue;276 const isActive = i === active;277 return (278 <div279 key={o.value}280 id={`${uid}-opt-${i}`}281 data-index={i}282 role="option"283 aria-selected={isSel}284 aria-disabled={o.disabled || undefined}285 onClick={() => pick(o)}286 onMouseMove={() => !o.disabled && setActive(i)}287 className={[288 "flex w-full items-center justify-between gap-3 rounded-[8px] px-2.5 py-2 text-[13px] transition-colors duration-100",289 o.disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer",290 isSel ? "font-medium" : "",291 // one ink per state, as in Select292 isSel || isActive ? "text-ink" : "text-ink-body",293 isActive ? "bg-surface-active/80" : "",294 ].join(" ")}295 style={{ animation: `select-option-in 300ms var(--ease-out-soft) ${40 + Math.min(i, 8) * 22}ms both` }}296 >297 <span className="flex min-w-0 flex-col gap-px">298 <span className="truncate">299 <Highlight text={o.label} query={query ?? ""} />300 </span>301 {o.description && <span className="truncate text-[11.5px] font-normal text-ink-faint">{o.description}</span>}302 </span>303 {isSel && (304 <svg aria-hidden className="shrink-0 text-ink" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">305 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />306 </svg>307 )}308 </div>309 );310 })}311 </div>312 </div>313 </Portal>314 )}315 <FieldMessage id={messageId} error={error} hint={hint} />316 </div>317 );318}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 { Combobox } from "@blurrow/ui";tsx
<Combobox label="language" options={options} value={value} onChange={setValue} />examples
descriptions and keywords
keywords match without being shown, so “gmt” finds london.
try typing “gmt” or “paris”
props
straight from the source, so they're always up to date.
propsts
interface ComboboxOption { value: string; label: string; /** A second, quieter line under the label */ description?: string; /** Extra words to match on that aren't shown */ keywords?: string[]; disabled?: boolean;}
interface ComboboxProps { options: ComboboxOption[]; value?: string | null; defaultValue?: string | null; onChange?: (value: string | null) => void; placeholder?: string; label?: string; hint?: string; error?: string; /** Shown when nothing matches */ emptyText?: string; /** Replace the default "contains" match */ filter?: (option: ComboboxOption, query: string) => boolean; /** Show a key that empties the field */ clearable?: boolean; disabled?: boolean; fullWidth?: boolean; name?: string; className?: string;}