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 combobox

or 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
"use client";
import React, { useEffect, useId, useMemo, useRef, useState } from "react";
import { Portal } from "./Floating";
import { FieldLabel, FieldMessage } from "./FieldMessage";
import { useFloatingPanel } from "./useFloatingPanel";
export 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;
}
const defaultFilter = (o: ComboboxOption, q: string) => {
const hay = [o.label, o.description ?? "", ...(o.keywords ?? [])].join(" ").toLowerCase();
return q
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.every((word) => hay.includes(word));
};
/* Bolds the part of the label that matched what was typed */
function Highlight({ text, query }: { text: string; query: string }) {
const q = query.trim().toLowerCase();
const at = q ? text.toLowerCase().indexOf(q) : -1;
if (at < 0) return <>{text}</>;
return (
<>
{text.slice(0, at)}
<mark className="bg-transparent font-semibold text-ink">{text.slice(at, at + q.length)}</mark>
{text.slice(at + q.length)}
</>
);
}
/* A Select you can type into. The field filters the list as you go,
arrows walk the matches, Enter picks one, and leaving the field puts
the chosen label back if what you typed didn't match anything. */
export function Combobox({
options,
value,
defaultValue = null,
onChange,
placeholder = "search…",
label,
hint,
error,
emptyText = "nothing matches",
filter = defaultFilter,
clearable = false,
disabled = false,
fullWidth = false,
name,
className = "",
}: ComboboxProps) {
const uid = useId();
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [internal, setInternal] = useState<string | null>(defaultValue);
const selectedValue = value !== undefined ? value : internal;
const selected = options.find((o) => o.value === selectedValue) ?? null;
const [query, setQuery] = useState<string | null>(null); // null: not typing, show the selection
const [active, setActive] = useState(0);
const { open, mounted, show, close, setFloating, panelStyle } = useFloatingPanel(ref, {
matchWidth: true,
onEscape: () => setQuery(null),
});
const messageId = error || hint ? `${uid}-message` : undefined;
const matches = useMemo(
() => (query ? options.filter((o) => filter(o, query)) : options),
[options, query, filter],
);
useEffect(() => {
if (!open) return;
listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
}, [open, active]);
const pick = (o: ComboboxOption | null) => {
if (o?.disabled) return;
setInternal(o?.value ?? null);
onChange?.(o?.value ?? null);
setQuery(null);
close();
};
const openList = () => {
if (open || disabled) return;
const i = matches.findIndex((o) => o.value === selectedValue);
setActive(Math.max(0, i));
show();
};
const step = (dir: 1 | -1) => {
if (!matches.length) return;
let i = active;
for (let n = 0; n < matches.length; n++) {
i = (i + dir + matches.length) % matches.length;
if (!matches[i].disabled) break;
}
setActive(i);
};
const onKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!open) openList();
else step(1);
break;
case "ArrowUp":
e.preventDefault();
if (!open) openList();
else step(-1);
break;
case "Enter":
if (open && matches[active]) {
e.preventDefault();
pick(matches[active]);
}
break;
case "Tab":
if (open) close();
break;
}
};
const shownText = query ?? selected?.label ?? "";
const showClear = clearable && !!selected && !disabled;
return (
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
{label && (
<FieldLabel id={`${uid}-label`} htmlFor={`${uid}-input`}>
{label}
</FieldLabel>
)}
{/* Closed, it reads exactly like Select: the chosen label and a
chevron. Focus it and it turns into a field you type into. */}
<div ref={ref} className={["group/combo relative isolate", fullWidth ? "w-full" : "w-60", className].join(" ")}>
<input
ref={inputRef}
id={`${uid}-input`}
role="combobox"
aria-expanded={open}
aria-controls={`${uid}-list`}
aria-autocomplete="list"
aria-activedescendant={open && matches[active] ? `${uid}-opt-${active}` : undefined}
aria-invalid={error ? true : undefined}
aria-describedby={messageId}
autoComplete="off"
spellCheck={false}
disabled={disabled}
placeholder={selected?.label ?? placeholder}
value={shownText}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
if (!open) show();
}}
onClick={openList}
// typing replaces the chosen label instead of adding to it
onFocus={(e) => e.target.select()}
onKeyDown={onKeyDown}
onBlur={() => {
setQuery(null);
if (open) close();
}}
data-open={open || undefined}
className={[
"field relative z-10 w-full rounded-xl py-3 pl-4 text-[13.5px] font-medium text-ink caret-ink",
"placeholder:font-normal placeholder:text-ink-faint focus:placeholder:text-ink-ghost",
"disabled:opacity-40 disabled:cursor-not-allowed",
open ? "cursor-text" : "cursor-pointer",
showClear ? "pr-[60px]" : "pr-10",
].join(" ")}
/>
{/* Input's sweep of light as the field comes up */}
<span aria-hidden className="pointer-events-none absolute inset-0 z-15 overflow-hidden rounded-xl">
<span
className="absolute inset-y-0 -left-[30%] block w-[30%] opacity-0 group-focus-within/combo:animate-input-shimmer"
style={{ background: "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)" }}
/>
</span>
{showClear && (
<button
type="button"
aria-label="clear"
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(null)}
className={[
"absolute right-9 top-1/2 z-20 grid h-[18px] w-[18px] -translate-y-1/2 cursor-pointer place-items-center rounded-full",
"text-ink-faint transition-[opacity,color,background-color] duration-150 hover:bg-surface-active hover:text-ink",
"opacity-0 group-hover/combo:opacity-100 group-focus-within/combo:opacity-100",
].join(" ")}
>
<svg aria-hidden width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2.75" viewBox="0 0 24 24">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
)}
<svg
aria-hidden
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className={[
"pointer-events-none absolute right-4 top-1/2 z-20 -mt-[7px] transition-[transform,color] duration-300 ease-[var(--ease-spring)]",
open ? "rotate-180 text-ink" : "text-ink-faint group-hover/combo:text-ink-label",
].join(" ")}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
{name && <input type="hidden" name={name} value={selectedValue ?? ""} />}
</div>
{mounted && (
<Portal>
<div
ref={setFloating}
className="rounded-[16px] p-1.5"
style={{ ...panelStyle, maxWidth: "min(420px, calc(100vw - 16px))" }}
>
<div
ref={listRef}
id={`${uid}-list`}
role="listbox"
aria-labelledby={label ? `${uid}-label` : undefined}
// keeps focus in the field, even when the scrollbar is dragged
onMouseDown={(e) => e.preventDefault()}
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto rounded-[11px] bg-panel-face p-1 scroll-thin"
style={{ boxShadow: "var(--panel-face-shadow)" }}
>
{matches.length === 0 && (
<div role="presentation" className="px-2.5 py-2 text-[13px] text-ink-faint">
{emptyText}
</div>
)}
{matches.map((o, i) => {
const isSel = o.value === selectedValue;
const isActive = i === active;
return (
<div
key={o.value}
id={`${uid}-opt-${i}`}
data-index={i}
role="option"
aria-selected={isSel}
aria-disabled={o.disabled || undefined}
onClick={() => pick(o)}
onMouseMove={() => !o.disabled && setActive(i)}
className={[
"flex w-full items-center justify-between gap-3 rounded-[8px] px-2.5 py-2 text-[13px] transition-colors duration-100",
o.disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer",
isSel ? "font-medium" : "",
// one ink per state, as in Select
isSel || isActive ? "text-ink" : "text-ink-body",
isActive ? "bg-surface-active/80" : "",
].join(" ")}
style={{ animation: `select-option-in 300ms var(--ease-out-soft) ${40 + Math.min(i, 8) * 22}ms both` }}
>
<span className="flex min-w-0 flex-col gap-px">
<span className="truncate">
<Highlight text={o.label} query={query ?? ""} />
</span>
{o.description && <span className="truncate text-[11.5px] font-normal text-ink-faint">{o.description}</span>}
</span>
{isSel && (
<svg aria-hidden className="shrink-0 text-ink" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
);
})}
</div>
</div>
</Portal>
)}
<FieldMessage id={messageId} error={error} hint={hint} />
</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 { 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;
}