Shipping Option Select
One plate travels to the chosen speed while the arrival estimate restates itself and the total follows.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Changing the delivery speed restates the promised date above the order summary.
Related patterns
- Cart Item RemoveA removed line collapses its own height and gap while the survivors travel up and the total falls.
- Order Tracking ProgressA shipment walks its stages while the connector fills from stop to stop.
- Cart Quantity StepperStepping the quantity rolls the count and both totals in the direction of the change.