Search Start Prompt
Before the first keystroke, the rule under the field draws out and starting points arrive beneath it.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Search results
Focusing the field before typing surfaces recent lookups beneath it.
Related patterns
- Archive EmptyOne fade and a lid settling three pixels onto a box — the least motion a state can have and still arrive.
- Chart No DataThe axes extend from the origin and a flat dashed baseline runs across, so an empty range still looks like a chart.
- Deleted Item TombstoneA removed row leaves a stub holding its slot open, with a hairline showing how long undo is on offer.