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-method
checkout-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-method

Source

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.

Shipping method, StoreKit