All patterns

Search Start Prompt

Before the first keystroke, the rule under the field draws out and starting points arrive beneath it.

empty-statesminimalelegantautomatic · 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.

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

/**
 * Vibary · Search Start Prompt
 *
 * The state before the first keystroke. The rule under the field draws
 * out to the right, the way a cursor would travel, and the starting
 * points arrive underneath it in reading order: what you looked for
 * recently, then what everyone looks for. Picking one writes it into the
 * field in place, so the panel never resizes between prompt and query.
 *
 * 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 SearchStartPromptProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Field text before anything is chosen. */
  placeholder?: string;
  /** Section headings. */
  recentLabel?: string;
  popularLabel?: string;
  /** Terms offered as starting points. */
  recent?: string[];
  popular?: string[];
  /** Fires with the term that was picked. */
  onSelect?: (term: string) => void;
  /** Block width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** Seconds the rule under the field takes to draw out. */
  ruleSeconds: number;
  /** px each row travels on its way in. */
  rise: number;
  /** Seconds between one row and the next. */
  stagger: number;
  fadeSeconds: number;
};

// Quality rule: nothing springs and nothing scales in this file. A panel
// that appears under a text field is read while someone is deciding what
// to type, so every element arrives on a plain ease-out and stops.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // For a field that opens and closes constantly.
  subtle: {
    ruleSeconds: 0.3,
    rise: 4,
    stagger: 0.03,
    fadeSeconds: 0.2,
  },
  // The all-purpose setting: the rule leads, the rows follow.
  default: {
    ruleSeconds: 0.42,
    rise: 7,
    stagger: 0.05,
    fadeSeconds: 0.26,
  },
  // A longer draw, for a full-screen search that opens over the page.
  playful: {
    ruleSeconds: 0.56,
    rise: 10,
    stagger: 0.07,
    fadeSeconds: 0.3,
  },
};

