Skip to content
txbrendan/ui

Selection Card

beta

Options as cards: a dot for one answer, a check for several, with a hint and an aside like a price on each card.

Preview

one

Plan

multiple

Who to tell

tone

size

invalid

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/selection-card

Use it for

  • A question whose options each need a hint, a price, or a consequence, like a plan, a delivery speed, or which teams to tell.
  • One answer by default. Add multiple for several answers, and give value and defaultValue a string array.
  • Put the option's name in the children, the explanation in hint, and a short figure like a price in aside.
  • Name the question with a small field label above the cards and point aria-labelledby at it. Use aria-label when there is no visible label.
  • Lay the cards out with className on SelectionGroup, like className='grid-cols-2'.
  • Set tone and size once, on SelectionGroup. Every card follows them.

Avoid it for

  • Short, plain options with nothing to explain, in a dense form. Use Radio Group or Checkbox.
  • A setting that turns on or off. Use a switch.
  • Long lists, more than about six options. Use a select or a combobox.
  • A heading above the cards. A small field label names the question.
  • Starting with nothing chosen when one answer is the safe default. Set defaultValue to it.

Props

  • SelectionGroup

    Root. Holds the value. Takes value, defaultValue, onValueChange, disabled, and, for one answer, name, required, and readOnly.

    SelectionGroupProps

  • multiple

    SelectionGroup. Several answers. Cards draw a check, and value is a string array.

    boolean · default false

  • aria-label

    SelectionGroup. Names the question. TypeScript requires this or aria-labelledby.

    string

  • aria-labelledby

    SelectionGroup. The id of the visible field label above the cards.

    string

  • tone

    SelectionGroup. Color of a chosen card's edge and control.

    "neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"

  • size

    SelectionGroup. Text and control size. Density scales them.

    "sm" | "md" | "lg" · default "md"

  • value

    SelectionGroup: the chosen answer, or answers with multiple. SelectionCard: what the group holds when this card is chosen. Required on a card.

    string | string[]

  • defaultValue

    SelectionGroup. The answer, or answers, chosen at the start when you do not control it.

    string | string[]

  • onValueChange

    SelectionGroup. Called with the new value, a string, or a string array with multiple.

    (value, details) => void

  • aria-invalid

    SelectionGroup. Turns every card's edge to the danger tone. Set it when validation fails, and link the message with aria-describedby.

    boolean

  • SelectionCard

    Part. One answer. The whole card is the target.

    ComponentProps<"label">

  • children

    SelectionCard. The option's name. It names the control.

    ReactNode

  • hint

    SelectionCard. A second line that explains the option. Read as the control's description.

    ReactNode

  • aside

    SelectionCard. Short text at the end of the card, like a price. Read as part of the description.

    ReactNode

  • disabled

    SelectionGroup or SelectionCard. Blocks choosing. On the group it blocks every card.

    boolean · default false

  • className

    SelectionGroup: classes for the layout. SelectionCard: classes for the card. Later classes win.

    string

Accessibility

  • One answer renders role="radiogroup" with a role="radio" in each card. Several answers render a group with a role="checkbox" in each card. Each also has a hidden native input, so the value is sent with a form.
  • The title names the control through aria-labelledby. The hint and the aside describe it through aria-describedby.
  • With one answer the group is one tab stop, and arrow keys move the answer, skipping disabled cards. With several, each card is a tab stop and Space toggles it.
  • The control takes focus, and the whole card shows the 2px focus ring. The theme tests hold --ring to 3:1 on the page and cards.
  • A resting card's edge uses --input, which the theme tests hold to 3:1. A chosen card adds the tone's edge and an inset ring, so the choice does not rest on color alone: the dot or the check shows it too.
  • TypeScript requires a name for the group and a value for each card.

Examples

One answer, named by a field label

<div className="grid gap-1.5">
  <p id="plan-label" className="text-sm text-muted-foreground">
    Plan
  </p>
  <SelectionGroup aria-labelledby="plan-label" name="plan" defaultValue="team" tone="brand">
    <SelectionCard value="solo" hint="One person, three projects." aside="£0">
      Solo
    </SelectionCard>
    <SelectionCard value="team" hint="Up to ten people, every project." aside="£12 a month">
      Team
    </SelectionCard>
  </SelectionGroup>
</div>

Several answers, in two columns

<SelectionGroup multiple aria-label="Who to tell" defaultValue={["design"]} className="grid-cols-2">
  <SelectionCard value="design" hint="19 people get a message.">
    Design team
  </SelectionCard>
  <SelectionCard value="managers" hint="12 managers get a copy.">
    Managers
  </SelectionCard>
</SelectionGroup>

Controlled

"use client";
import { useState } from "react";

function Delivery() {
  const [speed, setSpeed] = useState("standard");
  return (
    <SelectionGroup aria-label="Delivery speed" value={speed} onValueChange={(value) => setSpeed(value)}>
      <SelectionCard value="standard" hint="3 to 5 days." aside="Free">
        Standard
      </SelectionCard>
      <SelectionCard value="express" hint="Next day, if you order by 14:00." aside="£6">
        Express
      </SelectionCard>
    </SelectionGroup>
  );
}