All patterns

Shipping Option Select

One plate travels to the chosen speed while the arrival estimate restates itself and the total follows.

commerceminimalcalminteraction · finite · intermediate · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

363 lines · react + motion only
import { useId, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Shipping Option Select
 *
 * One plate travels to the speed you picked while the arrival date
 * restates itself in place and the order total rolls to match. The
 * marker moves rather than blinking out and back in somewhere else, so
 * the eye never loses which row is chosen.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `options`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ShippingOption = {
  id: string;
  /** Name of the speed. */
  name: string;
  /** When it lands, in words. */
  arrival: string;
  /** Longer sentence shown in the summary under the list. */
  detail: string;
  /** Formatted cost of this speed. */
  cost: string;
  /** Formatted order total with this speed applied. */
  total: string;
};

export type ShippingOptionSelectProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The speeds on offer. */
  options?: ShippingOption[];
  /** Index selected on first render. */
  initialIndex?: number;
  /** Accent for the marker and the plate. */
  accent?: string;
  /** Fires with the chosen speed. */
  onSelect?: (option: ShippingOption) => void;
};

type VariantConfig = {
  /** Spring the plate rides between rows. */
  plate: { type: "spring"; stiffness: number; damping: number };
  /** Spring the total rolls on. */
  roll: { type: "spring"; stiffness: number; damping: number };
  /** Crossfade for the arrival line restating itself. */
  swapSeconds: number;
};

