Address form

Shipping address with blur validation, an address combobox that fills city, state and ZIP, and a save checkbox.

componentaddressanycheckoutaddressautocompletevalidation
$npx shadcn@latest add @storekit/checkout-address
checkout-address

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/checkout-address
$pnpm dlx shadcn@latest add @storekit/checkout-address
$yarn dlx shadcn@latest add @storekit/checkout-address
$bunx --bun shadcn@latest add @storekit/checkout-address

Source

components/storekit/checkout/checkout-address.tsx
296 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, ChevronDown, MapPin } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Input } from "@/components/storekit/ui/input"

/* Shipping address form. Checks a field's format when you leave it (empty
   fields are only flagged on submit, so tabbing through never paints the
   form red), moves focus to the first problem on submit, and has an address
   combobox whose suggestion fills city, state and ZIP. Swap `suggest` for
   your places API. */

export type Address = {
  email: string
  firstName: string
  lastName: string
  address1: string
  address2: string
  city: string
  state: string
  zip: string
  phone: string
  save: boolean
}

type Suggestion = { line: string; city: string; state: string; zip: string }

const SAMPLE_SUGGESTIONS: Suggestion[] = [
  { line: "12 Elmwood Avenue", city: "Buffalo", state: "NY", zip: "14201" },
  { line: "120 Delaware Avenue", city: "Buffalo", state: "NY", zip: "14202" },
  { line: "1275 Market Street", city: "San Francisco", state: "CA", zip: "94103" },
  { line: "450 Brannan Street", city: "San Francisco", state: "CA", zip: "94107" },
  { line: "88 Kendall Street", city: "Cambridge", state: "MA", zip: "02142" },
]

const defaultSuggest = (q: string) => {
  const s = q.trim().toLowerCase()
  if (s.length < 2) return []
  return SAMPLE_SUGGESTIONS.filter((x) => x.line.toLowerCase().startsWith(s)).slice(0, 3)
}

const STATES = ["AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA","KS","KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ","NM","NY","NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT","VT","VA","WA","WV","WI","WY"]

type Field = Exclude<keyof Address, "save" | "address2">

function validate(f: Field, a: Address): string | undefined {
  const v = a[f].trim()
  switch (f) {
    case "email":
      return !v ? "Enter an email." : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? undefined : "Enter a valid email."
    case "firstName":
      return v ? undefined : "Enter a first name."
    case "lastName":
      return v ? undefined : "Enter a last name."
    case "address1":
      return v ? undefined : "Enter an address."
    case "city":
      return v ? undefined : "Enter a city."
    case "state":
      return v ? undefined : "Choose a state."
    case "zip":
      return /^\d{5}(-\d{4})?$/.test(v) ? undefined : "Enter a 5-digit ZIP."
    case "phone":
      return v.replace(/\D/g, "").length >= 10 ? undefined : "Enter a 10-digit phone."
  }
}

const FIELDS: Field[] = ["email", "firstName", "lastName", "address1", "city", "state", "zip", "phone"]
const EMPTY: Address = { email: "", firstName: "", lastName: "", address1: "", address2: "", city: "", state: "", zip: "", phone: "", save: true }

