← 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("