HextaUI

Address Form

A shipping or billing address form that changes with the country. Handles address search, postal codes, saved addresses and error summaries.

Open in new tab

Address Form asks for an address the way the shopper’s country writes it. A US address gets City, State and ZIP code on one row, the UK gets Town or city and Postcode with no state, Germany puts the postal code before the city, and Japan starts with the postal code and prefecture. Sixteen countries have their own format with labels, required fields, region lists and postal rules, and every other country gets a plain fallback.

Fewer fields come first. Full name is one field, Company and Apartment sit behind small links, and the phone is optional with the reason under it. Every input has the right autocomplete token with a shipping or billing prefix, the right keyboard on phones and a name, so browser autofill fills the whole form at once, including the hidden apartment field.

Errors show when you leave a field, then update as you type until it’s fixed, and they say what’s wrong: “Enter a 5-digit ZIP code”, “Enter a postcode like SW1A 1AA”. validate() lists every problem in a summary at the top with links to the fields and moves focus there. Switching country keeps what still fits, clears regions that don’t, and re-checks only fields you’ve already visited, while the form’s height animates to the new layout.

Pass onSearchAddress and onResolveAddress to suggest addresses as the shopper types, with Enter address manually always at the end of the list. onLookupPostalCode fills city and region from a postal code without overwriting anything typed. SavedAddresses lists a signed-in shopper’s addresses with a Default badge, and AddressSummary is the one-line Ship to row for a finished checkout step.

  1. Obtén HextaUI Pro

    Un solo pago desbloquea todos los bloques, incluidos los futuros. Ver precios.

  2. Añade el registro Pro a components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Añade tu token

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  4. Añade el bloque

    pnpm dlx shadcn@latest add @hextaui-pro/address-form

Address search with Google Places

onSearchAddress returns suggestions for what’s typed and onResolveAddress turns the picked one into an Address. The same two calls map onto Mapbox Search Box’s /suggest and /retrieve. Both get an AbortSignal, so stale requests are cancelled.

"use client"

import type { Address } from "@/components/blocks/address-form/address"
import { AddressForm } from "@/components/blocks/address-form/address-form"
import type { AddressSuggestion } from "@/components/blocks/address-form/address-search"

const key = process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY ?? ""

type Prediction = {
  placePrediction: {
    placeId: string
    structuredFormat: {
      mainText: { text: string }
      secondaryText?: { text: string }
    }
  }
}

type Component = { longText: string; shortText: string; types: string[] }

async function searchAddresses(
  query: string,
  { country, signal }: { country: string; signal: AbortSignal }
): Promise<AddressSuggestion[]> {
  const response = await fetch(
    "https://places.googleapis.com/v1/places:autocomplete",
    {
      method: "POST",
      signal,
      headers: { "Content-Type": "application/json", "X-Goog-Api-Key": key },
      body: JSON.stringify({
        input: query,
        includedRegionCodes: [country.toLowerCase()],
        includedPrimaryTypes: ["street_address", "premise", "subpremise"],
      }),
    }
  )
  if (!response.ok) throw new Error("Address search failed")
  const { suggestions = [] } = (await response.json()) as {
    suggestions?: Prediction[]
  }
  return suggestions.map(({ placePrediction }) => ({
    id: placePrediction.placeId,
    title: placePrediction.structuredFormat.mainText.text,
    description: placePrediction.structuredFormat.secondaryText?.text,
  }))
}

async function resolveAddress(
  id: string,
  { signal }: { signal: AbortSignal }
): Promise<Partial<Address>> {
  const response = await fetch(`https://places.googleapis.com/v1/places/${id}`, {
    signal,
    headers: {
      "X-Goog-Api-Key": key,
      "X-Goog-FieldMask": "addressComponents",
    },
  })
  if (!response.ok) throw new Error("We couldn’t load that address. Enter the rest below.")
  const { addressComponents = [] } = (await response.json()) as {
    addressComponents?: Component[]
  }
  const find = (type: string, short = false) => {
    const part = addressComponents.find((item) => item.types.includes(type))
    return short ? part?.shortText : part?.longText
  }
  return {
    line1: [find("street_number"), find("route")].filter(Boolean).join(" "),
    line2: find("subpremise"),
    city: find("locality") ?? find("postal_town") ?? find("sublocality"),
    region: find("administrative_area_level_1", true),
    postalCode: find("postal_code"),
    country: find("country", true),
  }
}

