All patterns

Search Results Swap

Stale answers dim and stay put while the fresh set cross-fades over them.

loadingcalmminimalautomatic · finite · intermediate · ~0.7s
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.

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

/**
 * Motionary · Search Results Swap
 *
 * Results never leave the screen. While a fresh query is in flight the
 * previous results stay, dimmed, and the new set cross-fades over them —
 * so a fast typist sees stale answers becoming fresh ones instead of the
 * list emptying and refilling on every keystroke.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; pass `results` and `loading` to drive it from
 * your own query state.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SearchResultsSwapItem = {
  title: string;
  kind: string;
  meta: string;
};

export type SearchResultsSwapProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** What the user has typed. Shown instantly — they typed it, it should not lag. */
  query?: string;
  /** The results for `query`. Falls back to an embedded sample sequence. */
  results?: SearchResultsSwapItem[];
  /** True while the request for `query` is in flight. */
  loading?: boolean;
  /** Only consulted while `results` is undefined: how long a sample set is held. */
  holdMs?: number;
  /** Panel width — px number or any CSS length. */
  width?: number | string;
  /** Reserved list height, so a shorter result set can't shrink the panel. */
  minHeight?: number;
};

type VariantConfig = {
  /** How far the stale set is pushed back while the fresh one is fetched. */
  staleOpacity: number;
  dimSeconds: number;
  /** Travel of the arriving set, in px. */
  lift: number;
  enterSeconds: number;
  exitSeconds: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  fetchMs: number;
};

// Quality rule: rows carry text, so they fade and translate but never
// scale, and the spring sits above critical damping — a result list that
// wobbles on every keystroke is unreadable. Variants differ in how far
// back the stale set is pushed and how far the fresh one travels.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a dim, barely a lift. For type-ahead that fires on every
  // character, where the swap should be almost subliminal.
  subtle: {
    staleOpacity: 0.62,
    dimSeconds: 0.14,
    lift: 3,
    enterSeconds: 0.2,
    exitSeconds: 0.14,
    spring: { type: "spring", stiffness: 620, damping: 46 },
    fetchMs: 520,
  },
  // A readable hand-off between two sets of answers. All-purpose.
  default: {
    staleOpacity: 0.45,
    dimSeconds: 0.18,
    lift: 6,
    enterSeconds: 0.28,
    exitSeconds: 0.18,
    spring: { type: "spring", stiffness: 520, damping: 42 },
    fetchMs: 680,
  },
  // A deeper dim and longer travel — for a full search page where the
  // result set is the whole screen.
  playful: {
    staleOpacity: 0.32,
    dimSeconds: 0.22,
    lift: 10,
    enterSeconds: 0.34,
    exitSeconds: 0.2,
    spring: { type: "spring", stiffness: 440, damping: 38 },
    fetchMs: 820,
  },
};

/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
 *  mixing it with `transparent` yields a field, rules and chips correctly
 *  toned on light and dark pages. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Embedded sample: one person narrowing a query, three keystrokes apart.
 *  Replace it by passing `query`, `results` and `loading`. */
const SAMPLE: { query: string; results: SearchResultsSwapItem[] }[] = [
  {
    query: "invo",
    results: [
      { title: "Invoice INV-2041", kind: "Invoice", meta: "Aug 1 · $4,820" },
      { title: "Invoice INV-2038", kind: "Invoice", meta: "Jul 1 · $4,640" },
      { title: "Invoicing policy", kind: "Doc", meta: "Finance handbook" },
    ],
  },
  {
    query: "invoice ref",
    results: [
      { title: "Invoice refund request", kind: "Thread", meta: "3 replies · Marco D." },
      { title: "Refund policy", kind: "Doc", meta: "Finance handbook" },
      { title: "Invoice INV-2041", kind: "Invoice", meta: "Aug 1 · $4,820" },
    ],
  },
  {
    query: "refund policy",
    results: [
      { title: "Refund policy", kind: "Doc", meta: "Finance handbook" },
      { title: "Refund service levels", kind: "Doc", meta: "Support handbook" },
    ],
  },
];

