Radio Cards
Selectable option cards with a title, detail and price; the radio ring fills and the card border tightens.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/radio-cards.json'use client'
import { useRef, useState } from 'react'
import './radio-cards.css'
export interface RadioCardOption { value: string; title: string; detail?: string; aside?: string }
export interface RadioCardsProps {
options: RadioCardOption[]
value?: string
defaultValue?: string
onValueChange?: (v: string) => void
label?: string
layout?: 'stack' | 'grid'
fill?: 'dark' | 'light'
className?: string
}
/** Option cards that behave like radios. */
export function RadioCards({ options, value, defaultValue, onValueChange, label = 'Options', layout = 'stack', fill = 'dark', className }: RadioCardsProps) {
const [inner, setInner] = useState(defaultValue ?? options[0]?.value)
const v = value ?? inner
const refs = useRef<(HTMLButtonElement | null)[]>([])
const pick = (i: number) => {
const j = (i + options.length) % options.length
setInner(options[j].value); onValueChange?.(options[j].value); refs.current[j]?.focus()
}
return (
<div role="radiogroup" aria-label={label} className={['sd-rcd', className].filter(Boolean).join(' ')} data-fill={fill} data-layout={layout}>
{options.map((o, i) => (
<button
key={o.value}
ref={(el) => { refs.current[i] = el }}
type="button"
role="radio"
aria-checked={o.value === v}
tabIndex={o.value === v ? 0 : -1}
className="sd-rcd__card"
onClick={() => pick(i)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') (e.preventDefault(), pick(i + 1))
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') (e.preventDefault(), pick(i - 1))
}}
>
<span className="sd-rcd__ring" aria-hidden />
<span><span className="sd-rcd__t">{o.title}</span>{o.detail && <span className="sd-rcd__d" style={{ display: 'block' }}>{o.detail}</span>}</span>
{o.aside && <span className="sd-rcd__aside">{o.aside}</span>}
</button>
))}
</div>
)
}.sd-rcd { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-rcd[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-rcd button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rcd :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rcd .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rcd { width: min(100%, 320px); display: grid; gap: 8px; }
.sd-rcd[data-layout='grid'] { grid-template-columns: 1fr 1fr; }
.sd-rcd__card { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: start; padding: 14px; border-radius: 14px; text-align: left; background: var(--bg) !important; box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow 220ms, transform 150ms; }
.sd-rcd[data-layout='grid'] .sd-rcd__card { grid-template-columns: auto 1fr; }
.sd-rcd[data-layout='grid'] .sd-rcd__aside { grid-column: 2; }
.sd-rcd__card:hover { box-shadow: inset 0 0 0 1px var(--mute); }
.sd-rcd__card:active { transform: scale(0.985); }
.sd-rcd__card[aria-checked='true'] { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-rcd__ring { position: relative; width: 18px; height: 18px; margin-top: 1px; border-radius: 99px; box-shadow: inset 0 0 0 1.5px var(--mute); transition: box-shadow 200ms; }
.sd-rcd__ring::after { content: ''; position: absolute; inset: 4px; border-radius: 99px; background: var(--fg); transform: scale(0); transition: transform 300ms cubic-bezier(0.3, 1.8, 0.4, 1); }
.sd-rcd__card[aria-checked='true'] .sd-rcd__ring { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-rcd__card[aria-checked='true'] .sd-rcd__ring::after { transform: scale(1); }
.sd-rcd__t { font-weight: 600; font-size: 14px; }
.sd-rcd__d { font-size: 12.5px; color: var(--mute); margin-top: 2px; }
.sd-rcd__aside { font-weight: 600; font-size: 13.5px; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .sd-rcd *, .sd-rcd *::before, .sd-rcd *::after { animation: none !important; transition: none !important; } }Overview
Bigger hit targets than radio buttons, with room for a description and a trailing value such as a price or ETA.
Selecting fills the ring with a springy dot and draws the card outline in the foreground colour. Arrow keys move the selection like native radios.
When to use
- Shipping speed, plan choice, payment method.
When not to use
- Long option lists: use a select.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/radio-cards.json$
npx shadcn@latest add https://sundaeui.dev/r/radio-cards.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/radio-cards.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/radio-cards.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy radio-cards.tsx and radio-cards.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { RadioCards } from '@/components/sundae/radio-cards'
<RadioCards />Props
| Prop | Type | Default |
|---|---|---|
options*Cards. | { value: string; title: string; detail?: string; aside?: string }[] | — |
value / defaultValueSelection. | string | — |
onValueChangeCalled on change. | (v: string) => void | — |
labelGroup label. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- role="radiogroup" with role="radio" cards and roving tabindex.
- Arrow keys select the previous / next card.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rcd; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Previous / next option
FAQ
- Is Radio Cards free?
- Yes. Radio Cards is free for personal and commercial projects, with full source on this page.
- How do I install Radio Cards?
- Run npx shadcn@latest add https://sundaeui.dev/r/radio-cards.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Radio Cards support keyboard and screen readers?
- role="radiogroup" with role="radio" cards and roving tabindex. Arrow keys select the previous / next card.
- Does Radio Cards respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Rating Scale
Forms · RefinedTransfer List
Forms · Refined- Pro
Address Autocomplete
Forms · Refined OTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined Combobox
Forms · Understated