All patterns

Combobox Filter Narrow

Typing fades out the options that no longer match while the survivors slide up.

formsminimalfuturisticinteraction · finite · advanced · ~0.3s
Interactive · click to play
Variant

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.

345 lines · react + motion only
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.

Filter a long option listSearchable select fieldCountry or method pickerNarrow choices as you type

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    Country selection filters in place while the panel resizes to fit.

Related patterns