export default function SearchResultsSwap({
  variant = "default",
  query,
  results,
  loading,
  holdMs = 1900,
  width = 336,
  minHeight = 150,
}: SearchResultsSwapProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [selfStep, setSelfStep] = useState(0);
  const [selfBusy, setSelfBusy] = useState(false);
  const uncontrolled = results === undefined;

  // Uncontrolled by default so the file runs on its own: type, wait, land,
  // read, type again. A caller passing `results` takes the wheel entirely.
  useEffect(() => {
    if (!uncontrolled) return;
    if (selfBusy) {
      const timer = setTimeout(() => setSelfBusy(false), cfg.fetchMs);
      return () => clearTimeout(timer);
    }
    const timer = setTimeout(() => {
      setSelfStep((current) => (current + 1) % SAMPLE.length);
      setSelfBusy(true);
    }, holdMs);
    return () => clearTimeout(timer);
  }, [uncontrolled, selfBusy, holdMs, cfg.fetchMs]);

  const incoming = results ?? SAMPLE[selfStep].results;
  const busy = loading ?? selfBusy;
  const shownQuery = query ?? SAMPLE[selfStep].query;

  // The set on screen only changes when a request settles. Comparing by
  // signature rather than identity means a caller re-creating the array on
  // every render can't restart the swap.
  const signature = incoming.map((item) => item.title).join("");
  const [held, setHeld] = useState(() => ({ list: incoming, signature }));
  // Adjusted during render rather than inside an effect: React re-runs the
  // component immediately, before anything is painted, so committing a
  // settled request never costs the swap a frame of stale content.
  if (!busy && held.signature !== signature) {
    setHeld({ list: incoming, signature });
  }

  const lift = reduceMotion ? 0 : cfg.lift;
  const dim = { duration: cfg.dimSeconds, ease: "easeOut" as const };

  return (
    <div style={{ width }}>
      {/* The query is echoed the instant it changes. The user typed it —
          animating their own input back at them reads as lag. */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 9,
          padding: "9px 12px",
          borderRadius: 10,
          background: tone(6),
          border: `1px solid ${tone(13)}`,
        }}
      >
        <svg
          aria-hidden
          width="14"
          height="14"
          viewBox="0 0 16 16"
          fill="none"
          style={{ flexShrink: 0, opacity: 0.55 }}
        >
          <circle cx="7" cy="7" r="4.6" stroke="currentColor" strokeWidth="1.5" />
          <path
            d="M10.4 10.4 14 14"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
          />
        </svg>
        <span style={{ fontSize: 13, lineHeight: 1.3 }}>{shownQuery}</span>
        <span
          aria-hidden
          style={{
            width: 1.5,
            height: 14,
            background: "currentColor",
            opacity: 0.4,
            marginLeft: -3,
          }}
        />
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          margin: "12px 2px 4px",
          fontSize: 11.5,
        }}
      >
        <motion.span
          animate={{ opacity: busy ? cfg.staleOpacity : 0.55 }}
          transition={dim}
          style={{ fontVariantNumeric: "tabular-nums" }}
        >
          {held.list.length} results
        </motion.span>
        {/* The dim is the loading state; this line only names it. */}
        <motion.span
          initial={false}
          animate={{ opacity: busy ? 0.55 : 0 }}
          transition={dim}
        >
          Searching
        </motion.span>
      </div>

      <div
        role="listbox"
        aria-busy={busy}
        aria-label="Search results"
        style={{
          // Fresh and stale sets share one grid cell, so the panel is sized
          // by the taller of them while they cross and the list never
          // collapses to nothing between two queries.
          display: "grid",
          alignItems: "start",
          minHeight,
        }}
      >
        <AnimatePresence initial={false}>
          <motion.div
            key={held.signature}
            initial={{ opacity: 0, y: lift }}
            animate={{ opacity: busy ? cfg.staleOpacity : 1, y: 0 }}
            exit={{ opacity: 0 }}
            transition={{
              opacity: {
                duration: reduceMotion ? 0.16 : cfg.enterSeconds,
                ease: "easeOut",
              },
              y: reduceMotion ? { duration: 0 } : cfg.spring,
            }}
            style={{ gridArea: "1 / 1" }}
          >
            {held.list.map((item, itemIndex) => (
              <div
                key={item.title}
                role="option"
                aria-selected={false}
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: 10,
                  padding: "10px 2px",
                  borderTop: itemIndex === 0 ? "none" : `1px solid ${tone(9)}`,
                }}
              >
                <span style={{ flex: 1, minWidth: 0 }}>
                  <span
                    style={{
                      display: "block",
                      fontSize: 13,
                      fontWeight: 560,
                      lineHeight: 1.35,
                      whiteSpace: "nowrap",
                      overflow: "hidden",
                      textOverflow: "ellipsis",
                    }}
                  >
                    {item.title}
                  </span>
                  <span
                    style={{
                      display: "block",
                      fontSize: 11.5,
                      opacity: 0.55,
                      marginTop: 2,
                    }}
                  >
                    {item.meta}
                  </span>
                </span>
                <span
                  style={{
                    flexShrink: 0,
                    fontSize: 10.5,
                    fontWeight: 600,
                    letterSpacing: 0.3,
                    padding: "3px 7px",
                    borderRadius: 6,
                    background: tone(8),
                    opacity: 0.75,
                  }}
                >
                  {item.kind}
                </span>
              </div>
            ))}
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

Type-ahead's real failure is not slowness, it is emptiness: clearing the list on every keystroke makes a fast connection feel broken. Here the previous results never leave. They drop to roughly half opacity the moment a new request goes out — that dim is the loading state, so no spinner is needed — and the fresh set cross-fades over them with a small lift once it lands. Both sets share one grid cell, so the panel is sized by the taller of them while they cross and can never collapse between two queries. The query itself is echoed instantly, without animation: the user typed it, and animating their own input back at them reads as lag.

Type-ahead searchCommand paletteFiltered directoryAutocomplete list

Where it shows up

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

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    Documentation results refresh under a changing query without emptying the panel.

Related patterns