Address Autocomplete
Choosing a suggestion closes the list from the bottom up and fills the fields in reading order.
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 { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Address Autocomplete
*
* Choosing a suggestion closes the list from the bottom up and fills the
* fields in reading order, each one flashing a ring as its value lands.
* The sequence is the point: five fields populating at once looks like a
* glitch, five fields populating in order looks like the form doing the
* typing for you.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the form reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `defaultQuery`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AddressAutocompletePickProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Text already in the search field on first render. */
defaultQuery?: string;
/** Fires with the fields once a suggestion has been taken. */
onPick?: (fields: Record<string, string>) => void;
};
type VariantConfig = {
/** Seconds for the list to close. */
collapse: number;
/** Seconds between fields filling. */
stagger: number;
/** Seconds the ring stays lit on a freshly filled field. */
ring: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: values slide a few pixels and fade — they never scale,
// because an address that grows into place is an address that is hard to
// proofread. The springs are at or above a 0.8 damping ratio so each
// value lands once.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Near-instant fill. For a checkout the customer has done before.
subtle: {
collapse: 0.16,
stagger: 0.03,
ring: 0.4,
spring: { type: "spring", stiffness: 560, damping: 42 },
},
// The fill reads as a sequence. All-purpose.
default: {
collapse: 0.24,
stagger: 0.06,
ring: 0.6,
spring: { type: "spring", stiffness: 420, damping: 35 },
},
// A slower cascade for a first-time address entry, where watching the
// form fill itself is reassuring.
playful: {
collapse: 0.32,
stagger: 0.09,
ring: 0.8,
spring: { type: "spring", stiffness: 350, damping: 32 },
},
};
const ACCENT = "#4C7DF0";
/** 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 that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const FIELD_LABELS = ["Street", "Suburb", "City", "Postcode"] as const;
const SUGGESTIONS = [
{
id: "marsden-teAro",
line: "40 Marsden Row",
context: "Te Aro, Wellington 6011",
fields: ["40 Marsden Row", "Te Aro", "Wellington", "6011"],
},
{
id: "marsden-newtown",
line: "40 Marsden Row",
context: "Newtown, Wellington 6021",
fields: ["40 Marsden Row", "Newtown", "Wellington", "6021"],
},
{
id: "marsden-karori",
line: "40 Marsden Street",
context: "Karori, Wellington 6012",
fields: ["40 Marsden Street", "Karori", "Wellington", "6012"],
},
{
id: "marsden-petone",
line: "402 Marsden Avenue",
context: "Petone, Lower Hutt 5012",
fields: ["402 Marsden Avenue", "Petone", "Lower Hutt", "5012"],
},
] as const;
export default function AddressAutocompletePick({
variant = "default",
defaultQuery = "40 Marsden",
onPick,
}: AddressAutocompletePickProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [query, setQuery] = useState(defaultQuery);
const [picked, setPicked] = useState<number | null>(null);
const needle = query.trim().toLowerCase();
const matches = SUGGESTIONS.map((suggestion, index) => ({ suggestion, index })).filter(
({ suggestion }) =>
needle.length > 1 &&
`${suggestion.line} ${suggestion.context}`.toLowerCase().includes(needle)
);
const open = picked === null && matches.length > 0;
const values = picked === null ? null : SUGGESTIONS[picked].fields;
const choose = (index: number) => {
setPicked(index);
setQuery(SUGGESTIONS[index].line);
onPick?.(
Object.fromEntries(
FIELD_LABELS.map((label, position) => [
label.toLowerCase(),
SUGGESTIONS[index].fields[position],
])
)
);
};
const fieldAt = (index: number) =>
reduceMotion ? 0.06 : cfg.collapse * 0.55 + index * cfg.stagger;
return (
<div
style={{
width: 342,
padding: 16,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
fontFamily: "inherit",
boxSizing: "border-box",
}}
>
<div style={{ fontSize: 13, fontWeight: 650, marginBottom: 11 }}>
Delivery address
</div>
<div style={{ position: "relative" }}>
<span
aria-hidden
style={{
position: "absolute",
left: 11,
top: 10,
opacity: 0.45,
lineHeight: 0,
}}
>
<svg
width="15"
height="15"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 18s6-5.1 6-9.4A6 6 0 0 0 4 8.6C4 12.9 10 18 10 18z" />
<circle cx="10" cy="8.4" r="2.2" />
</svg>
</span>
<input
value={query}
onChange={(event) => {
setQuery(event.target.value);
setPicked(null);
}}
aria-label="Search for an address"
spellCheck={false}
style={{
width: "100%",
padding: "9px 11px 9px 33px",
fontSize: 12.5,
fontFamily: "inherit",
borderRadius: 10,
border: `1px solid ${tone(13)}`,
background: tone(5),
color: "inherit",
boxSizing: "border-box",
}}
/>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="list"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: {
duration: reduceMotion ? 0 : cfg.collapse,
ease: "easeOut",
},
opacity: { duration: reduceMotion ? 0.1 : cfg.collapse * 0.7 },
}}
style={{ overflow: "hidden" }}
>
<ul
style={{
listStyle: "none",
margin: "8px 0 0",
padding: 4,
borderRadius: 12,
border: `1px solid ${tone(11)}`,
background: tone(4),
}}
>
{matches.map(({ suggestion, index }, position) => (
// The list empties from the bottom up, so the row being
// chosen is the last one still on screen.
<motion.li
key={suggestion.id}
initial={false}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.1 } }
: {
opacity: 0,
y: -5,
transition: {
duration: 0.16,
ease: "easeIn",
delay:
(matches.length - 1 - position) * cfg.stagger * 0.6,
},
}
}
>
<button
type="button"
onClick={() => choose(index)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "7px 9px",
borderRadius: 9,
border: "none",
background: "transparent",
color: "inherit",
fontFamily: "inherit",
cursor: "pointer",
}}
>
<span
style={{
display: "block",
fontSize: 12.5,
fontWeight: 600,
}}
>
{suggestion.line}
</span>
<span
style={{ display: "block", fontSize: 11, opacity: 0.55 }}
>
{suggestion.context}
</span>
</button>
</motion.li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
<div
style={{
marginTop: 12,
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
}}
>
{FIELD_LABELS.map((label, index) => {
const value = values?.[index] ?? "";
const wide = index === 0;
return (
<div
key={label}
style={{
gridColumn: wide ? "1 / -1" : undefined,
position: "relative",
}}
>
<div style={{ fontSize: 10.5, opacity: 0.5, marginBottom: 4 }}>
{label}
</div>
<div
style={{
position: "relative",
height: 34,
display: "flex",
alignItems: "center",
padding: "0 10px",
borderRadius: 10,
border: `1px solid ${tone(12)}`,
background: tone(4),
overflow: "hidden",
}}
>
{/* The old and new values overlap in one absolutely
positioned slot, so they can crossfade without the row
ever holding two of them side by side. */}
<AnimatePresence initial={false}>
<motion.span
key={value || `${label}-empty`}
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: value ? 1 : 0.32, y: 0 }}
exit={{
opacity: 0,
y: reduceMotion ? 0 : -6,
transition: { duration: 0.16, ease: "easeIn" },
}}
transition={{
...(reduceMotion
? { duration: 0.16, ease: "easeOut" as const }
: cfg.spring),
delay: value ? fieldAt(index) : 0,
opacity: {
duration: 0.2,
ease: "easeOut",
delay: value ? fieldAt(index) : 0,
},
}}
style={{
position: "absolute",
left: 10,
right: 10,
top: 0,
bottom: 0,
display: "flex",
alignItems: "center",
fontSize: 12.5,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontVariantNumeric: "tabular-nums",
}}
>
{value || "—"}
</motion.span>
</AnimatePresence>
{/* A ring rather than an animated border colour: it is a
plain opacity fade, so it cannot be affected by how the
host theme resolves the border. */}
<AnimatePresence>
{value && (
<motion.span
key={`${value}-ring`}
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0] }}
transition={{
duration: reduceMotion ? 0 : cfg.ring,
times: [0, 0.25, 1],
ease: "easeOut",
delay: fieldAt(index),
}}
style={{
position: "absolute",
inset: 0,
borderRadius: 10,
border: `1.5px solid ${ACCENT}`,
pointerEvents: "none",
}}
/>
)}
</AnimatePresence>
</div>
</div>
);
})}
</div>
</div>
);
}About this pattern
The step that decides whether a checkout feels short. Sequence does the work: five fields populating at once looks like a glitch, five populating in reading order looks like the form typing for you. The list empties from the bottom so the chosen row is the last one on screen, each value crossfades inside an absolutely positioned slot so the row never holds two at once, and a ring flashes over the field as its value lands. Values slide a few pixels and fade but never scale — an address that grows into place is an address that is hard to proofread.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Address lookup collapses its suggestion list and populates the remaining fields immediately.
Related patterns
- Cart Quantity StepperStepping the quantity rolls the count and both totals in the direction of the change.
- Cart Badge CountThe cart badge rolls each changed digit in the direction of the change and settles once.
- Gift Card ApplyA single sheen crosses the card while the balance eases down and the amount due settles to zero.