Skip to contentVibraUI
Inputs & filters

Selectable card

Card-sized radios and checkboxes for picking a plan, a mode, or a set of sources.

Each card is a real button carrying its own role and aria-checked, so it is reachable and operable from the keyboard. The indicator picks both the mark and the role: a radio never unchecks itself, a checkbox toggles. SelectableCardGroup renders a single-choice grid as a radiogroup with roving arrow keys, and a multiple-choice grid as a plain group whose checkboxes are each tabbed to.

Install

npx shadcn@latest add @vibra/selectable-card

Needs the @vibra registry in your components.json — set it up once.

Examples

Single and multiple groups

A plan picker holding one choice, and a source picker holding several.

Props

PropTypeDefaultDescription
selectedboolean—Whether this card is chosen.
onSelectedChange(selected: boolean) => void—Called with the state the card moves to when it is activated.
titleReact.ReactNode—The card's heading.
descriptionReact.ReactNode—A line or two under the title saying what the choice means.
iconReact.ReactNode—Sits before the title; sized to 4 unless it sets its own size.
badgeReact.ReactNode—Sits beside the title, e.g. a Most popular badge.
indicator"radio" | "checkbox" | "none""checkbox"Picks the mark and the role; none hides the mark and keeps checkbox behaviour.
disabledbooleanfalseDims the card and stops it responding.
SelectableCardGroup.type"single" | "multiple"—single holds one value or null; multiple holds an array.
SelectableCardGroup.valuestring | null | string[]—The chosen value for a single group, or the chosen values for a multiple one.
SelectableCardGroup.onValueChange(value: string | null | string[]) => void—Called with the whole new selection.
SelectableCardGroup.columns1 | 2 | 3 | 42Columns at the widest breakpoint; the grid steps down to one on small screens.
SelectableCardGroup.options{ value: string; title: React.ReactNode; description?: React.ReactNode; icon?: React.ReactNode; badge?: React.ReactNode; disabled?: boolean }[]—The cards, in the order they are shown.

Dependencies

Source

components/ui/selectable-card.tsx
"use client"

import * as React from "react"
import { CheckIcon } from "lucide-react"

import { cn } from "@/lib/utils"

const COLUMN_CLASSES = {
  1: "grid-cols-1",
  2: "grid-cols-1 sm:grid-cols-2",
  3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
  4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
} as const

// "title" is omitted as well as "value": the button's own title attribute is a
// tooltip string, and this one is the card's heading.
export type SelectableCardProps = Omit<React.ComponentProps<"button">, "value" | "title"> & {
  selected: boolean
  onSelectedChange?: (selected: boolean) => void
  title: React.ReactNode
  description?: React.ReactNode
  /** Sits before the title; sized to 4 unless it sets its own size. */
  icon?: React.ReactNode
  /** Sits beside the title, e.g. a "Most popular" badge. */
  badge?: React.ReactNode
  /** Picks the mark and the role: radio for one of many, checkbox otherwise. */
  indicator?: "radio" | "checkbox" | "none"
  disabled?: boolean
}

/** A card-sized radio or checkbox for picking a plan, a mode, or a destination. */
function SelectableCard({
  className,
  selected,
  onSelectedChange,
  title,
  description,
  icon,
  badge,
  indicator = "checkbox",
  disabled = false,
  onClick,
  ...props
}: SelectableCardProps) {
  const isRadio = indicator === "radio"

  return (
    <button
      type="button"
      // A radio never unchecks itself; a checkbox toggles.
      role={isRadio ? "radio" : "checkbox"}
      aria-checked={selected}
      data-slot="selectable-card"
      data-selected={selected || undefined}
      data-indicator={indicator}
      disabled={disabled}
      onClick={(event) => {
        onClick?.(event)
        onSelectedChange?.(isRadio ? true : !selected)
      }}
      className={cn(
        // A disabled card mutes its text and dashes its border instead of fading the
        // whole card: the reason it is disabled (an "Expired" pill, a note) stays
        // readable at full contrast rather than compositing at half opacity.
        "relative flex w-full items-start gap-3 rounded-lg border border-input bg-card p-3 text-start transition-colors hover:bg-accent/40 focus-ring disabled:pointer-events-none disabled:border-dashed disabled:text-muted-foreground",
        // Chosen is a fill, never a stroke: the indicator carries the ink.
        selected && "bg-brand-muted",
        className
      )}
      {...props}
    >
      {icon ? (
        <span
          data-slot="selectable-card-icon"
          aria-hidden="true"
          className={cn(
            "mt-0.5 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
            selected ? "text-foreground" : "text-muted-foreground"
          )}
        >
          {icon}
        </span>
      ) : null}

      <span className="flex min-w-0 flex-1 flex-col gap-1">
        <span className="flex items-center gap-2">
          <span data-slot="selectable-card-title" className="truncate text-sm font-medium">
            {title}
          </span>
          {badge}
        </span>
        {description ? (
          <span
            data-slot="selectable-card-description"
            className="text-xs leading-relaxed text-muted-foreground"
          >
            {description}
          </span>
        ) : null}
      </span>

      {indicator === "none" ? null : (
        <span
          data-slot="selectable-card-indicator"
          aria-hidden="true"
          className={cn(
            "mt-0.5 flex size-4 shrink-0 items-center justify-center border border-input text-primary-foreground transition-colors",
            isRadio ? "rounded-full" : "rounded-[4px]",
            selected && "border-primary bg-primary"
          )}
        >
          {isRadio ? (
            <span
              className={cn(
                "size-1.5 rounded-full bg-current transition-opacity",
                selected ? "opacity-100" : "opacity-0"
              )}
            />
          ) : (
            <CheckIcon
              className={cn("size-3 transition-opacity", selected ? "opacity-100" : "opacity-0")}
            />
          )}
        </span>
      )}
    </button>
  )
}

