Choice Cards

One option chosen from a few, each shown as a picture of what it does.

'use client';

import { ChoiceCards } from '@/components/panelui/choice-cards';

/** Four pointer styles, drawn at the size a recording would show them. */
const POINTERS = [
  {
    value: 'arrow',
    label: 'Arrow',
    drawing: <path d="M14 10v26l7-6.5 4.5 10 4.5-2-4.5-9.5H35Z" />,
  },
  { value: 'dot', label: 'Dot', drawing: <circle cx="24" cy="24" r="7" /> },
  {
    value: 'halo',
    label: 'Halo',
    drawing: (
      <>
        <circle cx="24" cy="24" r="15" fillOpacity={0.22} stroke="none" />
        <circle cx="24" cy="24" r="6" />
      </>
    ),
  },
  {
    value: 'ring',
    label: 'Ring',
    drawing: (
      <>
        <circle cx="24" cy="24" r="11" fill="none" strokeWidth={3} className="stroke-current" />
        <circle cx="24" cy="24" r="2.5" />
      </>
    ),
  },
];

export default function ChoiceCardsOverview() {
  return (
    <ChoiceCards defaultValue="halo" aria-label="Pointer" className="max-w-md">
      {POINTERS.map(({ value, label, drawing }) => (
        <ChoiceCards.Item key={value} value={value}>
          <ChoiceCards.Media>
            <svg viewBox="0 0 48 48" className="size-12 fill-current stroke-background" strokeWidth={2}>
              {drawing}
            </svg>
          </ChoiceCards.Media>
          <ChoiceCards.Label>{label}</ChoiceCards.Label>
        </ChoiceCards.Item>
      ))}
    </ChoiceCards>
  );
}

Use it to choose one of a few options that are easier to see than to name: a pointer style, a canvas size, a background, an export quality. Each card leads with a picture of the option and its name sits under it.

It suits two to six options. Past that the pictures get too small to compare; use a Select with icons instead. For options that are only words, use Tabs or a radio group.

Give the group an aria-label, or point aria-labelledby at its heading, so a screen reader says what is being chosen.

Installation

npx shadcn@latest add https://panelui.dev/r/web/choice-cards.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import from panelui-web/choice-cards instead of @/components/panelui/choice-cards. Tailwind needs one line to read the package's classes, which is on the installation page.

Install the packages it uses:

npm install @base-ui/react tailwind-variants

Copy the source into components/panelui/choice-cards.tsx:

Usage

import { ChoiceCards } from "@/components/panelui/choice-cards"
<ChoiceCards defaultValue="square" aria-label="Canvas size">
  <ChoiceCards.Item value="square">
    <ChoiceCards.Media>
      <SquareIcon />
    </ChoiceCards.Media>
    <ChoiceCards.Label>Square</ChoiceCards.Label>
    <ChoiceCards.Description>1080 × 1080</ChoiceCards.Description>
  </ChoiceCards.Item>
</ChoiceCards>

Composition

<ChoiceCards>
  <ChoiceCards.Item>
    <ChoiceCards.Media />
    <ChoiceCards.Label />
    <ChoiceCards.Description />
  </ChoiceCards.Item>
</ChoiceCards>
  • ChoiceCards.Item — One option. Its value is what the group is set to when it is chosen. The chosen card is ringed in the group's colour and a check appears in its corner.
  • ChoiceCards.Media — The picture of the option: a drawing, an icon or an image, centred in a rounded frame at the group's aspect. An image fills it edge to edge. On the chosen card it grows slightly.
  • ChoiceCards.Label — The option's name, under the picture. It is also what a screen reader calls the card.
  • ChoiceCards.Description — A line under the name: what the option costs or what it is for. A screen reader reads it after the name.

Examples

Shapes

aspect sets the shape of every card's picture. Here square pictures hold a frame drawn at each ratio, so the options can be compared at a glance.

'use client';

import { useState } from 'react';
import { ChoiceCards } from '@/components/panelui/choice-cards';

const RATIOS = [
  { value: '16:9', label: 'Widescreen', width: 16, height: 9 },
  { value: '4:5', label: 'Portrait post', width: 4, height: 5 },
  { value: '1:1', label: 'Square', width: 1, height: 1 },
  { value: '9:16', label: 'Story', width: 9, height: 16 },
];

