Cart & checkout/block
Delivery options
How it gets there: one ruled list of delivery methods, each with its arrival dates and price. Arrival is a date range from business days and a dispatch cut-off on the shopper's own clock, worked out after hydration so the server never guesses their time zone, with a countdown to the cut-off on the chosen method. Prices are right-aligned tabular figures and a free method says Free. Unavailable methods give their reason in words; loading and empty states included. The heavy rule slides to the chosen method (Motion; a fade under reduced motion).
$ npx shadcn@latest add @ecomcn/delivery-optionsOlder shadcn CLI, or want the registry pinned?
Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonUsage
Rates for the addressimport { DeliveryOptions, type DeliveryOption } from "@/components/ecomcn/delivery-options"
const options: DeliveryOption[] = [
// Business days from dispatch; dispatch is today before the cut-off (shopper's clock).
{ id: "standard", label: "Standard", price: 0, minDays: 3, maxDays: 5, cutoffHour: 15 },
{ id: "express", label: "Express", price: 25, minDays: 1, maxDays: 1, cutoffHour: 14 },
{ id: "courier", label: "Same-day courier", price: 35, minDays: 0, maxDays: 0,
available: false, unavailableReason: "Not available for this postcode" },
]
<DeliveryOptions
options={options}
value={method}
onValueChange={setMethod}
loading={ratesLoading} // skeleton rows while you fetch rates for the address
/>Props
Read straight out of the source at build time, so this cannot drift from the file you install.
DeliveryOptionsPropsoptions: DeliveryOption[]
value: string | null
onValueChange: (id: string) => void
currency?: string
/** `undefined` follows the visitor's locale, once hydrated. */
locale?: string
/** "Delivery", by default. */
legend?: React.ReactNode
/** Keeps the legend for screen readers when your page already has a heading. */
hideLegend?: boolean
/** The radio group's name, for a plain form post. */
name?: string
/** The options aren't known yet — rates are still being fetched for the address. */
loading?: boolean
disabled?: booleanSource
components/ecomcn/delivery-options.tsx"use client"
import * as React from "react"
import { MotionConfig, motion, useReducedMotion } from "motion/react"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
/*
* How it gets there: one ruled list of delivery methods, each with the
* dates it would arrive and what it costs.
*
* <DeliveryOptions options={options} value={method} onValueChange={setMethod} />
*
* Arrival is a date range — "Thu 16 – Mon 20 Oct" — worked out from
* business days and a dispatch cut-off on the shopper's own clock, after
* hydration (the server can't know their time zone); until then each row
* holds a placeholder of the same height. Prices sit right-aligned in
* tabular figures, and a free method says "Free" in words. The heavy rule
* slides to the chosen method (Motion; a fade under reduced motion).
*/
export interface DeliveryOption {
id: string
/** "Standard", "Express", "Collect in store". */
label: string
/** 0 shows "Free". */
price: number
/** Business days from dispatch to the door, at the soonest and the latest. */
minDays: number
maxDays: number
/** Orders before this hour (0–23, the shopper's clock) dispatch the same day. */
cutoffHour?: number
/** Skip Saturdays and Sundays. Defaults to true. */
businessDaysOnly?: boolean
/** "Tracked, signed for", or a collection point's address. */
description?: React.ReactNode
/** False when it can't reach this address. */
available?: boolean
/** Why, in words: "Not available for PO boxes". */
unavailableReason?: string
}
/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */
const MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const
const subscribeNothing = () => () => {}
/** Your locale, or the visitor's once hydrated — so server and client HTML agree. */
function useFormatLocale(locale?: string) {
const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)
return locale ?? (hydrated ? undefined : "en-US")
}
const subscribeMinute = (onChange: () => void) => {
const timer = window.setInterval(onChange, 30_000)
return () => window.clearInterval(timer)
}
const currentMinute = () => Math.floor(Date.now() / 60_000)
const serverMinute = () => null
/** `days` business (or calendar) days after `from`. */
function addDays(from: Date, days: number, businessOnly: boolean) {
const date = new Date(from)
let left = days
while (left > 0) {
date.setDate(date.getDate() + 1)
const day = date.getDay()
if (!businessOnly || (day !== 0 && day !== 6)) left -= 1
}
return date
}
/** When an option would arrive if ordered at `now`, and how long is left to dispatch today. */
export function deliveryWindow(option: DeliveryOption, now: Date) {
const businessOnly = option.businessDaysOnly ?? true
const cutoff = option.cutoffHour
// Past the cut-off, or on a day nothing dispatches: it goes out next working day.
let dispatch = new Date(now)
let minutesLeft: number | null = null
const closed = businessOnly && (now.getDay() === 0 || now.getDay() === 6)
if (closed || (cutoff !== undefined && now.getHours() >= cutoff)) {
dispatch = addDays(now, 1, businessOnly)
} else if (cutoff !== undefined) {
minutesLeft = cutoff * 60 - (now.getHours() * 60 + now.getMinutes())
}
return {
first: addDays(dispatch, option.minDays, businessOnly),
last: addDays(dispatch, option.maxDays, businessOnly),
minutesLeft,
}
}
export interface DeliveryOptionsProps
extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, "onChange" | "defaultValue" | "name"> {
options: DeliveryOption[]
value: string | null
onValueChange: (id: string) => void
currency?: string
/** `undefined` follows the visitor's locale, once hydrated. */
locale?: string
/** "Delivery", by default. */
legend?: React.ReactNode
/** Keeps the legend for screen readers when your page already has a heading. */
hideLegend?: boolean
/** The radio group's name, for a plain form post. */
name?: string
/** The options aren't known yet — rates are still being fetched for the address. */
loading?: boolean
disabled?: boolean
}
export function DeliveryOptions({
options,
value,
onValueChange,
currency = "USD",
locale: localeProp,
legend = "Delivery",
hideLegend = false,
name,
loading = false,
disabled = false,
className,
...props
}: DeliveryOptionsProps) {
const locale = useFormatLocale(localeProp)
const reduce = useReducedMotion()
const uid = React.useId()
const minute = React.useSyncExternalStore(subscribeMinute, currentMinute, serverMinute)
const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency])
const dates = React.useMemo(
() => new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short" }),
[locale]
)
return (
<MotionConfig reducedMotion="user">
<fieldset data-slot="delivery-options" aria-busy={loading || undefined} className={cn("min-w-0", className)} {...props}>
<legend id={`${uid}-legend`} className={cn("mb-3 text-base font-medium", hideLegend && "sr-only")}>
{legend}
</legend>
{loading ? (
<div data-slot="delivery-options-skeleton" className="border">
<span className="sr-only" role="status">
Loading delivery options
</span>
{[0, 1, 2].map((row) => (
<div key={row} aria-hidden className="flex min-h-16 items-center gap-4 border-t px-4 py-3.5 first:border-t-0">
<Skeleton className="size-4 rounded-full" />
<span className="flex flex-1 flex-col gap-2">
<Skeleton className="h-3.5 w-28 rounded-none" />
<Skeleton className="h-3 w-44 rounded-none" />
</span>
<Skeleton className="h-3.5 w-12 rounded-none" />
</div>
))}
</div>
) : options.length === 0 ? (
<p data-slot="delivery-options-empty" className="border px-4 py-5 text-sm text-muted-foreground">
Nothing can be delivered to this address yet. Check the address, or contact us.
</p>
) : (
<RadioGroup
value={value ?? ""}
onValueChange={(next: unknown) => onValueChange(String(next))}
name={name}
disabled={disabled}
aria-labelledby={`${uid}-legend`}
className="gap-0 border"
>
{options.map((option) => {
const selected = value === option.id
const unavailable = option.available === false
const when = minute === null || unavailable ? null : deliveryWindow(option, new Date(minute * 60_000))
const arrival = when
? option.minDays === option.maxDays
? dates.format(when.first)
: dates.formatRange(when.first, when.last)
: null
const left = selected && when?.minutesLeft != null ? when.minutesLeft : null
const noteId = `${uid}-${option.id}-note`
return (
<label
key={option.id}
data-slot="delivery-option"
data-state={selected ? "checked" : "unchecked"}
data-unavailable={unavailable || undefined}
className={cn(
"relative flex min-h-16 cursor-pointer items-center gap-4 border-t px-4 py-3.5 transition-colors first:border-t-0",
selected ? "bg-secondary/50" : "hover:bg-secondary/30",
(unavailable || disabled) && "cursor-not-allowed hover:bg-transparent"
)}
>
{selected ? (
<motion.span
aria-hidden
layoutId={reduce ? undefined : `${uid}-rule`}
transition={MORPH}
className="absolute inset-y-0 -left-px w-0.5 bg-foreground"
/>
) : null}
<RadioGroupItem value={option.id} disabled={unavailable || disabled} aria-describedby={noteId} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className={cn("text-sm font-medium", unavailable && "text-muted-foreground line-through decoration-1")}>
{option.label}
</span>
<span id={noteId} className="flex flex-col text-[13px] leading-snug text-muted-foreground">
{unavailable ? (
(option.unavailableReason ?? "Not available for this address")
) : arrival ? (
<span>
Arrives <span className="text-foreground tabular-nums">{arrival}</span>
</span>
) : (
// The shopper's dates arrive with hydration; hold the line's height till then.
<span aria-hidden className="my-1 h-3 w-40 animate-pulse bg-muted" />
)}
{left !== null ? (
<span className="tabular-nums">
Order within {Math.floor(left / 60)}h {String(left % 60).padStart(2, "0")}m for dispatch today
</span>
) : null}
{!unavailable && option.description ? <span>{option.description}</span> : null}
</span>
</span>
<span className={cn("shrink-0 text-sm tabular-nums", unavailable && "text-muted-foreground")}>
{option.price === 0 ? "Free" : money.format(option.price)}
</span>
</label>
)
})}
</RadioGroup>
)}
</fieldset>
</MotionConfig>
)
}