Selection Card
betaOptions 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
multiple
tone
size
invalid
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. 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
| Prop | Type | Default | What it does |
|---|---|---|---|
| SelectionGroup | SelectionGroupProps | - | Root. Holds the value. Takes value, defaultValue, onValueChange, disabled, and, for one answer, name, required, and readOnly. |
| multiple | boolean | false | SelectionGroup. Several answers. Cards draw a check, and value is a string array. |
| aria-label | string | - | SelectionGroup. Names the question. TypeScript requires this or aria-labelledby. |
| aria-labelledby | string | - | SelectionGroup. The id of the visible field label above the cards. |
| tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | SelectionGroup. Color of a chosen card's edge and control. |
| size | "sm" | "md" | "lg" | "md" | SelectionGroup. Text and control size. Density scales them. |
| value | string | string[] | - | SelectionGroup: the chosen answer, or answers with multiple. SelectionCard: what the group holds when this card is chosen. Required on a card. |
| defaultValue | string | string[] | - | SelectionGroup. The answer, or answers, chosen at the start when you do not control it. |
| onValueChange | (value, details) => void | - | SelectionGroup. Called with the new value, a string, or a string array with multiple. |
| aria-invalid | boolean | - | SelectionGroup. Turns every card's edge to the danger tone. Set it when validation fails, and link the message with aria-describedby. |
| SelectionCard | ComponentProps<"label"> | - | Part. One answer. The whole card is the target. |
| children | ReactNode | - | SelectionCard. The option's name. It names the control. |
| hint | ReactNode | - | SelectionCard. A second line that explains the option. Read as the control's description. |
| aside | ReactNode | - | SelectionCard. Short text at the end of the card, like a price. Read as part of the description. |
| disabled | boolean | false | SelectionGroup or SelectionCard. Blocks choosing. On the group it blocks every card. |
| className | string | - | SelectionGroup: classes for the layout. SelectionCard: classes for the card. Later classes win. |
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>
);
}