All patterns

No Results Suggest

An empty result set settles into a quiet mark, then offers queries worth trying instead.

empty-statescalmfriendlyautomatic · finite · starter · ~0.9s
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.

240 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · No Results Suggest
 *
 * An empty result set is a dead end unless it offers a way out. The mark
 * settles once, the sentence naming the query follows it, and the queries
 * worth trying instead arrive last — so the eye lands on the explanation
 * before it lands on the options.
 *
 * Self-contained: depends only on `react` and `motion`. Neutrals are
 * mixed from the inherited text color, so it reads on light and dark
 * pages alike. Works with zero props.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type NoResultsSuggestProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The query that returned nothing. Shown back to the reader. */
  query?: string;
  /** Headline above the query line. */
  headline?: string;
  /** Queries offered instead. Three or four reads best. */
  suggestions?: string[];
  /** Label of the reset action under the suggestions. */
  clearLabel?: string;
  /** Fires with the suggestion that was picked. */
  onSelect?: (suggestion: string) => void;
  /** Fires when the reset action is pressed. */
  onClear?: () => void;
  /** Block width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** px each element travels on its way in. */
  rise: number;
  /** Seconds between one suggestion arriving and the next. */
  stagger: number;
  /** Seconds a text element takes to fade in. */
  fadeSeconds: number;
  /** The one spring in the file: the mark coming to rest. */
  markSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: only the drawn mark springs, and it springs above 0.85
// damping ratio (damping / 2√stiffness) so it lands with a single soft
// settle. Everything with words in it travels on a tween and never
// scales. Variants change pace and distance, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly a straight fade. For a results pane that empties and refills
  // as someone types.
  subtle: {
    rise: 5,
    stagger: 0.04,
    fadeSeconds: 0.22,
    markSpring: { type: "spring", stiffness: 420, damping: 42 },
  },
  // A readable sequence: mark, explanation, options. All-purpose.
  default: {
    rise: 8,
    stagger: 0.06,
    fadeSeconds: 0.28,
    markSpring: { type: "spring", stiffness: 340, damping: 34 },
  },
  // Slower and further, for a full-page result view where the empty
  // state is the only thing on screen.
  playful: {
    rise: 12,
    stagger: 0.08,
    fadeSeconds: 0.34,
    markSpring: { type: "spring", stiffness: 280, damping: 29 },
  },
};

const SUGGESTIONS = ["revenue forecast", "Q4 planning", "board deck"];

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

export default function NoResultsSuggest({
  variant = "default",
  query = "quarterly forcast",
  headline = "No matches",
  suggestions = SUGGESTIONS,
  clearLabel = "Clear search",
  onSelect,
  onClear,
  width = 320,
}: NoResultsSuggestProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion keeps the order — mark, sentence, options — and drops
  // only the travel. The sequence is the information here.
  const rise = reduceMotion ? 0 : cfg.rise;
  const fade = (delay: number) => ({
    duration: cfg.fadeSeconds,
    ease: "easeOut" as const,
    delay,
  });

  return (
    <div
      style={{
        width,
        boxSizing: "border-box",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        textAlign: "center",
        padding: "26px 20px 22px",
      }}
    >
      <motion.div
        aria-hidden
        initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.92 }}
        animate={{ opacity: 1, scale: 1 }}
        transition={
          reduceMotion
            ? { duration: 0.2, ease: "easeOut" }
            : { ...cfg.markSpring, opacity: { duration: cfg.fadeSeconds } }
        }
        style={{ lineHeight: 0, marginBottom: 14 }}
      >
        <SearchMark />
      </motion.div>

      <motion.div
        initial={{ opacity: 0, y: rise }}
        animate={{ opacity: 1, y: 0 }}
        transition={fade(0.07)}
        style={{ fontSize: 15, fontWeight: 640 }}
      >
        {headline}
      </motion.div>

      {/* The query is echoed back so the reader can see the typo they are
          about to be offered a fix for. */}
      <motion.div
        initial={{ opacity: 0, y: rise }}
        animate={{ opacity: 0.58, y: 0 }}
        transition={fade(0.12)}
        style={{ fontSize: 12.5, marginTop: 5, lineHeight: 1.5 }}
      >
        Nothing came back for &ldquo;{query}&rdquo;
      </motion.div>

      <div
        style={{
          display: "flex",
          flexWrap: "wrap",
          justifyContent: "center",
          gap: 7,
          marginTop: 16,
        }}
      >
        {suggestions.map((suggestion, index) => (
          <motion.button
            key={suggestion}
            type="button"
            onClick={() => onSelect?.(suggestion)}
            initial={{ opacity: 0, y: rise }}
            animate={{ opacity: 1, y: 0 }}
            transition={fade(0.2 + index * cfg.stagger)}
            style={{
              font: "inherit",
              fontSize: 12,
              lineHeight: 1,
              padding: "7px 11px",
              borderRadius: 999,
              color: "inherit",
              background: tone(7),
              border: `1px solid ${tone(13)}`,
              cursor: "pointer",
            }}
          >
            {suggestion}
          </motion.button>
        ))}
      </div>

      <motion.button
        type="button"
        onClick={() => onClear?.()}
        initial={{ opacity: 0, y: rise }}
        animate={{ opacity: 0.55, y: 0 }}
        transition={fade(0.2 + suggestions.length * cfg.stagger + 0.04)}
        style={{
          font: "inherit",
          fontSize: 12,
          marginTop: 14,
          padding: 0,
          color: "inherit",
          background: "none",
          border: "none",
          textDecoration: "underline",
          textUnderlineOffset: 3,
          cursor: "pointer",
        }}
      >
        {clearLabel}
      </motion.button>
    </div>
  );
}

/** Line art authored inline: stroked in `currentColor` at low opacity, so
 *  it needs no asset and inherits the page's theme for free. */
function SearchMark() {
  return (
    <svg width="52" height="52" viewBox="0 0 52 52" fill="none" aria-hidden>
      <g
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        opacity="0.34"
      >
        <circle cx="23" cy="23" r="13" />
        <line x1="32.6" y1="32.6" x2="41" y2="41" />
      </g>
      {/* The empty interior is the point: one short rule where a result
          would have been. */}
      <line
        x1="18"
        y1="23"
        x2="28"
        y2="23"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        opacity="0.18"
      />
    </svg>
  );
}

About this pattern

The most common empty state in any product, and the one most often left as a bare sentence. A line-art mark settles once, the query that returned nothing is echoed back underneath it, and the queries worth trying instead arrive last in a short cascade — order that puts the explanation before the options, so the reader understands the dead end before being handed a way out of it. Nothing loops and nothing bounces: the block has to be readable while someone is still typing in the field above it. Reduced motion keeps the same order and drops only the travel.

Search with no matchesDirectory lookupHelp center queryCatalog browse

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
    No results for that queryWhen there is something to show, it appears here.
    Search results

    The empty result set is replaced by a shorter query the reader can take.

Related patterns