HextaUI

Shipping Methods

Delivery options for checkout and delivery dates for product pages. Handles order cutoffs, store pickup and rates that change with the address.

Open in new tab

ShippingMethods is the delivery step of a checkout. Each option is a radio card that leads with the date it arrives, “Mon, Oct 26 – Wed, Oct 28” or “Tomorrow”, then the method and carrier, with the price on the right. Shoppers compare dates more easily than “3–5 business days”, so the block works out real dates from business days, skipping weekends and any holidays you pass.

A rate can have an order cutoff. Before it, the card says “Order within 3 h 12 min for delivery tomorrow”, updated once a minute and never ticking seconds. When the cutoff passes, the estimate moves to the next business day on its own. The fastest paid option gets a small badge, and the cheapest one does too when it isn’t already free.

Pickup options sit behind a Ship / Pick up switch that only appears when both exist. A pickup card shows when the order is ready, “Ready today after 2 PM”, with the store name, distance and address. Switching back remembers the shipping option you had.

The block owns the hard states: skeleton cards the same shape as the real ones while rates load, an empty state with Change address when nothing ships there, an inline error with Retry, and rates that change after an address change. If the chosen option is still offered it stays selected; if not, the cheapest one is picked and the block says “Express isn’t available for this address”. DeliveryEstimate brings the same dates to a product page, with a ZIP code popover and a returns line.

  1. Get HextaUI Pro

    One payment unlocks every block, including future ones. See pricing.

  2. Add the Pro registry to components.json

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

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  4. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/shipping-methods

Shopify delivery groups

Map a cart’s deliveryGroups to rates and save the choice with cartSelectedDeliveryOptionsUpdate. Pickup and local options keep their own kind, so the Ship / Pick up switch appears on its own.

"use client"

import * as React from "react"

import type { ShippingRate } from "@/components/blocks/shipping-methods/shipping"
import { ShippingMethods } from "@/components/blocks/shipping-methods/shipping-methods"

type ShopifyDeliveryOption = {
  handle: string
  title: string | null
  description: string | null
  deliveryMethodType: "SHIPPING" | "PICK_UP" | "LOCAL" | "PICKUP_POINT" | "NONE"
  estimatedCost: { amount: string; currencyCode: string }
}

type ShopifyDeliveryGroup = {
  id: string
  deliveryOptions: ShopifyDeliveryOption[]
  selectedDeliveryOption: { handle: string } | null
}

const transitDays: Record<string, [number, number]> = {
  Standard: [3, 5],
  Express: [1, 2],
}

function fromShopify(group: ShopifyDeliveryGroup): ShippingRate[] {
  return group.deliveryOptions.map((option) => ({
    id: option.handle,
    title: option.title ?? "Shipping",
    price: option.estimatedCost,
    delivery: { businessDays: transitDays[option.title ?? ""] ?? [3, 7] },
    kind:
      option.deliveryMethodType === "PICK_UP" ||
      option.deliveryMethodType === "PICKUP_POINT"
        ? "pickup"
        : option.deliveryMethodType === "LOCAL"
          ? "local"
          : "ship",
    pickup:
      option.deliveryMethodType === "PICK_UP"
        ? { name: option.title ?? "Store pickup", address: option.description ?? "" }
        : undefined,
    note: option.deliveryMethodType === "PICK_UP" ? undefined : option.description ?? undefined,
  }))
}

export function CheckoutDelivery({
  group,
  loading,
  selectDeliveryOption,
}: {
  group: ShopifyDeliveryGroup | null
  loading: boolean
  selectDeliveryOption: (groupId: string, handle: string) => Promise<void>
}) {
  const rates = React.useMemo(() => (group ? fromShopify(group) : []), [group])
  return (
    <ShippingMethods
      rates={rates}
      status={loading ? "loading" : "idle"}
      value={group?.selectedDeliveryOption?.handle ?? null}
      onValueChange={(handle) => {
        if (group) void selectDeliveryOption(group.id, handle)
      }}
    />
  )
}

Shippo and EasyPost rates

Carrier APIs return transit days or a delivery date. Pass them through and the block turns them into real dates, skipping weekends and the holidays you list.

"use client"