export function ShippingAddress() {
  return (
    <AddressForm
      defaultCountry="US"
      onSearchAddress={searchAddresses}
      onResolveAddress={resolveAddress}
    />
  )
}

Save to a Shopify customer account

validate() returns the errors and focuses the summary, so saving only runs on a complete address. The Address maps to customerAddressCreate field by field, and a userError from Shopify shows under the form.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"

import type { Address } from "@/components/blocks/address-form/address"
import { AddressForm, type AddressFormActions } from "@/components/blocks/address-form/address-form"

const mutation = `
  mutation customerAddressCreate($address: CustomerAddressInput!) {
    customerAddressCreate(address: $address, defaultAddress: false) {
      customerAddress { id }
      userErrors { field message }
    }
  }
`

function toShopify(address: Address) {
  const [firstName, ...rest] = address.name.trim().split(/\s+/)
  return {
    firstName,
    lastName: rest.join(" "),
    company: address.company,
    address1: address.line1,
    address2: address.line2,
    city: address.city,
    zoneCode: address.region,
    zip: address.postalCode,
    territoryCode: address.country,
    phoneNumber: address.phone,
  }
}

async function saveAddress(address: Address) {
  const response = await fetch("/api/customer-account/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: mutation,
      variables: { address: toShopify(address) },
    }),
  })
  const { data } = (await response.json()) as {
    data: {
      customerAddressCreate: { userErrors: { message: string }[] }
    }
  }
  const [error] = data.customerAddressCreate.userErrors
  if (error) throw new Error(error.message)
}

export function AddAddress({ onSaved }: { onSaved: () => void }) {
  const formRef = React.useRef<AddressFormActions>(null)
  const [address, setAddress] = React.useState<Address>()
  const [error, setError] = React.useState<string | null>(null)

  const save = async () => {
    if (!address || formRef.current?.validate().length) return
    setError(null)
    try {
      await saveAddress(address)
      onSaved()
    } catch (reason) {
      setError(reason instanceof Error ? reason.message : String(reason))
      throw reason
    }
  }

  return (
    <div className="flex flex-col gap-6">
      <AddressForm
        actionsRef={formRef}
        onValueChange={setAddress}
        phone="required"
      />
      {error ? (
        <p role="alert" className="text-sm text-destructive">
          {error}
        </p>
      ) : null}
      <Button feedback successLabel="Saved" onClick={save}>
        Save address
      </Button>
    </div>
  )
}

A billing section

Billing defaults to the shipping address and shows it as one line. Unticking the box opens a second form with section="billing", so browsers autofill billing tokens there instead of shipping ones.

"use client"

import * as React from "react"

import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel } from "@/components/ui/field"

import type { Address } from "@/components/blocks/address-form/address"
import { AddressForm } from "@/components/blocks/address-form/address-form"
import { AddressSummary } from "@/components/blocks/address-form/address-summary"
import { Reveal } from "@/components/blocks/address-form/reveal"

