Radio cards
A single choice laid out as cards, with a title, description and meta line each.
npx shadcn@latest add https://ui.goodtabs.co/r/radio-cards.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/radio-cards
Three plans across, one selected. Arrow keys move the selection.
With icons
A 16px icon before each title.
One column, controlled
Stacked cards with value and onValueChange from state.
Usage
import { RadioCard, RadioCards } from "@/components/ui/radio-cards"
<RadioCards aria-label="Plan" defaultValue="pro" columns={3}>
<RadioCard value="hobby" title="Hobby" description="For side projects." meta="$0 per month" />
<RadioCard value="pro" title="Pro" description="For one person." meta="$12 per month" />
</RadioCards>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value, defaultValue, onValueChange | radix RadioGroup props | Controlled or uncontrolled, on RadioCards. | |
| columns | 1 | 2 | 3 | 1 | On RadioCards: columns from the md and lg container widths. |
| value | string | On RadioCard: the value reported when it is selected. | |
| title | ReactNode | The card's title and the radio's accessible name. | |
| description | ReactNode | One line under the title. | |
| meta | ReactNode | Mono meta line at the bottom, for example a price. | |
| icon | ReactNode | A 16px icon before the title. | |
| disabled | boolean | false | Disables one card. |
Source
radio-cards/radio-cards.tsx
"use client";
import * as React from "react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
type RadioCardsProps = React.ComponentProps<typeof RadioGroup> & {
/** Columns from the md container width (448px) for 2, the lg width (512px) for 3. One below. */
columns?: 1 | 2 | 3;
};
/**
* A radio group laid out as cards. Controlled or uncontrolled through radix:
* value, defaultValue, onValueChange. Arrow keys move the selection.
*/
export function RadioCards({ columns = 1, className, ...props }: RadioCardsProps) {
return (
<RadioGroup
data-slot="radio-cards"
className={cn(
"@container grid w-full gap-2",
columns === 2 && "@md:grid-cols-2",
columns === 3 && "@lg:grid-cols-3",
className,
)}
{...props}
/>
);
}
type RadioCardProps = Omit<React.ComponentProps<"label">, "title"> & {
/** The value reported by the group when this card is selected. */
value: string;
/** The card's title. Also the radio's accessible name. */
title: React.ReactNode;
/** One line under the title. Also the radio's description. */
description?: React.ReactNode;
/** Mono meta line at the bottom, for example a price or a limit. */
meta?: React.ReactNode;
/** A 16px icon shown before the title. */
icon?: React.ReactNode;
/** Disables this card only. */
disabled?: boolean;
};
/** One choice. The whole card is the label, so clicking anywhere selects it. */
export function RadioCard({ value, title, description, meta, icon, disabled, className, ...props }: RadioCardProps) {
const id = React.useId();
const titleId = `${id}-title`;
const descriptionId = description ? `${id}-description` : undefined;
return (
<label
data-slot="radio-card"
className={cn(
"group/field-label relative flex cursor-pointer gap-3 rounded-lg border bg-card p-3 text-left transition-colors duration-100 ease-out",
"hover:bg-accent/40 has-[:focus-visible]:border-ring has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50",
"has-data-[state=checked]:border-foreground/40 has-data-[state=checked]:bg-muted/40",
"has-disabled:cursor-not-allowed has-disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupItem
value={value}
disabled={disabled}
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="mt-0.5 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-2">
{icon ? (
<span aria-hidden="true" className="shrink-0 text-muted-foreground [&_svg]:size-4">
{icon}
</span>
) : null}
<span id={titleId} className="text-sm leading-5 font-medium text-foreground">
{title}
</span>
</span>
{description ? (
<span id={descriptionId} className="text-sm text-muted-foreground">
{description}
</span>
) : null}
{meta ? <span className="mt-1 font-mono text-xs text-muted-foreground">{meta}</span> : null}
</span>
</label>
);
}
For AI agents
Hand your agent this page, the markdown at /components/radio-cards/prompt.md, or the Copy AI prompt button above.
When to use
- One choice from two to six options that each need a line of context: plans, regions, levels
- Checkout and onboarding steps where the choice should read at a glance
- Anywhere a select would hide the differences between the options
When not to use
- For several choices at once, use checkbox-card
- For more than six options or options without context, use a select or plain radios
- For switching views of the same content, use segmented-tabs
Agent notes
- RadioCards is shadcn's RadioGroup, so value, defaultValue, onValueChange, name and required work as in shadcn
- onValueChange is a function: pass it from a client component. defaultValue works from a server component
- Give RadioCards an aria-label; each card names its radio from the title and describes it from the description
- Selected styling targets radix's data-state=checked, so it does not depend on the primitive's own checked classes