export function CheckoutAddress({
  defaultValue,
  suggest = defaultSuggest,
  submitLabel = "Continue to shipping",
  onSubmit,
  className,
}: {
  defaultValue?: Partial<Address>
  suggest?: (query: string) => Suggestion[]
  submitLabel?: string
  onSubmit?: (address: Address) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const [a, setA] = React.useState<Address>({ ...EMPTY, ...defaultValue })
  const [errors, setErrors] = React.useState<Partial<Record<Field, string>>>({})
  const [listOpen, setListOpen] = React.useState(false)
  const [active, setActive] = React.useState(0)
  const [done, setDone] = React.useState(false)
  const [submitted, setSubmitted] = React.useState(false)
  const formRef = React.useRef<HTMLFormElement>(null)
  const listId = React.useId()
  const stateId = React.useId()
  const results = suggest(a.address1)
  const showList = listOpen && results.length > 0

  const set = <K extends keyof Address>(k: K, v: Address[K]) => {
    setA((p) => ({ ...p, [k]: v }))
    if (k in errors) setErrors((e) => ({ ...e, [k]: undefined }))
  }
  const blur = (f: Field) => {
    if (!submitted && !a[f].trim()) return
    setErrors((e) => ({ ...e, [f]: validate(f, a) }))
  }

  const pick = (s: Suggestion) => {
    setA((p) => ({ ...p, address1: s.line, city: s.city, state: s.state, zip: s.zip }))
    setErrors((e) => ({ ...e, address1: undefined, city: undefined, state: undefined, zip: undefined }))
    setListOpen(false)
  }

  const onComboKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (!showList) return
    if (e.key === "ArrowDown") {
      e.preventDefault()
      setActive((i) => (i + 1) % results.length)
    } else if (e.key === "ArrowUp") {
      e.preventDefault()
      setActive((i) => (i - 1 + results.length) % results.length)
    } else if (e.key === "Enter") {
      e.preventDefault()
      pick(results[active])
    } else if (e.key === "Escape") {
      setListOpen(false)
    }
  }

  const submit = (e: React.FormEvent) => {
    e.preventDefault()
    const next: Partial<Record<Field, string>> = {}
    for (const f of FIELDS) next[f] = validate(f, a)
    setErrors(next)
    setSubmitted(true)
    const firstBad = FIELDS.find((f) => next[f])
    if (firstBad) {
      formRef.current?.querySelector<HTMLElement>(`[name="${firstBad}"]`)?.focus()
      return
    }
    setDone(true)
    onSubmit?.(a)
    window.setTimeout(() => setDone(false), 1200)
  }

  const field = (f: Field) => ({
    name: f,
    value: a[f],
    error: errors[f],
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => set(f, e.target.value),
    onBlur: () => blur(f),
  })

  return (
    <form ref={formRef} noValidate onSubmit={submit} data-sk-section="checkout" data-sk-variant="checkout-address" className={cn("grid w-full grid-cols-1 gap-4", className)}>
      <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Contact</p>
      <Input label="Email" type="email" autoComplete="email" placeholder="you@example.com" {...field("email")} />

      <p className="mt-2 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Shipping address</p>
      <div className="grid gap-4 sm:grid-cols-2">
        <Input label="First name" autoComplete="given-name" {...field("firstName")} />
        <Input label="Last name" autoComplete="family-name" {...field("lastName")} />
      </div>

      <div className="relative">
        <Input
          label="Address"
          placeholder="Start typing, try 12"
          role="combobox"
          aria-autocomplete="list"
          aria-expanded={showList}
          aria-controls={listId}
          aria-activedescendant={showList ? `${listId}-${active}` : undefined}
          name="address1"
          autoComplete="address-line1"
          value={a.address1}
          error={errors.address1}
          onChange={(e) => {
            set("address1", e.target.value)
            setListOpen(true)
            setActive(0)
          }}
          onKeyDown={onComboKey}
          onFocus={() => setListOpen(true)}
          onBlur={() => {
            // Let a click on an option land before closing.
            window.setTimeout(() => setListOpen(false), 120)
            blur("address1")
          }}
        />
        <AnimatePresence>
          {showList ? (
            <motion.ul
              id={listId}
              role="listbox"
              aria-label="Address suggestions"
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, y: -4 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
              style={{ transformOrigin: "top" }}
              className="absolute inset-x-0 top-[calc(100%+4px)] z-20 overflow-hidden rounded-sk-card border border-sk-line bg-sk-card p-1 shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)]"
            >
              {results.map((s, i) => (
                <li
                  key={s.line}
                  id={`${listId}-${i}`}
                  role="option"
                  aria-selected={i === active}
                  onPointerDown={(e) => e.preventDefault()}
                  onClick={() => pick(s)}
                  onMouseEnter={() => setActive(i)}
                  className={cn("flex min-h-11 cursor-pointer items-center gap-3 rounded-sk-btn px-3 text-sm", i === active && "bg-sk-ink/[0.05]")}
                >
                  <MapPin size={14} className="shrink-0 text-sk-muted" />
                  <span className="min-w-0 truncate">
                    <span className="font-medium text-sk-ink">{s.line}</span>
                    <span className="text-sk-muted">, {s.city}, {s.state} {s.zip}</span>
                  </span>
                </li>
              ))}
            </motion.ul>
          ) : null}
        </AnimatePresence>
      </div>

      <Input label="Apartment, suite (optional)" autoComplete="address-line2" value={a.address2} onChange={(e) => set("address2", e.target.value)} />

      <div className="grid grid-cols-2 gap-4 sm:grid-cols-[1.4fr_1fr_1fr]">
        <div className="col-span-2 sm:col-span-1">
          <Input label="City" autoComplete="address-level2" {...field("city")} />
        </div>
        <label htmlFor={stateId} className="block">
          <span className="mb-1.5 block text-[13px] font-medium text-sk-ink">State</span>
          <span className="relative block">
            <select
              id={stateId}
              name="state"
              autoComplete="address-level1"
              aria-describedby={errors.state ? `${stateId}-err` : undefined}
              value={a.state}
              aria-invalid={!!errors.state || undefined}
              onChange={(e) => set("state", e.target.value)}
              onBlur={() => blur("state")}
              className={cn(
                "h-11 w-full appearance-none rounded-sk-btn border bg-sk-card pl-3.5 pr-9 text-sm text-sk-ink outline-none transition-[border-color,box-shadow] duration-200",
                "focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15",
                errors.state ? "border-sk-sale" : "border-sk-line",
                !a.state && "text-sk-muted"
              )}
            >
              <option value="">Select</option>
              {STATES.map((s) => (
                <option key={s} value={s}>{s}</option>
              ))}
            </select>
            <ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-sk-muted" />
          </span>
          {errors.state ? <span id={`${stateId}-err`} role="alert" className="mt-1.5 block text-xs text-sk-sale">{errors.state}</span> : null}
        </label>
        <Input label="ZIP" inputMode="numeric" autoComplete="postal-code" maxLength={10} {...field("zip")} />
      </div>

      <Input label="Phone" type="tel" autoComplete="tel" hint="For delivery updates only." {...field("phone")} />

      <label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm">
        <span className="relative grid h-5 w-5 shrink-0 place-items-center">
          <input
            type="checkbox"
            checked={a.save}
            onChange={(e) => set("save", e.target.checked)}
            className={cn(
              "peer h-5 w-5 cursor-pointer appearance-none rounded-[min(var(--sk-radius-btn),6px)] border border-sk-line bg-sk-card transition-colors duration-200 checked:border-sk-accent checked:bg-sk-accent",
              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
            )}
          />
          <Check size={13} strokeWidth={3} className="pointer-events-none absolute text-sk-accent-ink opacity-0 transition-opacity duration-150 peer-checked:opacity-100" />
        </span>
        Save this address for next time
      </label>

      <Button type="submit" size="lg" className="mt-2 w-full sm:w-auto sm:justify-self-start">
        {done ? (
          <>
            <Check size={16} />
            Saved
          </>
        ) : (
          submitLabel
        )}
      </Button>
    </form>
  )
}

Usage

Pass suggest(query) to wire a real places API. Try typing 12 in the demo.

Address form, StoreKit