All patterns

Autocomplete Rise

A completion panel lifts into place under the field, its rows landing a beat apart.

aiminimalfriendlyautomatic · finite · starter · ~0.6s
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.

331 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Autocomplete Rise
 *
 * The completion list that appears under a field once the model has
 * something to offer: the panel lifts into place and its rows follow a
 * beat apart, so the eye lands on the first one.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, so it reads correctly on a light
 * page and on a dark one.
 * Works with zero props; tune via `variant`, `query`, `completions`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type AutocompleteRiseProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** What is already in the field. */
  query?: string;
  /** Completions offered under it, in rank order. */
  completions?: string[];
  /** Field label, shown above the input. */
  label?: string;
  /** ms after mount before the panel lifts in. */
  openDelayMs?: number;
  /** Accent for the matched prefix and the active row. */
  accent?: string;
  /** Fires with the completion the reader accepts. */
  onAccept?: (value: string) => void;
};

type VariantConfig = {
  /** px the whole panel lifts through. */
  panelY: number;
  /** px each row lifts through. */
  rowY: number;
  /** Seconds between one row landing and the next. */
  stagger: number;
  /** Seconds a row takes to resolve. */
  rowSeconds: number;
  panelSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the panel is the only thing with a spring, and it sits
// above a 0.8 damping ratio — a list that overshoots puts a different
// row under the cursor than the one the reader aimed at. Rows are text,
// so they translate and fade and never change size.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost no travel and a very tight cascade. For a field that reopens
  // this list on every keystroke.
  subtle: {
    panelY: 4,
    rowY: 3,
    stagger: 0.016,
    rowSeconds: 0.13,
    panelSpring: { type: "spring", stiffness: 620, damping: 48 },
  },
  // A clear lift with a readable cascade. ζ ≈ 0.89 — the all-purpose
  // setting.
  default: {
    panelY: 8,
    rowY: 6,
    stagger: 0.026,
    rowSeconds: 0.18,
    panelSpring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // More lift and a slower cascade, for a single prominent search box.
  playful: {
    panelY: 13,
    rowY: 10,
    stagger: 0.04,
    rowSeconds: 0.24,
    panelSpring: { type: "spring", stiffness: 400, damping: 34 },
  },
};

/** 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_COMPLETIONS = [
  "quarterly revenue by region",
  "quarterly revenue vs forecast",
  "quarterly refunds and chargebacks",
  "quarterly retention cohort",
];

export default function AutocompleteRise({
  variant = "default",
  query = "quarterly re",
  completions = DEFAULT_COMPLETIONS,
  label = "Ask about your data",
  openDelayMs = 520,
  accent = "#5B5BD6",
  onAccept,
}: AutocompleteRiseProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [value, setValue] = useState(query);
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const [ring, setRing] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => setOpen(true), openDelayMs);
    return () => clearTimeout(timer);
  }, [openDelayMs]);

  const accept = (completion: string) => {
    setValue(completion);
    setOpen(false);
    onAccept?.(completion);
  };

  const prefixLength = completions.some((item) =>
    item.toLowerCase().startsWith(value.toLowerCase())
  )
    ? value.length
    : 0;

  return (
    <div style={{ width: 300, color: "inherit" }}>
      <label
        htmlFor="vibary-acr-field"
        style={{
          display: "block",
          marginBottom: 6,
          fontSize: 11,
          fontWeight: 650,
          letterSpacing: 0.2,
          opacity: 0.55,
        }}
      >
        {label}
      </label>

      <div style={{ position: "relative" }}>
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            padding: "9px 11px",
            borderRadius: 10,
            border: `1px solid ${ring ? accent : tone(15)}`,
            background: tone(6),
            boxShadow: ring ? `0 0 0 3px ${tone(14)}` : "none",
          }}
        >
          <svg
            width="14"
            height="14"
            viewBox="0 0 16 16"
            fill="none"
            aria-hidden
            style={{ flex: "0 0 auto", opacity: 0.45 }}
          >
            <circle cx="7.2" cy="7.2" r="4.4" stroke="currentColor" strokeWidth="1.4" />
            <path
              d="m10.6 10.6 2.6 2.6"
              stroke="currentColor"
              strokeWidth="1.4"
              strokeLinecap="round"
            />
          </svg>
          <input
            id="vibary-acr-field"
            value={value}
            onChange={(event) => {
              setValue(event.target.value);
              setOpen(true);
              setActive(0);
            }}
            onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
            onBlur={() => setRing(false)}
            onKeyDown={(event) => {
              if (!open) return;
              if (event.key === "ArrowDown") {
                event.preventDefault();
                setActive((index) => (index + 1) % completions.length);
              } else if (event.key === "ArrowUp") {
                event.preventDefault();
                setActive(
                  (index) => (index - 1 + completions.length) % completions.length
                );
              } else if (event.key === "Enter") {
                event.preventDefault();
                accept(completions[active]);
              } else if (event.key === "Escape") {
                setOpen(false);
              }
            }}
            role="combobox"
            aria-expanded={open}
            aria-controls="vibary-acr-list"
            aria-autocomplete="list"
            aria-activedescendant={open ? `vibary-acr-row-${active}` : undefined}
            style={{
              flex: 1,
              minWidth: 0,
              padding: 0,
              fontSize: 13.5,
              fontFamily: "inherit",
              color: "inherit",
              background: "transparent",
              border: "none",
              outline: "none",
            }}
          />
        </div>

        <AnimatePresence initial={false}>
          {open && (
            <motion.ul
              id="vibary-acr-list"
              key="panel"
              role="listbox"
              aria-label="Completions"
              // Reduced motion: the panel is simply there. Its presence is
              // the information; the lift is presentation.
              initial={
                reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.panelY }
              }
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, transition: { duration: 0.12 } }}
              transition={
                reduceMotion
                  ? { duration: 0.12 }
                  : { ...cfg.panelSpring, opacity: { duration: 0.16 } }
              }
              style={{
                position: "absolute",
                top: "calc(100% + 6px)",
                left: 0,
                right: 0,
                zIndex: 5,
                margin: 0,
                padding: 5,
                listStyle: "none",
                borderRadius: 11,
                border: `1px solid ${tone(13)}`,
                // The panel floats over whatever follows the field, so it
                // needs a page-opaque surface. The CSS system colors track
                // the reader's light or dark setting on their own.
                background: "Canvas",
                color: "CanvasText",
                boxShadow: "0 14px 32px rgba(0,0,0,0.16)",
              }}
            >
              {completions.map((completion, index) => (
                <motion.li
                  key={completion}
                  id={`vibary-acr-row-${index}`}
                  role="option"
                  aria-selected={index === active}
                  initial={
                    reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rowY }
                  }
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    duration: reduceMotion ? 0.1 : cfg.rowSeconds,
                    delay: reduceMotion ? 0 : index * cfg.stagger,
                    ease: "easeOut",
                  }}
                >
                  <button
                    type="button"
                    tabIndex={-1}
                    onMouseEnter={() => setActive(index)}
                    onClick={() => accept(completion)}
                    style={{
                      display: "flex",
                      alignItems: "center",
                      gap: 8,
                      width: "100%",
                      padding: "7px 9px",
                      fontFamily: "inherit",
                      fontSize: 12.5,
                      textAlign: "left",
                      color: "inherit",
                      background: index === active ? tone(8) : "transparent",
                      border: "none",
                      borderRadius: 8,
                      cursor: "pointer",
                    }}
                  >
                    <svg
                      width="12"
                      height="12"
                      viewBox="0 0 16 16"
                      fill="none"
                      aria-hidden
                      style={{ flex: "0 0 auto", opacity: 0.4 }}
                    >
                      <path
                        d="M3 8h9.2M8.6 4.4 12.4 8l-3.8 3.6"
                        stroke="currentColor"
                        strokeWidth="1.4"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                    <span
                      style={{
                        whiteSpace: "nowrap",
                        overflow: "hidden",
                        textOverflow: "ellipsis",
                      }}
                    >
                      <span style={{ color: accent, fontWeight: 650 }}>
                        {completion.slice(0, prefixLength)}
                      </span>
                      {completion.slice(prefixLength)}
                    </span>
                  </button>
                </motion.li>
              ))}
            </motion.ul>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

What a predictive field does the moment the model has something to offer. The panel lifts a few pixels into position on a well-damped spring while its rows resolve top to bottom, close enough together to read as one arrival but far enough apart that the eye starts at the first row. The rows are text, so they translate and fade and never change size, and the panel is page-opaque because it covers live content. Matched characters carry the accent so the reader can see why each row is there.

Predictive search fieldQuery completionCommand input hintsType-ahead over saved reports

Where it shows up

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

  • Ridgeline
    Search
    Inbox
    Docs
    Files
    Issues
    SearchNew
    Contract renewalInbox · matched “renewal terms”
    Supplier contract.docxFiles · matched “renewal”
    Q3 planning notesDocs · matched “renewal window”
    RID-412 renewal bannerIssues · matched “renewal”
    Search results

    Predictions appear under the box with the typed prefix distinguished.

Related patterns