Shipping method
Radio cards with a gliding accent frame and delivery dates computed from today in business days.
componentradio-cardsanycheckoutshipping-methoddelivery-estimate
$
npx shadcn@latest add @storekit/checkout-shipping-methodcheckout-shipping-method
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-shipping-method$
pnpm dlx shadcn@latest add @storekit/checkout-shipping-method$
yarn dlx shadcn@latest add @storekit/checkout-shipping-method$
bunx --bun shadcn@latest add @storekit/checkout-shipping-methodSource
components/storekit/checkout/checkout-shipping-method.tsx
115 lines
"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Shipping method radio cards. Native radios (arrow keys work), an accent
frame that glides to the selected card via layoutId, and delivery dates
computed from today in business days. */
type Method = {
id: string
label: string
/** In cents. 0 renders "Free". */
price: number
/** Business days, [min, max]. */
days: [number, number]
note: string
}
const METHODS: Method[] = [
{ id: "standard", label: "Standard", price: 0, days: [3, 5], note: "3 to 5 business days" },
{ id: "express", label: "Express", price: 1200, days: [1, 2], note: "1 to 2 business days" },
{ id: "overnight", label: "Overnight", price: 2800, days: [1, 1], note: "Next business day by 10am" },
]
function addBusinessDays(from: Date, n: number) {
const d = new Date(from)
let left = n
while (left > 0) {
d.setDate(d.getDate() + 1)
const day = d.getDay()
if (day !== 0 && day !== 6) left--
}
return d
}
const fmt = (d: Date) => d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
function estimate(m: Method, today: Date) {
const a = addBusinessDays(today, m.days[0])
if (m.days[0] === m.days[1]) return `Arrives ${fmt(a)}${m.id === "overnight" ? " by 10am" : ""}`
return `Arrives ${fmt(a)} to ${fmt(addBusinessDays(today, m.days[1]))}`
}
export function CheckoutShippingMethod({
methods = METHODS,
defaultValue = "standard",
onChange,
className,
}: {
methods?: Method[]
defaultValue?: string
onChange?: (id: string) => void
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("checkout", "checkout-shipping-method")
const [value, setValue] = React.useState(defaultValue)
const [today, setToday] = React.useState<Date | null>(null)
const uid = React.useId()
// Computed after mount so server and client agree on the markup.
React.useEffect(() => setToday(new Date()), [])
return (
<fieldset ref={sk.ref as React.Ref<HTMLFieldSetElement>} {...sk.attrs} className={cn("w-full", className)}>
<legend className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Shipping method</legend>
<div className="mt-3 grid gap-2">
{methods.map((m) => {
const selected = value === m.id
return (
<label
key={m.id}
className={cn(
"relative flex min-h-[64px] cursor-pointer items-center gap-4 rounded-sk-card border border-sk-line bg-sk-card px-4 py-3 transition-colors duration-200",
"hover:bg-sk-ink/[0.02] has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-sk-accent has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-sk-paper"
)}
>
<input
type="radio"
name={uid}
value={m.id}
checked={selected}
onChange={() => {
setValue(m.id)
sk.emit("interact", `shipping_${m.id}`, m.price)
onChange?.(m.id)
}}
className="sr-only"
/>
{selected ? (
<motion.span
layoutId={`${uid}-frame`}
aria-hidden
className="pointer-events-none absolute -inset-px rounded-sk-card border-2 border-sk-accent"
transition={{ duration: reduce ? 0 : 0.35, ease: [0.16, 1, 0.3, 1] }}
/>
) : null}
<span aria-hidden className={cn("grid h-5 w-5 shrink-0 place-items-center rounded-full border transition-colors duration-200", selected ? "border-sk-accent" : "border-sk-muted/50")}>
<span className={cn("h-2.5 w-2.5 rounded-full bg-sk-accent transition-[transform,opacity] duration-200", selected ? "scale-100 opacity-100" : "scale-50 opacity-0")} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-sk-ink">{m.label}</span>
<span className="block text-[13px] text-sk-muted">{today ? estimate(m, today) : m.note}</span>
</span>
<span className={cn("text-sm font-medium tabular-nums", m.price === 0 ? "text-sk-savings" : "text-sk-price")}>{m.price === 0 ? "Free" : money(m.price)}</span>
</label>
)
})}
</div>
</fieldset>
)
}
Usage
Import from "@/components/storekit/checkout/checkout-shipping-method" and pass your product data. The demo above exercises the full prop surface with sample values.