Patterns
PatternsTier 5 · Blocks
Address card
A labelled delivery address with default and edit affordances.
bunx --bun shadcn@latest add @woohl/address-cardPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/address-cardregistry/tier-5-blocks/address-card.tsx
import type { ReactNode, ReactElement } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { Badge } from "@/registry/tier-3-components/badge";
import { focusRing } from "@/registry/tier-2-primitives/lib/cva";
/**
* AddressCard.
*
* A delivery address, as a selectable card in checkout or a read-only card in
* an account page.
*
* INDIAN ADDRESSES, IN THE INDIAN ORDER
* Flat/house, street/area, landmark, city, state, PIN. `landmark` is a first
* class field rather than an afterthought crammed into line two — outside the
* metros it is often how a delivery is actually found, and a form that treats
* it as optional garnish produces addresses that don't work.
*
* SELECTABLE MEANS A RADIO
* When `selectable`, the card renders a real `<input type="radio">` inside a
* `<label>`. A div with a click handler and a coloured border is not
* selectable to a keyboard or a screen reader, however much it looks it.
*/
export type Address = {
id: string;
/** "Home", "Office", "Mum's place". */
label?: string;
name: string;
line1: string;
line2?: string;
landmark?: string;
city: string;
state: string;
pincode: string;
phone?: string;
isDefault?: boolean;
};
export type AddressCardProps = {
/** Optional mark beside the phone number: `phoneIcon={<Icon name="phone" />}`. */
phoneIcon?: ReactElement;
address: Address;
selectable?: boolean;
selected?: boolean;
onSelect?: () => void;
/** Radio group name. Required when `selectable`. */
name?: string;
/** Edit / Delete, usually. */
actions?: ReactNode;
className?: string;
};
export function AddressCard({
phoneIcon,
address,
selectable = false,
selected = false,
onSelect,
name = "address",
actions,
className,
}: AddressCardProps) {
const body = (
<>
<div className="flex flex-wrap items-center gap-2">
{address.label && (
<span className="text-body text-text-primary font-bold">
{address.label}
</span>
)}
{address.isDefault && (
<Badge tone="neutral" size="sm">
Default
</Badge>
)}
</div>
<address className="text-body text-text-secondary mt-1 leading-[1.6] not-italic">
<span className="text-text-primary font-semibold">{address.name}</span>
<br />
{address.line1}
{address.line2 && (
<>
<br />
{address.line2}
</>
)}
{address.landmark && (
<>
<br />
<span className="text-text-muted">Near {address.landmark}</span>
</>
)}
<br />
{address.city}, {address.state} {address.pincode}
{address.phone && (
<>
<br />
<span className="text-text-muted inline-flex items-center gap-1.5">
{phoneIcon && (
<span
aria-hidden
className="grid flex-none place-items-center [&>svg]:size-[13px]"
>
{phoneIcon}
</span>
)}
{address.phone}
</span>
</>
)}
</address>
</>
);
if (!selectable) {
return (
<div
data-slot="address-card"
className={cn(
"border-border-subtle bg-surface-raised font-ui rounded-xl border p-5",
className,
)}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">{body}</div>
{actions && <div className="flex flex-none gap-1">{actions}</div>}
</div>
</div>
);
}
return (
<label
data-slot="address-card"
className={cn(
"font-ui flex cursor-pointer gap-3 rounded-xl border p-5",
"motion-instant transition-[border-color,background-color]",
selected
? "border-border-accent bg-surface-accent-subtle"
: "border-border-subtle bg-surface-raised hover:border-border-strong",
"has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-[var(--focus-ring)]",
className,
)}
>
<span className="relative mt-0.5 inline-grid place-items-center">
<input
type="radio"
name={name}
value={address.id}
checked={selected}
onChange={() => onSelect?.()}
className={cn(
"peer absolute size-full cursor-pointer opacity-0",
focusRing,
)}
/>
<span
aria-hidden
className={cn(
"grid size-[19px] flex-none place-items-center rounded-full border-[1.5px]",
"border-border-strong bg-surface-raised motion-instant transition-[border-color]",
"peer-checked:border-surface-accent peer-checked:[&>span]:scale-100",
)}
>
<span className="bg-surface-accent motion-instant size-[9px] scale-0 rounded-full transition-transform" />
</span>
</span>
<span className="min-w-0 flex-1">{body}</span>
{actions && <span className="flex flex-none gap-1">{actions}</span>}
</label>
);
}
AddressCardProps
| Prop | Type | Default |
|---|---|---|
address* | Address | — |
actionsEdit / Delete, usually. | ReactNode | — |
className | string | — |
nameRadio group name. Required when `selectable`. | string | — |
onSelect | () => void | — |
phoneIconOptional mark beside the phone number: `phoneIcon={<Icon name="phone" />}`. | ReactElement | — |
selectable | boolean | — |
selected | boolean | — |
Registry items
npm packages
None beyond React itself.