import { money } from "../price/money"
import type { ShippingRate } from "@/components/blocks/shipping-methods/shipping"
import { ShippingMethods } from "@/components/blocks/shipping-methods/shipping-methods"

type ShippoRate = {
  object_id: string
  amount: string
  currency: string
  provider: string
  servicelevel: { name: string; token: string }
  estimated_days: number | null
}

type EasyPostRate = {
  id: string
  rate: string
  currency: string
  carrier: string
  service: string
  delivery_days: number | null
  delivery_date: string | null
}

export function fromShippo(rate: ShippoRate): ShippingRate {
  const days = rate.estimated_days ?? 5
  return {
    id: rate.object_id,
    title: rate.servicelevel.name,
    carrier: rate.provider,
    price: money(rate.amount, rate.currency),
    delivery: { businessDays: [days, days] },
    kind: "ship",
  }
}

export function fromEasyPost(rate: EasyPostRate): ShippingRate {
  const days = rate.delivery_days ?? 5
  return {
    id: rate.id,
    title: rate.service.replace(/([a-z])([A-Z])/g, "$1 $2"),
    carrier: rate.carrier,
    price: money(rate.rate, rate.currency),
    delivery: rate.delivery_date
      ? { from: rate.delivery_date.slice(0, 10) }
      : { businessDays: [days, days] },
    kind: "ship",
  }
}

export function CarrierRates({
  rates,
  status,
  selected,
  onSelect,
  refetch,
  editAddress,
}: {
  rates: ShippoRate[]
  status: "idle" | "loading" | "error"
  selected: string | null
  onSelect: (rateId: string) => void
  refetch: () => Promise<void>
  editAddress: () => void
}) {
  return (
    <ShippingMethods
      rates={rates.map(fromShippo)}
      status={status}
      value={selected}
      onValueChange={onSelect}
      onRetry={refetch}
      onChangeAddress={editAddress}
      calendar={{ holidays: ["2026-11-26", "2026-12-25"] }}
    />
  )
}

Stripe shipping rates

Stripe shipping rates carry a price in minor units and a delivery estimate in business days, hours or weeks. Convert them once and let the block show dates instead of speeds.

"use client"

import { fromMinorUnits } from "../price/money"
import type { ShippingRate } from "@/components/blocks/shipping-methods/shipping"
import { ShippingMethods } from "@/components/blocks/shipping-methods/shipping-methods"

type StripeEstimateUnit = "hour" | "day" | "business_day" | "week" | "month"

type StripeShippingRate = {
  id: string
  display_name: string | null
  fixed_amount: { amount: number; currency: string } | null
  delivery_estimate: {
    minimum: { unit: StripeEstimateUnit; value: number } | null
    maximum: { unit: StripeEstimateUnit; value: number } | null
  } | null
}

function toBusinessDays(estimate?: { unit: StripeEstimateUnit; value: number } | null) {
  if (!estimate) return 5
  if (estimate.unit === "hour") return Math.ceil(estimate.value / 24)
  if (estimate.unit === "week") return estimate.value * 5
  if (estimate.unit === "month") return estimate.value * 21
  return estimate.value
}

export function fromStripe(rate: StripeShippingRate): ShippingRate {
  const amount = rate.fixed_amount ?? { amount: 0, currency: "usd" }
  return {
    id: rate.id,
    title: rate.display_name ?? "Shipping",
    price: fromMinorUnits(amount.amount, amount.currency),
    delivery: {
      businessDays: [
        toBusinessDays(rate.delivery_estimate?.minimum),
        toBusinessDays(rate.delivery_estimate?.maximum ?? rate.delivery_estimate?.minimum),
      ],
    },
    kind: "ship",
  }
}

