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-addresscheckout-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-addressSource
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.