export function BillingAddress({
  shipping,
  onBillingChange,
}: {
  shipping: Address
  onBillingChange: (billing: Address | null) => void
}) {
  const [same, setSame] = React.useState(true)

  return (
    <div className="flex flex-col">
      <Field orientation="horizontal">
        <Checkbox
          id="billing-same"
          checked={same}
          onCheckedChange={(checked) => {
            setSame(checked === true)
            if (checked === true) onBillingChange(null)
          }}
        />
        <FieldLabel htmlFor="billing-same">
          Billing address is the same as shipping
        </FieldLabel>
      </Field>
      {same ? (
        <AddressSummary
          className="mt-3"
          label="Bill to"
          address={shipping}
        />
      ) : null}
      <Reveal open={!same} inert>
        <AddressForm
          section="billing"
          phone="hidden"
          company={false}
          defaultCountry={shipping.country}
          onValueChange={onBillingChange}
        />
      </Reveal>
    </div>
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
AddressFormThe country-aware fields, the error summary and the address search.
SavedAddressesSaved addresses as radio cards, with Use a new address revealing the form.
AddressSummaryThe completed-step row: Ship to, the address on one line and Change.
getCountryFormat, validateAddress, formatAddressLinesCountry rules, validation and formatting that also work on the server.

AddressForm

Also takes div props.

PropTipoPredeterminado
valueThe address when controlled.
Address–
defaultValueThe starting address when uncontrolled.
Partial<Address>–
onValueChangeCalled on every change. Regions are codes for countries with a list, such as CA or JP-13.
(address: Address) => void–
sectionPrefix for autocomplete tokens and field names.
"shipping" | "billing""shipping"
countriesISO codes shoppers can pick, sorted by name. Defaults to every country.
string[]–
defaultCountryThe starting country when the value has none.
string"US"
phoneWhether to ask for a phone number.
"optional" | "required" | "hidden""optional"
companyOffer Add company.
booleantrue
onSearchAddressSuggest addresses as the shopper types three or more characters. Throwing shows that search isn’t available.
(query, { country, signal }) => Promise<AddressSuggestion[]>–
onResolveAddressTurn a picked suggestion into an address. A different country switches the form. Throw to show your message under the field.
(id, { signal }) => Promise<Partial<Address>>–
onLookupPostalCodeFill empty city and region fields from a valid postal code.
(postalCode, { country, signal }) => Promise<{ city?; region? } | null>–
actionsRefvalidate() returns { field, message }[] and shows the summary, focus(field), setCountry(code) and reset(address).
Ref<AddressFormActions>–
labelsEvery word the form shows or announces.
Partial<AddressFormLabels>–
localeLocale for country names and sorting.
Intl.LocalesArgument"en-US"
disabledDisable every field.
boolean–

SavedAddresses

Also takes div props.

PropTipoPredeterminado
addressesSaved addresses in display order.
(Address & { id: string; isDefault?: boolean })[]–
valueThe chosen id, or "new".
string–
defaultValueStarts on the default address, the first one, or "new" when there are none.
string–
onValueChangeCalled when the choice changes.
(value: string, address?: SavedAddress) => void–
onEditShows an Edit button on each card.
(address: SavedAddress) => void–
allowNewShow Use a new address.
booleantrue
childrenShown under the list while Use a new address is chosen, usually an AddressForm.
ReactNode–
labelsGroup name, Default, new address and edit wording.
Partial<SavedAddressesLabels>–

AddressSummary

Also takes div props.

PropTipoPredeterminado
addressThe address to show on one line.
Address–
labelThe row label.
string"Ship to"
onChangeShows Change.
() => void–
countryAdd the country name.
booleanfalse
PropTipoPredeterminado
idPassed to onResolveAddress.
string–
titleThe street line, shown first and put in the field when picked.
string–
descriptionThe rest, such as San Francisco, CA 94105.
string–
addressUsed directly when there’s no onResolveAddress.
Partial<Address>–
KeyAcción
ArrowDownArrowUpMoves through country or address suggestions.
EnterPicks the highlighted country or address.
EscapeCloses the suggestions and keeps what you typed.
TabMoves through the fields in the country’s order. Hidden company and apartment fields are skipped until opened.
  • Every field has a visible label, and optional fields say Optional. Errors are linked with aria-invalid and aria-describedby and use text next to the red outline, never color alone.
  • validate() moves focus to a summary titled There is a problem. Each item is a link worded like the inline error that focuses its field.
  • Address search is an ARIA combobox. A polite live region says how many addresses were found, when the address was filled in, when a postal code filled the city and when the fields changed for a new country.
  • Reveals and the country reflow animate height and fade, and turn off under reduced motion. Text fields use 16px text and a 48px height on touch screens.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Address Form. Cada uno se instala por separado.

Código

11 archivos, añadidos a components/blocks/address-form.