Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
value, defaultValue, onValueChangeradix RadioGroup propsControlled or uncontrolled, on RadioCards.
columns1 | 2 | 31On RadioCards: columns from the md and lg container widths.
valuestringOn RadioCard: the value reported when it is selected.
titleReactNodeThe card's title and the radio's accessible name.
descriptionReactNodeOne line under the title.
metaReactNodeMono meta line at the bottom, for example a price.
iconReactNodeA 16px icon before the title.
disabledbooleanfalseDisables 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