const RECENT = ["Q3 revenue model", "vendor contracts"];
const POPULAR = ["expense policy", "brand assets"];

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` keeps the field, the rule and the row glyphs correct on
 *  light and dark pages alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function SearchStartPrompt({
  variant = "default",
  placeholder = "Search documents",
  recentLabel = "Recent",
  popularLabel = "Popular in your team",
  recent = RECENT,
  popular = POPULAR,
  onSelect,
  width = 320,
}: SearchStartPromptProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [chosen, setChosen] = useState<string | null>(null);

  const rise = reduceMotion ? 0 : cfg.rise;
  const fade = (delay: number) => ({
    duration: cfg.fadeSeconds,
    ease: "easeOut" as const,
    delay,
  });

  const groups = [
    { label: recentLabel, terms: recent, recentGroup: true },
    { label: popularLabel, terms: popular, recentGroup: false },
  ];

  const pick = (term: string) => {
    setChosen(term);
    onSelect?.(term);
  };

  return (
    <div style={{ width, boxSizing: "border-box", padding: "14px 0 10px" }}>
      <div style={{ padding: "0 16px" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
          <SearchGlyph />

          {/* Prompt and chosen term share one cell, so writing into the
              field cannot change the height of the row. */}
          <span style={{ display: "grid", flex: 1, minWidth: 0 }}>
            <motion.span
              animate={{ opacity: chosen ? 0 : 0.45 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{
                gridArea: "1 / 1",
                fontSize: 13,
                whiteSpace: "nowrap",
                overflow: "hidden",
                textOverflow: "ellipsis",
              }}
            >
              {placeholder}
            </motion.span>
            <motion.span
              animate={{ opacity: chosen ? 1 : 0 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{
                gridArea: "1 / 1",
                fontSize: 13,
                whiteSpace: "nowrap",
                overflow: "hidden",
                textOverflow: "ellipsis",
              }}
            >
              {chosen ?? placeholder}
            </motion.span>
          </span>

          <span
            style={{
              flexShrink: 0,
              fontSize: 10.5,
              padding: "2px 6px",
              borderRadius: 5,
              opacity: 0.4,
              border: `1px solid ${tone(16)}`,
            }}
          >
            Esc
          </span>
        </div>

        {/* The rule draws out from where the caret sits, which is the one
            piece of motion that belongs to a field nobody has typed in. */}
        <motion.div
          aria-hidden
          initial={{ scaleX: reduceMotion ? 1 : 0, opacity: reduceMotion ? 0 : 1 }}
          animate={{ scaleX: 1, opacity: 1 }}
          transition={{
            duration: reduceMotion ? 0.2 : cfg.ruleSeconds,
            ease: [0.16, 1, 0.3, 1],
          }}
          style={{
            height: 1,
            marginTop: 11,
            background: tone(16),
            transformOrigin: "left center",
          }}
        />
      </div>

      {groups.map((group, groupIndex) => (
        <div key={group.label} style={{ marginTop: groupIndex === 0 ? 12 : 10 }}>
          <motion.div
            initial={{ opacity: 0, y: rise }}
            animate={{ opacity: 0.4, y: 0 }}
            transition={fade(0.1 + groupIndex * 0.12)}
            style={{
              fontSize: 10.5,
              fontWeight: 600,
              letterSpacing: 0.5,
              textTransform: "uppercase",
              padding: "0 16px 6px",
            }}
          >
            {group.label}
          </motion.div>

          {group.terms.map((term, termIndex) => {
            // Rows are numbered across both groups, so the cascade runs
            // down the panel as one sequence rather than restarting at
            // each heading. Derived from the data rather than counted
            // during render, which a partial re-render would get wrong.
            const rowIndex = groupIndex === 0 ? termIndex : recent.length + termIndex;
            return (
              <motion.button
                key={term}
                type="button"
                onClick={() => pick(term)}
                initial={{ opacity: 0, y: rise }}
                animate={{ opacity: 1, y: 0 }}
                transition={fade(0.16 + rowIndex * cfg.stagger + groupIndex * 0.08)}
                style={{
                  font: "inherit",
                  display: "flex",
                  alignItems: "center",
                  gap: 10,
                  width: "100%",
                  padding: "8px 16px",
                  color: "inherit",
                  background: chosen === term ? tone(6) : "transparent",
                  border: "none",
                  textAlign: "left",
                  cursor: "pointer",
                }}
              >
                {group.recentGroup ? <ClockGlyph /> : <TrendGlyph />}
                <span style={{ fontSize: 12.5 }}>{term}</span>
              </motion.button>
            );
          })}
        </div>
      ))}
    </div>
  );
}

/** Line art authored inline, all three glyphs stroked in `currentColor`
 *  so they inherit the page theme without an asset. */
function SearchGlyph() {
  return (
    <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
      <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.4" opacity="0.5" />
      <path
        d="M10.8 10.8L14 14"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinecap="round"
        opacity="0.5"
      />
    </svg>
  );
}

function ClockGlyph() {
  return (
    <svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden>
      <circle cx="7" cy="7" r="5.4" stroke="currentColor" strokeWidth="1.3" opacity="0.4" />
      <path
        d="M7 4v3.2l2.2 1.3"
        stroke="currentColor"
        strokeWidth="1.3"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.4"
      />
    </svg>
  );
}

function TrendGlyph() {
  return (
    <svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden>
      <path
        d="M1.6 9.6l3.4-3.6 2.6 2.4 4.8-5"
        stroke="currentColor"
        strokeWidth="1.3"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.4"
      />
      <path
        d="M9.2 3.4h3.2v3.2"
        stroke="currentColor"
        strokeWidth="1.3"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.4"
      />
    </svg>
  );
}

About this pattern

A field with nothing typed in it is an empty state that most products leave blank. This one gives the reader somewhere to begin: the rule under the field draws out to the right, in the direction a caret would travel, and the starting points arrive under it in reading order — what this person looked for recently, then what their team looks for. Picking one writes the term into the field in the same cell it was prompting from, so the panel holds its size between prompt and query. Nothing springs, because this is read while someone is deciding what to type.

Search panel before a queryRecent searches listEmpty lookup fieldJump-to overlay at rest

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

    Focusing the field before typing surfaces recent lookups beneath it.

Related patterns