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.
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
| Prop | Default | Description |
|---|---|---|
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. |
columns | 2 | Cards 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. |
disabled | false | Disables 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. |
disabled | false | Disables this card only. |
children | - | More under the description: a price, a reference. |
| Prop | Default | Description |
|---|---|---|
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. |
columns | 2 | Cards 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. |
disabled | false | Disables 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. |
disabled | false | Disables 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:
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>