Autocomplete Rise
A completion panel lifts into place under the field, its rows landing a beat apart.
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 { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Autocomplete Rise
*
* The completion list that appears under a field once the model has
* something to offer: the panel lifts into place and its rows follow a
* beat apart, so the eye lands on the first one.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so it reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `query`, `completions`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AutocompleteRiseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** What is already in the field. */
query?: string;
/** Completions offered under it, in rank order. */
completions?: string[];
/** Field label, shown above the input. */
label?: string;
/** ms after mount before the panel lifts in. */
openDelayMs?: number;
/** Accent for the matched prefix and the active row. */
accent?: string;
/** Fires with the completion the reader accepts. */
onAccept?: (value: string) => void;
};
type VariantConfig = {
/** px the whole panel lifts through. */
panelY: number;
/** px each row lifts through. */
rowY: number;
/** Seconds between one row landing and the next. */
stagger: number;
/** Seconds a row takes to resolve. */
rowSeconds: number;
panelSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the panel is the only thing with a spring, and it sits
// above a 0.8 damping ratio — a list that overshoots puts a different
// row under the cursor than the one the reader aimed at. Rows are text,
// so they translate and fade and never change size.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost no travel and a very tight cascade. For a field that reopens
// this list on every keystroke.
subtle: {
panelY: 4,
rowY: 3,
stagger: 0.016,
rowSeconds: 0.13,
panelSpring: { type: "spring", stiffness: 620, damping: 48 },
},
// A clear lift with a readable cascade. ζ ≈ 0.89 — the all-purpose
// setting.
default: {
panelY: 8,
rowY: 6,
stagger: 0.026,
rowSeconds: 0.18,
panelSpring: { type: "spring", stiffness: 500, damping: 40 },
},
// More lift and a slower cascade, for a single prominent search box.
playful: {
panelY: 13,
rowY: 10,
stagger: 0.04,
rowSeconds: 0.24,
panelSpring: { type: "spring", stiffness: 400, damping: 34 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_COMPLETIONS = [
"quarterly revenue by region",
"quarterly revenue vs forecast",
"quarterly refunds and chargebacks",
"quarterly retention cohort",
];
export default function AutocompleteRise({
variant = "default",
query = "quarterly re",
completions = DEFAULT_COMPLETIONS,
label = "Ask about your data",
openDelayMs = 520,
accent = "#5B5BD6",
onAccept,
}: AutocompleteRiseProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [value, setValue] = useState(query);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [ring, setRing] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setOpen(true), openDelayMs);
return () => clearTimeout(timer);
}, [openDelayMs]);
const accept = (completion: string) => {
setValue(completion);
setOpen(false);
onAccept?.(completion);
};
const prefixLength = completions.some((item) =>
item.toLowerCase().startsWith(value.toLowerCase())
)
? value.length
: 0;
return (
<div style={{ width: 300, color: "inherit" }}>
<label
htmlFor="vibary-acr-field"
style={{
display: "block",
marginBottom: 6,
fontSize: 11,
fontWeight: 650,
letterSpacing: 0.2,
opacity: 0.55,
}}
>
{label}
</label>
<div style={{ position: "relative" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "9px 11px",
borderRadius: 10,
border: `1px solid ${ring ? accent : tone(15)}`,
background: tone(6),
boxShadow: ring ? `0 0 0 3px ${tone(14)}` : "none",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden
style={{ flex: "0 0 auto", opacity: 0.45 }}
>
<circle cx="7.2" cy="7.2" r="4.4" stroke="currentColor" strokeWidth="1.4" />
<path
d="m10.6 10.6 2.6 2.6"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
<input
id="vibary-acr-field"
value={value}
onChange={(event) => {
setValue(event.target.value);
setOpen(true);
setActive(0);
}}
onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setRing(false)}
onKeyDown={(event) => {
if (!open) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActive((index) => (index + 1) % completions.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive(
(index) => (index - 1 + completions.length) % completions.length
);
} else if (event.key === "Enter") {
event.preventDefault();
accept(completions[active]);
} else if (event.key === "Escape") {
setOpen(false);
}
}}
role="combobox"
aria-expanded={open}
aria-controls="vibary-acr-list"
aria-autocomplete="list"
aria-activedescendant={open ? `vibary-acr-row-${active}` : undefined}
style={{
flex: 1,
minWidth: 0,
padding: 0,
fontSize: 13.5,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: "none",
outline: "none",
}}
/>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.ul
id="vibary-acr-list"
key="panel"
role="listbox"
aria-label="Completions"
// Reduced motion: the panel is simply there. Its presence is
// the information; the lift is presentation.
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.panelY }
}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={
reduceMotion
? { duration: 0.12 }
: { ...cfg.panelSpring, opacity: { duration: 0.16 } }
}
style={{
position: "absolute",
top: "calc(100% + 6px)",
left: 0,
right: 0,
zIndex: 5,
margin: 0,
padding: 5,
listStyle: "none",
borderRadius: 11,
border: `1px solid ${tone(13)}`,
// The panel floats over whatever follows the field, so it
// needs a page-opaque surface. The CSS system colors track
// the reader's light or dark setting on their own.
background: "Canvas",
color: "CanvasText",
boxShadow: "0 14px 32px rgba(0,0,0,0.16)",
}}
>
{completions.map((completion, index) => (
<motion.li
key={completion}
id={`vibary-acr-row-${index}`}
role="option"
aria-selected={index === active}
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rowY }
}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0.1 : cfg.rowSeconds,
delay: reduceMotion ? 0 : index * cfg.stagger,
ease: "easeOut",
}}
>
<button
type="button"
tabIndex={-1}
onMouseEnter={() => setActive(index)}
onClick={() => accept(completion)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "7px 9px",
fontFamily: "inherit",
fontSize: 12.5,
textAlign: "left",
color: "inherit",
background: index === active ? tone(8) : "transparent",
border: "none",
borderRadius: 8,
cursor: "pointer",
}}
>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
aria-hidden
style={{ flex: "0 0 auto", opacity: 0.4 }}
>
<path
d="M3 8h9.2M8.6 4.4 12.4 8l-3.8 3.6"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
<span style={{ color: accent, fontWeight: 650 }}>
{completion.slice(0, prefixLength)}
</span>
{completion.slice(prefixLength)}
</span>
</button>
</motion.li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
</div>
);
}About this pattern
What a predictive field does the moment the model has something to offer. The panel lifts a few pixels into position on a well-damped spring while its rows resolve top to bottom, close enough together to read as one arrival but far enough apart that the eye starts at the first row. The rows are text, so they translate and fade and never change size, and the panel is page-opaque because it covers live content. Matched characters carry the accent so the reader can see why each row is there.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Search results
Predictions appear under the box with the typed prefix distinguished.
Related patterns
- Context Pill AttachA dragged file is caught by the prompt bar and lands inside it as a context pill.
- Inline Completion GhostA faint prediction appears ahead of the caret and firms up to real content when it is accepted.
- Suggestion Chips CascadeSuggested next actions land in quick succession under the answer, one chip after another.