export default function ChoiceCardsAspect() {
  const [ratio, setRatio] = useState('16:9');
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ChoiceCards value={ratio} onValueChange={setRatio} aria-label="Canvas size" aspect="1 / 1">
        {RATIOS.map(({ value, label, width, height }) => {
          const scale = 50 / Math.max(width, height);
          return (
            <ChoiceCards.Item key={value} value={value}>
              <ChoiceCards.Media>
                <span
                  className="rounded-md border-2 border-current bg-current/10"
                  style={{ width: width * scale, height: height * scale }}
                />
              </ChoiceCards.Media>
              <ChoiceCards.Label>{label}</ChoiceCards.Label>
              <ChoiceCards.Description className="tabular-nums">{value}</ChoiceCards.Description>
            </ChoiceCards.Item>
          );
        })}
      </ChoiceCards>
    </div>
  );
}

Images

Anything that fills the media fills it to the edge, so a gradient, a photo or a video still works as the picture. columns sets how many cards stand in a row.

'use client';

import { ChoiceCards } from '@/components/panelui/choice-cards';

const BACKGROUNDS = [
  { value: 'dusk', label: 'Dusk', image: 'linear-gradient(140deg, var(--chart-1), var(--chart-4))' },
  { value: 'lagoon', label: 'Lagoon', image: 'linear-gradient(140deg, var(--chart-2), var(--chart-3))' },
  { value: 'ember', label: 'Ember', image: 'radial-gradient(circle at 30% 20%, var(--chart-5), var(--chart-1) 70%)' },
  { value: 'mesh', label: 'Mesh', image: 'conic-gradient(from 200deg at 60% 40%, var(--chart-3), var(--chart-5), var(--chart-2), var(--chart-3))' },
  {
    value: 'none',
    label: 'None',
    image: 'repeating-conic-gradient(var(--muted) 0 25%, transparent 0 50%) 0 0 / 12px 12px',
  },
];

export default function ChoiceCardsBackgrounds() {
  return (
    <ChoiceCards defaultValue="lagoon" columns={5} aspect="1 / 1" aria-label="Background" className="max-w-lg">
      {BACKGROUNDS.map(({ value, label, image }) => (
        <ChoiceCards.Item key={value} value={value}>
          <ChoiceCards.Media>
            <span className="size-full" style={{ background: image }} />
          </ChoiceCards.Media>
          <ChoiceCards.Label>{label}</ChoiceCards.Label>
        </ChoiceCards.Item>
      ))}
    </ChoiceCards>
  );
}

With descriptions

A ChoiceCards.Description under the label says what the option costs. color sets the ring and the check; here info.

'use client';

import { ChoiceCards } from '@/components/panelui/choice-cards';

const QUALITY = [
  { value: '720', label: 'Draft', size: '720p', description: 'About 40 MB. Quick to send for review.' },
  { value: '1080', label: 'Standard', size: '1080p', description: 'About 120 MB. Right for most sharing.' },
  { value: '2160', label: 'Master', size: '4K', description: 'About 620 MB. For editing elsewhere.' },
];

export default function ChoiceCardsDescriptions() {
  return (
    <ChoiceCards defaultValue="1080" aspect="5 / 2" color="info" aria-label="Export quality" className="max-w-lg">
      {QUALITY.map(({ value, label, size, description }) => (
        <ChoiceCards.Item key={value} value={value}>
          <ChoiceCards.Media>
            <span className="text-2xl font-semibold tracking-tight tabular-nums">{size}</span>
          </ChoiceCards.Media>
          <ChoiceCards.Label>{label}</ChoiceCards.Label>
          <ChoiceCards.Description>{description}</ChoiceCards.Description>
        </ChoiceCards.Item>
      ))}
    </ChoiceCards>
  );
}

API Reference

ChoiceCards

PropTypeDefaultDescription
columnsnumber—How many cards stand in a row. Default: one per item, up to four.
aspectstring'4 / 3'The shape of every card's picture, as a CSS aspect ratio. Default 4 / 3.
colorChoiceCardsColorprimaryThe colour of the ring and the check on the chosen card. Default primary.
classNamestring—
styleCSSProperties—

ChoiceCards.Item

PropTypeDefaultDescription
valuestring—What choosing this card sets the group's value to.
classNamestring—

Every part also takes the props of the element it renders, and a className.

Notes

Keyboard

The group is one stop in the tab order. The arrow keys move the choice from card to card, and Space chooses the focused one.

Columns

Without columns, the cards share one row, up to four of them. Set columns for a grid, and use a narrower count on a small screen by passing it from your own breakpoint.

Forms

Give it a name and it submits the chosen value with the form it is in. required stops the form being sent before a card is chosen.

With reduced motion turned on, the check and the picture change without moving.

Server Components

ChoiceCards is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read ChoiceCards.Item off the import, so each part is also exported by name: ChoiceCardsItem is ChoiceCards.Item, and so on for the others.

import { ChoiceCards, ChoiceCardsItem, ChoiceCardsMedia, ChoiceCardsLabel, ChoiceCardsDescription } from "@/components/panelui/choice-cards"

On this page