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
Built from
PriceBadge
Shipping method
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.

Choose a plan
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

Delivery
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.

Delivery
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

PropTypeDefaultWhat it does
labelReactNodenoneNames 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 }[]noneThe cards. price is in major units; meta is a short extra line such as a delivery estimate.
valuestringnoneControlled value. Pair it with onValueChange.
defaultValuestringnoneThe first checked value, when uncontrolled.
onValueChange(value: string) => voidnoneCalled with the value of the option that was checked.
namestringnoneForm field name. Generated when left out so the radios still group.
currencystring"USD"ISO 4217 currency code.
localestring"en-US"Formats every price.
layout"stack" | "grid""stack"Cards in a column, or a grid that fits as many as the width allows.
requiredbooleannoneSets required on the radios, so a form will not submit without a choice.
freeLabelstring"Free"Shown for a price of "free".
...restComponentProps<"fieldset">noneEvery native fieldset attribute, including disabled and form.

Anatomy

root.eq-plan-pickerThe fieldset and container-query box. Layout is on data-layout.
legend.eq-plan-picker-legend
options.eq-plan-picker-optionsThe column or grid of cards.
option.eq-plan-picker-optionA <label> card; checked, focus and disabled come from :has() on its radio.
input.eq-plan-picker-inputThe native radio, drawn as a ring and dot.
body.eq-plan-picker-body
head.eq-plan-picker-headTitle and badge.
title.eq-plan-picker-titleThe radio's accessible name.
badge.eq-plan-picker-badgeA Badge, such as "Recommended".
description.eq-plan-picker-description
meta.eq-plan-picker-metaDelivery estimate, or the reason when disabled.
price.eq-plan-picker-priceWraps Price or the free label.
free.eq-plan-picker-free

Keyboard

TabMoves into the group, onto the checked card, and out again.
Arrow keysMove to and check the next or previous enabled card.
SpaceChecks 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index