// A marker that overshoots its row makes the list look unsure which one
// you picked. Damping ratios (damping / 2√stiffness) sit at or above
// 0.86 throughout. Variants differ in speed, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly instant. For a checkout that is edited repeatedly.
  subtle: {
    plate: { type: "spring", stiffness: 760, damping: 53 },
    roll: { type: "spring", stiffness: 760, damping: 53 },
    swapSeconds: 0.1,
  },
  // The plate visibly travels between rows. All-purpose.
  default: {
    plate: { type: "spring", stiffness: 440, damping: 38 },
    roll: { type: "spring", stiffness: 440, damping: 38 },
    swapSeconds: 0.2,
  },
  // A longer traverse for a delivery step that carries real cost.
  playful: {
    plate: { type: "spring", stiffness: 240, damping: 28 },
    roll: { type: "spring", stiffness: 260, damping: 30 },
    swapSeconds: 0.26,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The accent stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_OPTIONS: ShippingOption[] = [
  {
    id: "standard",
    name: "Standard",
    arrival: "Fri 29 Aug",
    detail: "Arrives Fri 29 Aug · tracked, no signature needed",
    cost: "Free",
    total: "$198.40",
  },
  {
    id: "express",
    name: "Express",
    arrival: "Wed 27 Aug",
    detail: "Arrives Wed 27 Aug · tracked, signature on delivery",
    cost: "$9.00",
    total: "$207.40",
  },
  {
    id: "overnight",
    name: "Overnight",
    arrival: "Tomorrow",
    detail: "Arrives tomorrow before noon · order in the next four hours",
    cost: "$22.00",
    total: "$220.40",
  },
];

const numericOf = (value: string) => Number(value.replace(/[^0-9.-]/g, "")) || 0;

export default function ShippingOptionSelect({
  variant = "default",
  options = DEFAULT_OPTIONS,
  initialIndex = 0,
  accent = "#7C7CF0",
  onSelect,
}: ShippingOptionSelectProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  // Scoped so two of these on one page cannot share a travelling plate.
  const plateId = `${useId()}-plate`;

  const [index, setIndex] = useState(
    Math.min(Math.max(initialIndex, 0), options.length - 1)
  );
  const [direction, setDirection] = useState(1);
  const active = options[index];

  const choose = (nextIndex: number) => {
    const clamped = (nextIndex + options.length) % options.length;
    if (clamped === index) return;
    setDirection(
      numericOf(options[clamped].total) >= numericOf(options[index].total)
        ? 1
        : -1
    );
    setIndex(clamped);
    onSelect?.(options[clamped]);
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowDown" || event.key === "ArrowRight") {
      event.preventDefault();
      choose(index + 1);
    } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
      event.preventDefault();
      choose(index - 1);
    }
  };

  // Up when the total went up. The direction is the fastest read on the
  // panel — it says which way before the figure is legible.
  const travel = reduceMotion ? 0 : direction * 24;

  return (
    <div style={{ width: 278, fontSize: 13 }}>
      <div
        role="radiogroup"
        aria-label="Delivery speed"
        onKeyDown={onKeyDown}
        style={{ display: "grid", gap: 6 }}
      >
        {options.map((option, optionIndex) => {
          const selected = optionIndex === index;
          return (
            <button
              key={option.id}
              type="button"
              role="radio"
              aria-checked={selected}
              tabIndex={selected ? 0 : -1}
              onClick={() => choose(optionIndex)}
              style={{
                position: "relative",
                display: "flex",
                alignItems: "center",
                gap: 11,
                width: "100%",
                padding: "10px 12px",
                fontFamily: "inherit",
                textAlign: "left",
                color: "inherit",
                background: tone(4),
                border: `1px solid ${tone(10)}`,
                borderRadius: 11,
                cursor: "pointer",
              }}
            >
              {/* One plate for the whole group. Sharing a layout id makes
                  it travel to the chosen row instead of one plate fading
                  out while another fades in somewhere else. */}
              {selected && (
                <motion.span
                  layoutId={plateId}
                  transition={reduceMotion ? { duration: 0 } : cfg.plate}
                  aria-hidden
                  style={{
                    position: "absolute",
                    inset: -1,
                    borderRadius: 11,
                    border: `1.5px solid ${accent}`,
                    background: `color-mix(in srgb, ${accent} 9%, transparent)`,
                  }}
                />
              )}

              <span
                aria-hidden
                style={{
                  position: "relative",
                  width: 16,
                  height: 16,
                  flexShrink: 0,
                  display: "grid",
                  placeItems: "center",
                  borderRadius: 999,
                  border: `1.5px solid ${selected ? accent : tone(28)}`,
                }}
              >
                <motion.span
                  initial={false}
                  animate={{ scale: selected ? 1 : 0 }}
                  transition={reduceMotion ? { duration: 0 } : cfg.plate}
                  style={{
                    width: 8,
                    height: 8,
                    borderRadius: 999,
                    background: accent,
                  }}
                />
              </span>

              <span
                style={{ position: "relative", display: "grid", gap: 2, minWidth: 0 }}
              >
                <span style={{ fontSize: 12.5, fontWeight: 600 }}>
                  {option.name}
                </span>
                <span style={{ fontSize: 11, opacity: 0.5 }}>
                  {option.arrival}
                </span>
              </span>

              <span
                style={{
                  position: "relative",
                  marginLeft: "auto",
                  fontSize: 12.5,
                  fontWeight: 600,
                  fontVariantNumeric: "tabular-nums",
                }}
              >
                {option.cost}
              </span>
            </button>
          );
        })}
      </div>

      {/* The estimate restates itself in one cell, so a longer sentence
          can never push the total below it around. */}
      <div
        style={{
          display: "grid",
          height: 32,
          marginTop: 12,
          overflow: "hidden",
          alignContent: "center",
        }}
      >
        <AnimatePresence initial={false}>
          <motion.div
            key={active.id}
            initial={{ opacity: 0, y: reduceMotion ? 0 : 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduceMotion ? 0 : -8 }}
            transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
            style={{
              gridArea: "1 / 1",
              display: "flex",
              alignItems: "center",
              gap: 8,
            }}
          >
            <svg
              width="14"
              height="14"
              viewBox="0 0 16 16"
              fill="none"
              aria-hidden
              style={{ flexShrink: 0, opacity: 0.55 }}
            >
              <rect
                x="1.6"
                y="3.4"
                width="9"
                height="7.6"
                rx="1.4"
                stroke="currentColor"
                strokeWidth="1.3"
              />
              <path
                d="M10.6 6h2.2l1.6 2.3V11h-3.8"
                stroke="currentColor"
                strokeWidth="1.3"
                strokeLinejoin="round"
              />
              <circle cx="4.7" cy="12.2" r="1.3" stroke="currentColor" strokeWidth="1.3" />
              <circle cx="11.5" cy="12.2" r="1.3" stroke="currentColor" strokeWidth="1.3" />
            </svg>
            <span style={{ fontSize: 11.5, opacity: 0.62, lineHeight: 1.35 }}>
              {active.detail}
            </span>
          </motion.div>
        </AnimatePresence>
      </div>

      <div style={{ height: 1, background: tone(12), margin: "11px 0" }} />

      <div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
        <span style={{ fontSize: 13, fontWeight: 600 }}>Order total</span>
        {/* The total rolls in the direction the money went: it travels
            and crossfades, and holds one type size the whole way. */}
        <span
          style={{
            marginLeft: "auto",
            display: "grid",
            justifyItems: "end",
            height: 24,
            overflow: "hidden",
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={active.total}
              initial={{ y: travel, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: -travel, opacity: 0 }}
              transition={{
                y: reduceMotion ? { duration: 0 } : cfg.roll,
                opacity: { duration: cfg.swapSeconds, ease: "easeOut" },
              }}
              style={{
                gridArea: "1 / 1",
                fontSize: 19,
                fontWeight: 650,
                lineHeight: "24px",
                letterSpacing: -0.2,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              {active.total}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>
    </div>
  );
}

About this pattern

Choosing a delivery speed changes three things, and they have to agree. A single marker plate travels to the picked row on a shared layout animation rather than one plate fading out while another fades in, so the eye never loses the selection. The arrival sentence restates itself inside a fixed cell, which means a longer estimate cannot push the total around. The total rolls in the direction the money went — up for a faster speed, down for a slower one — at a constant type size. Arrow keys move the selection, and the plate travels for keyboard users too.

Delivery speed at checkoutPickup or courier choiceService tier selectionAny priced radio group

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    Changing the delivery speed restates the promised date above the order summary.

Related patterns