Combobox Filter Narrow
Typing fades out the options that no longer match while the survivors slide up.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Combobox Filter Narrow
*
* Typing narrows the list instead of redrawing it: options that no
* longer match fade where they stand, the ones that survive slide up
* into the gap, and the panel eases to its new length.
*
* Every row keeps its own element for the whole session, so the browser
* animates positions rather than tearing rows out and inserting new
* ones. Rows are laid out by transform only — nothing resizes, so no
* label is ever stretched.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `options`, `label`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ComboboxFilterNarrowProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Everything the field can resolve to. */
options?: string[];
/** Field label. */
label?: string;
/** Shown in the empty field. */
placeholder?: string;
/** Starting query. */
defaultQuery?: string;
/** Accent for the focus ring and the chosen row. */
accent?: string;
/** Fires with the option the reader picks. */
onSelect?: (option: string) => void;
};
type VariantConfig = {
/** Row height in px — fixed, so the list is pure translation. */
rowHeight: number;
/** Seconds a leaving row takes to clear. */
fadeSeconds: number;
/** Seconds the panel takes to reach its new length. */
resizeSeconds: number;
moveSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: rows carry labels, so they translate and fade and never
// change size. The move spring sits well above a 0.8 damping ratio — a
// list that overshoots while it narrows puts a different option under
// the cursor than the one the reader was reaching for.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Rows close the gap almost immediately. For a field filtering
// hundreds of options on every keystroke.
subtle: {
rowHeight: 32,
fadeSeconds: 0.09,
resizeSeconds: 0.14,
moveSpring: { type: "spring", stiffness: 750, damping: 54 },
},
// The narrowing is legible without ever being waited on. ζ ≈ 0.97 —
// the all-purpose setting.
default: {
rowHeight: 34,
fadeSeconds: 0.14,
resizeSeconds: 0.22,
moveSpring: { type: "spring", stiffness: 520, damping: 44 },
},
// A slower close, for a short list where each option is a decision.
playful: {
rowHeight: 39,
fadeSeconds: 0.22,
resizeSeconds: 0.32,
moveSpring: { type: "spring", stiffness: 350, damping: 33 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_OPTIONS = [
"Standard ground",
"Standard international",
"Express overnight",
"Express two-day",
"Economy freight",
"Same-day courier",
"Locker pickup",
"Collect in store",
];
export default function ComboboxFilterNarrow({
variant = "default",
options = DEFAULT_OPTIONS,
label = "Shipping method",
placeholder = "Start typing to narrow the list",
defaultQuery = "",
accent = "#5B5BD6",
onSelect,
}: ComboboxFilterNarrowProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [query, setQuery] = useState(defaultQuery);
const [chosen, setChosen] = useState<string | null>(null);
const [active, setActive] = useState(0);
const [ring, setRing] = useState(false);
const listId = useId();
const fieldId = useId();
const needle = query.trim().toLowerCase();
const matches = options.filter((option) =>
option.toLowerCase().includes(needle)
);
const activeIndex = Math.min(active, Math.max(0, matches.length - 1));
const select = (option: string) => {
setChosen(option);
setQuery(option);
setActive(0);
onSelect?.(option);
};
const listHeight = Math.max(1, matches.length) * cfg.rowHeight;
return (
<div style={{ width: 296, color: "inherit" }}>
<label
htmlFor={fieldId}
style={{
display: "block",
marginBottom: 6,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.2,
opacity: 0.6,
}}
>
{label}
</label>
<input
id={fieldId}
role="combobox"
aria-expanded
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
matches.length > 0 ? `${listId}-row-${activeIndex}` : undefined
}
value={query}
placeholder={placeholder}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setRing(false)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && matches.length) {
event.preventDefault();
setActive((index) => (index + 1) % matches.length);
} else if (event.key === "ArrowUp" && matches.length) {
event.preventDefault();
setActive(
(index) => (index - 1 + matches.length) % matches.length
);
} else if (event.key === "Enter" && matches[activeIndex]) {
event.preventDefault();
select(matches[activeIndex]);
} else if (event.key === "Escape") {
setQuery("");
setActive(0);
}
}}
style={{
width: "100%",
boxSizing: "border-box",
padding: "9px 12px",
fontSize: 13.5,
fontFamily: "inherit",
color: "inherit",
background: tone(6),
border: `1px solid ${ring ? accent : tone(15)}`,
borderRadius: 10,
boxShadow: ring ? `0 0 0 3px ${tone(14)}` : "none",
outline: "none",
}}
/>
<div
role="status"
style={{ margin: "7px 0 5px", fontSize: 11, opacity: 0.45 }}
>
{matches.length} of {options.length} options
</div>
{/*
Height is the only non-transform property animated here, and it
is animated because the panel genuinely gets shorter. Rows inside
are positioned by transform alone.
*/}
<motion.ul
id={listId}
role="listbox"
aria-label={label}
initial={false}
animate={{ height: listHeight }}
transition={{
duration: reduceMotion ? 0 : cfg.resizeSeconds,
ease: [0.22, 0.61, 0.36, 1],
}}
style={{
position: "relative",
margin: 0,
padding: 0,
listStyle: "none",
borderRadius: 11,
border: `1px solid ${tone(12)}`,
background: tone(4),
overflow: "hidden",
}}
>
{options.map((option, optionIndex) => {
// A row that no longer matches collapses to the slot it would
// have taken, so survivors slide up past it while it clears —
// rather than the whole list jumping to a new arrangement.
const slot = matches.indexOf(option);
const matched = slot !== -1;
const before = options
.slice(0, optionIndex)
.filter((other) => matches.indexOf(other) !== -1).length;
const rowIndex = matched ? slot : before;
const isActive = matched && slot === activeIndex;
const isChosen = option === chosen;
return (
<motion.li
key={option}
id={matched ? `${listId}-row-${slot}` : undefined}
role={matched ? "option" : undefined}
aria-selected={matched ? isChosen : undefined}
aria-hidden={!matched}
initial={false}
animate={{ y: rowIndex * cfg.rowHeight, opacity: matched ? 1 : 0 }}
transition={
reduceMotion
? { duration: 0 }
: {
...cfg.moveSpring,
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
}
}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: cfg.rowHeight,
pointerEvents: matched ? "auto" : "none",
}}
>
<button
type="button"
tabIndex={-1}
onMouseEnter={() => matched && setActive(slot)}
onClick={() => select(option)}
style={{
display: "flex",
alignItems: "center",
gap: 9,
width: "100%",
height: "100%",
padding: "0 11px",
fontFamily: "inherit",
fontSize: 12.5,
textAlign: "left",
color: "inherit",
background: isActive ? tone(8) : "transparent",
border: "none",
cursor: "pointer",
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
flex: "0 0 auto",
width: 13,
height: 13,
borderRadius: "50%",
border: `1.5px solid ${isChosen ? accent : tone(22)}`,
}}
>
{isChosen && (
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: accent,
}}
/>
)}
</span>
<span
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{option}
</span>
</button>
</motion.li>
);
})}
<motion.li
aria-hidden={matches.length > 0}
initial={false}
animate={{ opacity: matches.length === 0 ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : cfg.fadeSeconds }}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
fontSize: 12,
opacity: 0.45,
pointerEvents: "none",
}}
>
Nothing matches that
</motion.li>
</motion.ul>
</div>
);
}About this pattern
A picker that narrows instead of redrawing. Every option keeps its own element for the whole session, so a keystroke moves rows rather than tearing them out and inserting new ones: a row that stops matching fades where it stands, the ones that survive travel up into the gap on a tightly damped spring, and the panel eases to its new length. Rows are laid out by transform alone and never resize, so no label is ever stretched mid-move. Arrow keys, Enter and Escape all work, and the count of remaining options is announced.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Country selection filters in place while the panel resizes to fit.
Related patterns
- Range Double HandleTwo handles bound a range and the fill between them tracks both, one-to-one under the pointer and settling on a spring from the keyboard.
- Select Dropdown OpenThe list unfolds under the field, options a beat behind the panel, current choice already marked.
- Search Input ExpandA search icon opens into a full field while the controls beside it give up the space.