export function StripeShippingOptions({
  shippingRates,
  value,
  updateSession,
}: {
  shippingRates: StripeShippingRate[]
  value: string | null
  updateSession: (shippingRateId: string) => Promise<void>
}) {
  return (
    <ShippingMethods
      rates={shippingRates.map(fromStripe)}
      value={value}
      onValueChange={(id) => void updateSession(id)}
    />
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
ShippingMethodsThe delivery options as radio cards, with the Ship / Pick up switch and every loading, empty and error state.
DeliveryEstimateA product page line, “Free delivery Mon, Oct 26 – Wed, Oct 28 to 94110”, with a Change popover and a returns line.
businessDaysFrom, formatDeliveryRangePure date helpers: add business days skipping weekends and holidays, and format a date range with Today and Tomorrow.
resolveDelivery, rateBadgesTurn a rate into dates for a given time, including cutoffs, and work out the fastest and cheapest badges.

ShippingMethods

Also takes div props.

PropTypeDefault
ratesThe options for the current address.
ShippingRate[]–
valueThe selected rate id. Leave undefined for uncontrolled.
string | null–
defaultValueThe first selection when uncontrolled. Without one, the cheapest option is picked.
string | null–
onValueChangeCalled when the shopper picks an option, and when the block replaces one that’s no longer offered.
(id: string, rate: ShippingRate) => void–
statusLoading shows skeleton cards; error shows the error message with Retry.
"idle" | "loading" | "error""idle"
errorA specific error message instead of the default.
string–
onRetryShows Retry in the error state. A returned promise shows a spinner on the button.
() => unknown–
onChangeAddressShows Change address when there are no options.
() => void–
nowPins the current time, for tests and previews. Without it the block reads the clock after mount and once a minute.
Date | number–
calendarDays nothing ships or arrives on.
{ closedWeekdays?: number[]; holidays?: string[] }Saturday and Sunday
cutoffWindowShow the cutoff line only when it’s this many minutes away or fewer.
number1440
localeLocale for dates and prices.
Intl.LocalesArgument"en-US"
trailingZerosShow $18.00 instead of $18.
booleanfalse
labelsEvery visible and announced string, including the cutoff and ready sentences.
Partial<ShippingMethodsLabels>–
PropTypeDefault
idA stable id, such as the carrier rate id or Shopify handle.
string–
titleThe method, like Standard or Express.
string–
carrierThe carrier and service, like UPS 2nd Day Air.
string–
priceThe price. Zero shows Free.
Money–
compareAtPriceThe usual price, struck through, when shipping is discounted.
Money | null–
deliveryDelivery dates as YYYY-MM-DD, or business days counted from the ship day.
{ from: string; to?: string } | { businessDays: [min, max] }–
cutoffISO time of the order cutoff for this estimate. After it, the estimate moves one business day.
string–
kindPickup rates go behind the Pick up switch.
"ship" | "pickup" | "local"–
pickupStore details for pickup. readyBy is an ISO time.
{ name; address; distance?; readyBy? }–
noteA short extra line, like how long pickups are held.
string–

DeliveryEstimate

Also takes div props.

PropTypeDefault
defaultPostalCodeThe ZIP code the estimate is for.
string | null–
defaultEstimateThe estimate for that ZIP code.
{ delivery; price?; cutoff? }–
onEstimateLooks up a new ZIP code. Return null when you don’t deliver there; throw to show your message.
(postalCode: string) => Promise<DeliveryEstimateValue | null>–
onPostalCodeChangeCalled after a successful lookup, to remember the ZIP code.
(postalCode, estimate) => void–
validatePostalCodeReturns an error message or null. Defaults to US ZIP codes.
(value: string) => string | null–
returnsThe returns line. null hides it.
ReactNode | null"Free 30-day returns"
now, calendar, locale, labelsSame as ShippingMethods.
See ShippingMethods–
KeyAction
TabMoves to the Ship / Pick up switch, then to the selected option.
ArrowDownArrowUpMoves between options and selects them.
ArrowLeftArrowRightSwitches between Ship and Pick up.
EnterSubmits the ZIP code in the delivery estimate popover.
EscapeCloses the ZIP code popover and returns focus to Change.
  • Options are a real radio group. Each card reads its date first, “Arrives Mon, Oct 26 to Wed, Oct 28”, then the method, carrier and price.
  • A polite live region announces new options after an address change, and why an option was replaced. Errors use role="alert". The cutoff line updates once a minute and is not announced, so screen readers aren’t interrupted.
  • After Retry, focus moves to the selected option once rates load.
  • Selection shows a ring, a filled radio and a tinted card, not color alone. Loading and date changes fade in, and nothing moves with reduced motion.

Built with

The free HextaUI components Shipping Methods is made from. Each one installs on its own.

Code

7 files, added to components/blocks/shipping-methods.