All patterns

Address Autocomplete

Choosing a suggestion closes the list from the bottom up and fills the fields in reading order.

commerceminimalfriendlyinteraction · finite · intermediate · ~0.7s
Interactive · click to play
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.

401 lines · react + motion only
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.

Checkout address entryShipping details formBilling address lookupStore locator field

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    Address lookup collapses its suggestion list and populates the remaining fields immediately.

Related patterns