export type SelectableCardOption = {
  value: string
  title: React.ReactNode
  description?: React.ReactNode
  icon?: React.ReactNode
  badge?: React.ReactNode
  disabled?: boolean
}

export type SelectableCardGroupProps<T extends "single" | "multiple"> =
  React.ComponentProps<"div"> & {
    type: T
    value: T extends "single" ? string | null : string[]
    onValueChange: (value: T extends "single" ? string | null : string[]) => void
    columns?: 1 | 2 | 3 | 4
    options: SelectableCardOption[]
  }

/** A grid of selectable cards holding one choice or several. */
function SelectableCardGroup<T extends "single" | "multiple">({
  className,
  type,
  value,
  onValueChange,
  columns = 2,
  options,
  ...props
}: SelectableCardGroupProps<T>) {
  const single = type === "single"
  const selectedValues = single ? (value ? [value as string] : []) : (value as string[])
  const cardRefs = React.useRef(new Map<string, HTMLButtonElement | null>())

  // The handler's parameter is conditional on T, which no call site inside a
  // generic body can satisfy; the branches below pass the right shape.
  const change = onValueChange as (next: string | null | string[]) => void

  function select(option: SelectableCardOption, next: boolean) {
    if (single) {
      change(next ? option.value : null)
      return
    }
    const current = value as string[]
    change(next ? [...current, option.value] : current.filter((item) => item !== option.value))
  }

  // Roving arrow keys, but only for the radiogroup: a set of checkboxes is
  // tabbed through one by one.
  function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
    if (!single) return

    const forward = event.key === "ArrowRight" || event.key === "ArrowDown"
    const back = event.key === "ArrowLeft" || event.key === "ArrowUp"
    if (!forward && !back) return
    const direction = forward ? 1 : -1

    event.preventDefault()
    const count = options.length
    for (let step = 1; step <= count; step += 1) {
      const next = options[(((index + direction * step) % count) + count) % count]
      if (next && !next.disabled) {
        change(next.value)
        cardRefs.current.get(next.value)?.focus()
        return
      }
    }
  }

  // Only one card in a radiogroup is in the tab order: the chosen one, or the
  // first that can take the choice.
  const chosen = options.find((option) => option.value === selectedValues[0] && !option.disabled)
  const firstEnabled = options.find((option) => !option.disabled)
  const tabStop = single ? ((chosen ?? firstEnabled)?.value ?? null) : null

  return (
    <div
      data-slot="selectable-card-group"
      data-type={type}
      data-columns={columns}
      role={single ? "radiogroup" : "group"}
      className={cn("grid w-full gap-3", COLUMN_CLASSES[columns], className)}
      {...props}
    >
      {options.map((option, index) => (
        <SelectableCard
          key={option.value}
          ref={(node) => {
            cardRefs.current.set(option.value, node)
          }}
          selected={selectedValues.includes(option.value)}
          onSelectedChange={(next) => select(option, next)}
          indicator={single ? "radio" : "checkbox"}
          title={option.title}
          description={option.description}
          icon={option.icon}
          badge={option.badge}
          disabled={option.disabled}
          tabIndex={single ? (option.value === tabStop ? 0 : -1) : undefined}
          onKeyDown={(event) => handleKeyDown(event, index)}
        />
      ))}
    </div>
  )
}

export { SelectableCard, SelectableCardGroup }