Skip to content
  1. Selection
  2. Radio card

Radio card

One choice among a few, each with a title and a line to tell them apart. A native radio sits in every card, so the arrow keys and the grouping come from the browser.

Customer type
import { Fieldset, Legend } from '@/components/fieldset'
import { RadioCard, RadioCardGroup } from '@/components/radio-card'
import { BuildingOfficeIcon, UserIcon } from '@heroicons/react/16/solid'

export default function Example() {
  return (
    <Fieldset>
      <Legend>Customer type</Legend>
      <RadioCardGroup className="mt-3" name="customer_type" defaultValue="individual">
        <RadioCard value="individual" title="Individual" description="A person paying in their own name." icon={<UserIcon />} />
        <RadioCard value="business" title="Business" description="A company, with its registration details." icon={<BuildingOfficeIcon />} />
      </RadioCardGroup>
    </Fieldset>
  )
}
<script setup lang="ts">
import Fieldset from '@/components/fieldset.vue'
import Legend from '@/components/legend.vue'
import RadioCard from '@/components/radio-card.vue'
import RadioCardGroup from '@/components/radio-card-group.vue'
import { BuildingOfficeIcon, UserIcon } from '@heroicons/vue/16/solid'
</script>

<template>
  <Fieldset>
    <Legend>Customer type</Legend>
    <RadioCardGroup class="mt-3" name="customer_type" default-value="individual">
      <RadioCard value="individual" title="Individual" description="A person paying in their own name.">
        <template #icon><UserIcon /></template>
      </RadioCard>
      <RadioCard value="business" title="Business" description="A company, with its registration details.">
        <template #icon><BuildingOfficeIcon /></template>
      </RadioCard>
    </RadioCardGroup>
  </Fieldset>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import clsx from 'clsx'
import type React from 'react'
import { createContext, useContext, useId } from 'react'

type Group = {
  name: string
  value?: string
  defaultValue?: string
  onChange?: (value: string) => void
  disabled: boolean
}

const GroupContext = createContext<Group | null>(null)

const columns = {
  1: '',
  2: 'sm:grid-cols-2',
  3: 'sm:grid-cols-2 lg:grid-cols-3',
}

export function RadioCardGroup({
  name,
  value,
  defaultValue,
  onChange,
  columns: count = 2,
  disabled = false,
  className,
  ...props
}: {
  name?: string
  value?: string
  defaultValue?: string
  onChange?: (value: string) => void
  columns?: keyof typeof columns
  disabled?: boolean
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange'>) {
  let id = useId()

  return (
    <GroupContext.Provider value={{ name: name ?? id, value, defaultValue, onChange, disabled }}>
      <div role="radiogroup" {...props} className={clsx(className, 'grid gap-3', columns[count])} />
    </GroupContext.Provider>
  )
}

export function RadioCard({
  value,
  title,
  description,
  icon,
  disabled = false,
  className,
  children,
  ...props
}: {
  value: string
  title: React.ReactNode
  description?: React.ReactNode
  icon?: React.ReactNode
  disabled?: boolean
} & Omit<React.ComponentPropsWithoutRef<'label'>, 'title'>) {
  let group = useContext(GroupContext)
  if (!group) throw new Error('RadioCard must be inside a RadioCardGroup')

  return (
    <label
      {...props}
      className={clsx(
        className,
        'group relative flex cursor-pointer items-start gap-3 rounded-xl p-4 ring-1 ring-zinc-950/10 dark:ring-white/10',
        'hover:ring-zinc-950/20 dark:hover:ring-white/20',
        'has-checked:ring-2 has-checked:ring-zinc-950 dark:has-checked:ring-white',
        'has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-accent-500',
        'has-disabled:cursor-not-allowed has-disabled:opacity-50'
      )}
    >
      <input
        type="radio"
        name={group.name}
        value={value}
        checked={group.value === undefined ? undefined : group.value === value}
        defaultChecked={group.value === undefined ? group.defaultValue === value : undefined}
        disabled={disabled || group.disabled}
        onChange={() => group.onChange?.(value)}
        className="sr-only"
      />
      {icon && (
        <span className="flex h-lh shrink-0 items-center text-base/6 text-zinc-400 group-has-checked:text-zinc-950 sm:text-sm/6 dark:text-zinc-500 dark:group-has-checked:text-white *:size-4 *:shrink-0">
          {icon}
        </span>
      )}
      <span className="min-w-0 flex-1">
        <span className="block text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">{title}</span>
        {description && (
          <span className="mt-0.5 block text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400">{description}</span>
        )}
        {children}
      </span>
    </label>
  )
}
<script setup lang="ts">
import { inject } from 'vue'
import { RadioCardGroupKey } from './radio-card-group.vue'

withDefaults(
  defineProps<{
    value: string
    title: string
    description?: string
    disabled?: boolean
  }>(),
  { description: undefined, disabled: false }
)

const group = inject(RadioCardGroupKey)
if (!group) throw new Error('RadioCard must be inside a RadioCardGroup')
</script>

<template>
  <label
    :class="[
      'group relative flex cursor-pointer items-start gap-3 rounded-xl p-4 ring-1 ring-zinc-950/10 dark:ring-white/10',
      'hover:ring-zinc-950/20 dark:hover:ring-white/20',
      'has-checked:ring-2 has-checked:ring-zinc-950 dark:has-checked:ring-white',
      'has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-accent-500',
      'has-disabled:cursor-not-allowed has-disabled:opacity-50',
    ]"
  >
    <input
      type="radio"
      :name="group.name"
      :value="value"
      :checked="(group.model.value ?? group.defaultValue) === value"
      :disabled="disabled || group.disabled.value"
      class="sr-only"
      @change="group.model.value = value"
    />
    <span
      v-if="$slots.icon"
      class="flex h-lh shrink-0 items-center text-base/6 text-zinc-400 group-has-checked:text-zinc-950 sm:text-sm/6 dark:text-zinc-500 dark:group-has-checked:text-white *:size-4 *:shrink-0"
    >
      <slot name="icon" />
    </span>
    <span class="min-w-0 flex-1">
      <span class="block text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">{{ title }}</span>
      <span v-if="description" class="mt-0.5 block text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400">
        {{ description }}
      </span>
      <slot />
    </span>
  </label>
