Commerce
Plan picker
Chooses one plan or shipping method from radio cards with a title, description, price and optional badge.
- Built on
<input type="radio"><fieldset><legend>:has()CSS container queriesIntl.NumberFormat- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.9 kB
Code
import { PlanPicker } from "@/components/equip/plan-picker";
export default function Demo() {
return (
<PlanPicker
label="Shipping method"
defaultValue="standard"
style={{ width: 224 }}
options={[
{ value: "standard", title: "Standard", meta: "3 to 5 working days", price: "free" },
{ value: "express", title: "Express", meta: "Arrives tomorrow", price: 12 },
]}
/>
);
}Examples
Plans in a grid
Cards sit side by side once each can be about 11rem wide, and stack below that.
Code
import { PlanPicker } from "@/components/equip/plan-picker";
export default function Example() {
return (
<PlanPicker
label="Choose a plan"
layout="grid"
name="plan"
defaultValue="team"
options={[
{ value: "starter", title: "Starter", description: "One workspace and 5 GB of storage.", price: 8, period: "month" },
{
value: "team",
title: "Team",
description: "Up to 20 people, shared templates and guest access.",
price: 24,
period: "month",
badge: "Recommended",
},
{
value: "business",
title: "Business",
description: "Single sign-on, audit log and priority support.",
price: 49,
period: "month",
},
]}
/>
);
}Shipping methods in pounds
Code
import { PlanPicker } from "@/components/equip/plan-picker";
export default function Example() {
return (
<PlanPicker
label="Delivery"
name="delivery"
currency="GBP"
locale="en-GB"
defaultValue="tracked"
style={{ maxWidth: 420 }}
options={[
{ value: "standard", title: "Royal Mail 2nd Class", meta: "Arrives Wed 18 Nov to Fri 20 Nov", price: "free" },
{ value: "tracked", title: "Tracked 48", meta: "Arrives Tue 17 Nov", price: 3.95 },
{ value: "next-day", title: "Next day by DPD", meta: "Arrives Mon 16 Nov, 1 hour slot by text", price: 6.5 },
{ value: "collect", title: "Click and collect", description: "From the King's Road shop, after 2pm tomorrow.", price: "free" },
]}
/>
);
}Unavailable option in a form
The radios are native and required, so the form will not submit without a choice; the reason stays readable on the disabled card.
Code
import { Button } from "@/components/equip/button";
import { PlanPicker } from "@/components/equip/plan-picker";
export default function Example() {
return (
<form action="#" style={{ display: "grid", gap: 16, width: "100%", maxWidth: 420, justifyItems: "start" }}>
<PlanPicker
label="Delivery"
name="delivery"
currency="GBP"
locale="en-GB"
required
options={[
{ value: "standard", title: "Standard", meta: "2 to 4 working days", price: 2.99 },
{
value: "next-day",
title: "Next day",
price: 7.99,
disabled: true,
disabledReason: "Next-day unavailable for this postcode",
},
{ value: "pallet", title: "Two-person delivery", meta: "We call to book a day", price: 34.99 },
]}
/>
<Button type="submit" variant="accent">
Continue to payment
</Button>
</form>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Names the choice. Rendered as the legend. |
options | { value: string; title: string; description?: string; price?: number | "free"; period?: string; meta?: string; badge?: string; disabled?: boolean; disabledReason?: string }[] | none | The cards. price is in major units; meta is a short extra line such as a delivery estimate. |
value | string | none | Controlled value. Pair it with onValueChange. |
defaultValue | string | none | The first checked value, when uncontrolled. |
onValueChange | (value: string) => void | none | Called with the value of the option that was checked. |
name | string | none | Form field name. Generated when left out so the radios still group. |
currency | string | "USD" | ISO 4217 currency code. |
locale | string | "en-US" | Formats every price. |
layout | "stack" | "grid" | "stack" | Cards in a column, or a grid that fits as many as the width allows. |
required | boolean | none | Sets required on the radios, so a form will not submit without a choice. |
freeLabel | string | "Free" | Shown for a price of "free". |
...rest | ComponentProps<"fieldset"> | none | Every native fieldset attribute, including disabled and form. |
Anatomy
| root | .eq-plan-picker | The fieldset and container-query box. Layout is on data-layout. |
|---|---|---|
| legend | .eq-plan-picker-legend | |
| options | .eq-plan-picker-options | The column or grid of cards. |
| option | .eq-plan-picker-option | A <label> card; checked, focus and disabled come from :has() on its radio. |
| input | .eq-plan-picker-input | The native radio, drawn as a ring and dot. |
| body | .eq-plan-picker-body | |
| head | .eq-plan-picker-head | Title and badge. |
| title | .eq-plan-picker-title | The radio's accessible name. |
| badge | .eq-plan-picker-badge | A Badge, such as "Recommended". |
| description | .eq-plan-picker-description | |
| meta | .eq-plan-picker-meta | Delivery estimate, or the reason when disabled. |
| price | .eq-plan-picker-price | Wraps Price or the free label. |
| free | .eq-plan-picker-free |
Keyboard
| Tab | Moves into the group, onto the checked card, and out again. |
|---|---|
| Arrow keys | Move to and check the next or previous enabled card. |
| Space | Checks the focused card when none is checked. |
Notes
- Each card is a native radio inside its label, so it prerenders, submits with a form and, uncontrolled, works before the client script runs.
- The title is the radio's name; price, badge, description and meta are read as its description.
- Checked state shows as a solid ring and a filled dot, not by colour alone.
- A disabled card stays in view with its reason readable, but the arrow keys skip it.
- Keep fraction digits consistent across a group: Price drops zero cents on whole amounts, so mix whole prices with free rather than with cents.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/plan-picker.tsx138 lines
"use client";
import { useId, type ComponentProps, type ReactNode } from "react";
import { Badge } from "./badge";
import { Price } from "./price";
import "./plan-picker.css";
export type PlanPickerOption = {
value: string;
title: string;
description?: string;
/** Major units, or "free" for the free label. */
price?: number | "free";
/** Billing period, such as "month". */
period?: string;
/** A short extra line, such as "Arrives Tue 18 Nov". */
meta?: string;
/** Small label beside the title, such as "Recommended". */
badge?: string;
disabled?: boolean;
/** Why it cannot be chosen. Shown in place of meta and read with the option. */
disabledReason?: string;
};
export type PlanPickerProps = Omit<ComponentProps<"fieldset">, "defaultValue" | "children"> & {
/** Names the choice. Rendered as the legend. */
label: ReactNode;
options: PlanPickerOption[];
/** Controlled value. Pair it with onValueChange. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Form field name. Generated when left out so the radios still group. */
name?: string;
/** ISO 4217 code. */
currency?: string;
locale?: string;
/** Cards in a column, or a grid that fits as many as the width allows. */
layout?: "stack" | "grid";
/** Asks the form to require a choice. */
required?: boolean;
/** Text for price "free". */
freeLabel?: string;
};
export function PlanPicker({
label,
options,
value,
defaultValue,
onValueChange,
name,
currency = "USD",
locale = "en-US",
layout = "stack",
required,
freeLabel = "Free",
className,
...rest
}: PlanPickerProps) {
const uid = useId();
const controlled = value !== undefined;
return (
<fieldset {...rest} className={["eq-plan-picker", className].filter(Boolean).join(" ")} data-layout={layout}>
<legend className="eq-plan-picker-legend">{label}</legend>
<div className="eq-plan-picker-options">
{options.map((option, i) => {
const id = `${uid}-${i}`;
const extra = option.disabled && option.disabledReason ? option.disabledReason : option.meta;
const described = [
option.price !== undefined && `${id}-price`,
option.badge && `${id}-badge`,
option.description && `${id}-description`,
extra && `${id}-meta`,
]
.filter(Boolean)
.join(" ");
return (
<label key={option.value} className="eq-plan-picker-option" data-disabled={option.disabled || undefined}>
<input
className="eq-plan-picker-input"
type="radio"
name={name ?? uid}
value={option.value}
{...(controlled ? { checked: option.value === value } : { defaultChecked: option.value === defaultValue })}
disabled={option.disabled}
required={required}
aria-labelledby={`${id}-title`}
aria-describedby={described || undefined}
onChange={(event) => {
if (event.currentTarget.checked) onValueChange?.(option.value);
}}
/>
<span className="eq-plan-picker-body">
<span className="eq-plan-picker-head">
<span className="eq-plan-picker-title" id={`${id}-title`}>
{option.title}
</span>
{option.badge && (
<Badge className="eq-plan-picker-badge" id={`${id}-badge`}>
{option.badge}
</Badge>
)}
</span>
{option.description && (
<span className="eq-plan-picker-description" id={`${id}-description`}>
{option.description}
</span>
)}
{extra && (
<span className="eq-plan-picker-meta" id={`${id}-meta`} data-reason={option.disabled || undefined}>
{extra}
</span>
)}
</span>
{option.price !== undefined && (
<span className="eq-plan-picker-price" id={`${id}-price`}>
{option.price === "free" ? (
<span className="eq-plan-picker-free">{freeLabel}</span>
) : (
<Price
amount={option.price}
currency={currency}
locale={locale}
period={option.period}
size={layout === "grid" ? "m" : "s"}
/>
)}
</span>
)}
</label>
);
})}
</div>
</fieldset>
);
}components/equip/plan-picker.css191 lines
@layer equip {
.eq-plan-picker {
container-type: inline-size;
box-sizing: border-box;
width: 100%;
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
}
.eq-plan-picker-legend {
margin: 0 0 var(--eq-space-2);
padding: 0;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-plan-picker-options {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--eq-space-2);
}
/* Grid cards sit side by side once each can be about 11rem wide; narrower, they stack. */
@container (width >= 30rem) {
.eq-plan-picker[data-layout="grid"] .eq-plan-picker-options {
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: var(--eq-space-3);
}
}
.eq-plan-picker-option {
position: relative;
box-sizing: border-box;
display: grid;
grid-template-columns: 16px minmax(0, 1fr) auto;
align-items: start;
column-gap: var(--eq-space-3);
min-width: 0;
padding: var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
cursor: pointer;
transition:
border-color var(--eq-dur) var(--eq-ease),
box-shadow var(--eq-dur) var(--eq-ease),
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-plan-picker[data-layout="grid"] .eq-plan-picker-option {
grid-template-columns: 16px minmax(0, 1fr);
align-content: start;
row-gap: var(--eq-space-3);
padding: var(--eq-space-4);
}
@media (hover: hover) {
.eq-plan-picker-option:hover:not(:has(:checked, :disabled)) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
.eq-plan-picker-option:active:not(:has(:disabled)) {
scale: var(--eq-press);
}
/* Checked: the border turns solid and an inset ring doubles it, so nothing moves. */
.eq-plan-picker-option:has(.eq-plan-picker-input:checked) {
border-color: var(--eq-solid);
box-shadow: inset 0 0 0 1px var(--eq-solid);
}
.eq-plan-picker-option:has(.eq-plan-picker-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* A required group left empty, once the person has tried to submit. */
.eq-plan-picker-option:has(.eq-plan-picker-input:user-invalid) {
border-color: var(--eq-danger);
}
/* The drawn radio, as in Radio: a ring that thins on the spring so the dot grows from the centre. */
.eq-plan-picker-input {
appearance: none;
box-sizing: border-box;
display: block;
width: 16px;
height: 16px;
margin: 2px 0 0;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-full);
background: transparent;
box-shadow: inset 0 0 0 7px var(--eq-raised);
cursor: inherit;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
box-shadow var(--eq-dur) var(--eq-ease);
}
.eq-plan-picker-input:checked {
border-color: var(--eq-solid);
background: var(--eq-solid-ink);
box-shadow: inset 0 0 0 4px var(--eq-solid);
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
box-shadow var(--eq-dur-move) var(--eq-spring);
}
/* The card carries the focus ring. */
.eq-plan-picker-input:focus-visible {
outline: none;
}
.eq-plan-picker-body {
display: grid;
gap: 2px;
min-width: 0;
}
.eq-plan-picker-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-1) var(--eq-space-2);
min-width: 0;
}
.eq-plan-picker-title {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
line-height: 20px;
}
.eq-plan-picker-description,
.eq-plan-picker-meta {
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
line-height: 18px;
}
.eq-plan-picker-price {
min-width: 0;
padding-top: 1px;
text-align: end;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-plan-picker[data-layout="grid"] .eq-plan-picker-price {
grid-column: 2;
padding-top: 0;
text-align: start;
}
.eq-plan-picker-free {
font-weight: var(--eq-weight-strong);
font-size: var(--eq-text-m);
line-height: 20px;
}
/* Disabled: the option dims, but the reason it cannot be chosen stays readable. */
.eq-plan-picker-option:has(.eq-plan-picker-input:disabled) {
cursor: not-allowed;
background: transparent;
border-style: dashed;
}
.eq-plan-picker-option:has(.eq-plan-picker-input:disabled) > :is(.eq-plan-picker-input, .eq-plan-picker-price),
.eq-plan-picker-option:has(.eq-plan-picker-input:disabled) :is(.eq-plan-picker-head, .eq-plan-picker-description) {
opacity: 0.45;
}
/* A disabled fieldset dims everything once, reasons included. */
.eq-plan-picker:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-plan-picker:disabled .eq-plan-picker-option:has(.eq-plan-picker-input:disabled) > :is(.eq-plan-picker-input, .eq-plan-picker-price),
.eq-plan-picker:disabled .eq-plan-picker-option:has(.eq-plan-picker-input:disabled) :is(.eq-plan-picker-head, .eq-plan-picker-description) {
opacity: 1;
}
}