</template>

Component API

PropDefaultDescription
RadioCardGrouprenders a <div> element
value-The selected value. Leave it out to let the radios hold the choice.
defaultValue-The card checked at first when `value` is left out.
onChange-Called with the value of the card picked.
columns2Cards per row from `sm`. Three adds a third column from `lg`.
name-Name of the radios. Generated by default; set it when two groups sit in one form.
disabledfalseDisables every card.
RadioCardrenders a <label> element
value-What picking this card selects.
title-The first line, bold.
description-The line under it, muted.
icon-A 16px icon before the text. Muted until the card is checked.
disabledfalseDisables this card only.
children-More under the description: a price, a reference.
PropDefaultDescription
RadioCardGrouprenders a <div> element
v-model-The selected value. Leave it out to let the radios hold the choice.
defaultValue-The card checked at first when `v-model` is left out.
columns2Cards per row from `sm`. Three adds a third column from `lg`.
name-Name of the radios. Generated by default; set it when two groups sit in one form.
disabledfalseDisables every card.
RadioCardrenders a <label> element
value-What picking this card selects.
title-The first line, bold.
description-The line under it, muted.
#icon-A 16px icon before the text. Muted until the card is checked.
disabledfalseDisables this card only.
#default-More under the description: a price, a reference.

Examples

Basic example

Group the cards under a Legend. Reserve the icon for choices the reader tells apart at a glance, a person and a company, a card and a wallet. Without value, the radios keep the choice and a form post sends it under name:

Customer type
import { Fieldset, Legend } from '@/components/fieldset'
import { RadioCard, RadioCardGroup } from '@/components/radio-card'
import { BuildingOfficeIcon, UserIcon } from '@heroicons/react/16/solid'

export default function Example() {
  return (
    <Fieldset>
      <Legend>Customer type</Legend>
      <RadioCardGroup className="mt-3" name="customer_type" defaultValue="individual">
        <RadioCard value="individual" title="Individual" description="A person paying in their own name." icon={<UserIcon />} />
        <RadioCard value="business" title="Business" description="A company, with its registration details." icon={<BuildingOfficeIcon />} />
      </RadioCardGroup>
    </Fieldset>
  )
}
<script setup lang="ts">
import Fieldset from '@/components/fieldset.vue'
import Legend from '@/components/legend.vue'
import RadioCard from '@/components/radio-card.vue'
import RadioCardGroup from '@/components/radio-card-group.vue'
import { BuildingOfficeIcon, UserIcon } from '@heroicons/vue/16/solid'
</script>

<template>
  <Fieldset>
    <Legend>Customer type</Legend>
    <RadioCardGroup class="mt-3" name="customer_type" default-value="individual">
      <RadioCard value="individual" title="Individual" description="A person paying in their own name.">
        <template #icon><UserIcon /></template>
      </RadioCard>
      <RadioCard value="business" title="Business" description="A company, with its registration details.">
        <template #icon><BuildingOfficeIcon /></template>
      </RadioCard>
    </RadioCardGroup>
  </Fieldset>
</template>

Descriptions only

Without icons the title carries the choice. Keep every description to one line, they line up across the row:

<RadioCardGroup value={preference} onChange={setPreference}>
  {preferences.map((option) => (
    <RadioCard key={option.value} value={option.value} title={option.label} description={option.description} />
  ))}
</RadioCardGroup>
<RadioCardGroup v-model="preference">
  <RadioCard v-for="option in preferences" :key="option.value" :value="option.value" :title="option.label" :description="option.description" />
</RadioCardGroup>

Three columns

columns={3} for short options that fit side by side from lg; from sm they still go two by two:

<RadioCardGroup columns={3} value={type} onChange={setType}>
  {fieldTypes.map((option) => (
    <RadioCard key={option.value} value={option.value} title={option.label} description={option.description} icon={<option.icon />} />
  ))}
</RadioCardGroup>
<RadioCardGroup v-model="type" :columns="3">
  <RadioCard v-for="option in fieldTypes" :key="option.value" :value="option.value" :title="option.label" :description="option.description">
    <template #icon><component :is="option.icon" /></template>
  </RadioCard>
</RadioCardGroup>

Disabled option

A card that cannot be picked stays in the row, faded, with its description saying why:

<RadioCardGroup value={channel} onChange={setChannel}>
  <RadioCard value="bank" title="Bank transfer" description="One to three business days." />
  <RadioCard value="wallet" title="Mobile wallet" description="Not available for this country yet." disabled />
</RadioCardGroup>
<RadioCardGroup v-model="channel">
  <RadioCard value="bank" title="Bank transfer" description="One to three business days." />
  <RadioCard value="wallet" title="Mobile wallet" description="Not available for this country yet." disabled />
</RadioCardGroup>