Component
Radio Card Group
Group of selectable card radios in a fieldset/legend; only one may be selected.
- Component
- React
- TSX
- Tailwind CSS
- MIT
What it does
Capabilities
- Semantic HTML
- Native form control
- Native checkbox
- Associated labels
- ARIA
Preview
Live component
9:41 100%
devsnips.site/library/react/components/radios/radio-card-group/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Radios/radio-card-group React/Components/Radios/radio-card-group import type { ChangeEvent, ReactNode } from "react";
import { useId, useState } from "react";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export interface RadioCardOption {
value: string;
label: ReactNode;
description?: ReactNode;
disabled?: boolean;
}
export interface RadioCardGroupProps {
legend: ReactNode;
options: RadioCardOption[];
value?: string;
defaultValue?: string;
onChange?: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
error?: string;
helperText?: ReactNode;
name?: string;
id?: string;
columns?: 1 | 2 | 3;
className?: string;
}
/**
* A group of selectable card radios inside a `<fieldset>`/`<legend>`. Only
* one card may be selected. Controlled and uncontrolled modes both supported.
* Each card is a clickable `<label>` wrapping a real `<input type="radio">`.
*/
export function RadioCardGroup({
legend,
options,
value,
defaultValue = "",
onChange,
disabled,
required,
invalid,
error,
helperText,
name,
id,
columns = 1,
className,
}: RadioCardGroupProps) {
const generatedId = useId();
const groupId = id ?? `radio-card-group-${generatedId}`;
const groupName = name ?? groupId;
const messageId = `${groupId}-msg`;
const isControlled = value !== undefined;
const [internal, setInternal] = useState<string>(defaultValue);
const selected = isControlled ? value : internal;
function handleChange(option: RadioCardOption, event: ChangeEvent<HTMLInputElement>) {
if (!isControlled) setInternal(option.value);
onChange?.(option.value, event);
}
const describedby = error || helperText ? messageId : undefined;
const gridCols = columns === 3 ? "sm:grid-cols-3" : columns === 2 ? "sm:grid-cols-2" : "grid-cols-1";
return (
<fieldset
id={groupId}
className={cx("min-w-0 border-0 p-0", className)}
aria-invalid={invalid || error ? true : undefined}
aria-describedby={describedby}
>
<legend className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">
{legend}
{required ? (
<span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span>
) : null}
</legend>
<div className={cx("grid gap-2", gridCols)}>
{options.map((option) => {
const isOn = selected === option.value;
const isDisabled = option.disabled || disabled;
return (
<label
key={option.value}
htmlFor={`${groupId}-${option.value}`}
className={cx(
"relative flex w-full items-start gap-3 rounded-[var(--ds-radius-md)] border bg-[var(--ds-color-surface)] p-3.5 text-left transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-within:border-[var(--ds-color-border-strong)] motion-reduce:transition-none",
isDisabled && "cursor-not-allowed opacity-60 hover:bg-[var(--ds-color-surface)]",
isOn ? "border-[var(--ds-color-primary)]" : "border-[var(--ds-color-border)]",
(invalid || error) && "border-[var(--ds-color-destructive)]",
)}
>
<span className="relative mt-0.5 inline-flex size-[18px] shrink-0 items-center justify-center">
<input
id={`${groupId}-${option.value}`}
type="radio"
name={groupName}
value={option.value}
checked={isOn}
disabled={isDisabled}
required={required}
aria-invalid={invalid || error ? true : undefined}
aria-describedby={option.description ? `${groupId}-${option.value}-desc` : undefined}
className={cx(
"size-[18px] cursor-pointer appearance-none rounded-full border bg-[var(--ds-color-input)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
invalid || error
? "border-[var(--ds-color-destructive)]"
: "border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)]",
)}
onChange={(e) => handleChange(option, e)}
/>
<span
aria-hidden="true"
className={cx(
"pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-150 motion-reduce:transition-none",
isOn ? "opacity-100" : "opacity-0",
)}
>
<span
className={cx(
"block size-[8px] rounded-full",
invalid || error ? "bg-[var(--ds-color-destructive)]" : "bg-[var(--ds-color-primary)]",
)}
/>
</span>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">{option.label}</span>
{option.description ? (
<span id={`${groupId}-${option.value}-desc`} className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
{option.description}
</span>
) : null}
</span>
</label>
);
})}
</div>
{error ? (
<p id={messageId} role="alert" className="mt-2 text-xs leading-4 text-[var(--ds-color-destructive)]">
{error}
</p>
) : helperText ? (
<p id={messageId} className="mt-2 text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
{helperText}
</p>
) : null}
</fieldset>
);
}
export default RadioCardGroup; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { useId, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
export function RadioCardGroup({
legend,
options,
value,
defaultValue = "",
onChange,
disabled,
required,
invalid,
error,
helperText,
name,
id,
columns = 1,
className
}) {
const generatedId = useId();
const groupId = id ?? `radio-card-group-${generatedId}`;
const groupName = name ?? groupId;
const messageId = `${groupId}-msg`;
const isControlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue);
const selected = isControlled ? value : internal;
function handleChange(option, event) {
if (!isControlled) setInternal(option.value);
onChange?.(option.value, event);
}
const describedby = error || helperText ? messageId : undefined;
const gridCols = columns === 3 ? "sm:grid-cols-3" : columns === 2 ? "sm:grid-cols-2" : "grid-cols-1";
return <fieldset
id={groupId}
className={cx("min-w-0 border-0 p-0", className)}
aria-invalid={invalid || error ? true : undefined}
aria-describedby={describedby}
>
<legend className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]">
{legend}
{required ? <span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span> : null}
</legend>
<div className={cx("grid gap-2", gridCols)}>
{options.map((option) => {
const isOn = selected === option.value;
const isDisabled = option.disabled || disabled;
return <label
key={option.value}
htmlFor={`${groupId}-${option.value}`}
className={cx(
"relative flex w-full items-start gap-3 rounded-[var(--ds-radius-md)] border bg-[var(--ds-color-surface)] p-3.5 text-left transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-within:border-[var(--ds-color-border-strong)] motion-reduce:transition-none",
isDisabled && "cursor-not-allowed opacity-60 hover:bg-[var(--ds-color-surface)]",
isOn ? "border-[var(--ds-color-primary)]" : "border-[var(--ds-color-border)]",
(invalid || error) && "border-[var(--ds-color-destructive)]"
)}
>
<span className="relative mt-0.5 inline-flex size-[18px] shrink-0 items-center justify-center">
<input
id={`${groupId}-${option.value}`}
type="radio"
name={groupName}
value={option.value}
checked={isOn}
disabled={isDisabled}
required={required}
aria-invalid={invalid || error ? true : undefined}
aria-describedby={option.description ? `${groupId}-${option.value}-desc` : undefined}
className={cx(
"size-[18px] cursor-pointer appearance-none rounded-full border bg-[var(--ds-color-input)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
invalid || error ? "border-[var(--ds-color-destructive)]" : "border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)]"
)}
onChange={(e) => handleChange(option, e)}
/>
<span
aria-hidden="true"
className={cx(
"pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-150 motion-reduce:transition-none",
isOn ? "opacity-100" : "opacity-0"
)}
>
<span
className={cx(
"block size-[8px] rounded-full",
invalid || error ? "bg-[var(--ds-color-destructive)]" : "bg-[var(--ds-color-primary)]"
)}
/>
</span>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">{option.label}</span>
{option.description ? <span id={`${groupId}-${option.value}-desc`} className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
{option.description}
</span> : null}
</span>
</label>;
})}
</div>
{error ? <p id={messageId} role="alert" className="mt-2 text-xs leading-4 text-[var(--ds-color-destructive)]">
{error}
</p> : helperText ? <p id={messageId} className="mt-2 text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
{helperText}
</p> : null}
</fieldset>;
}
export default RadioCardGroup; # Radio Card Group
Group of selectable card radios in a fieldset/legend; only one may be selected.
## Usage
```tsx
<RadioCardGroup legend="Workspace plan" options={[{value:"personal",label:"Personal"},{value:"team",label:"Team"}]} defaultValue="team" columns={3} />
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
<RadioCardGroup legend="Workspace plan" options={[{value:"personal",label:"Personal"},{value:"team",label:"Team"}]} defaultValue="team" columns={3} />
```
## Props
| Name | Type | Default | Description |
|---|---|---:|---|
| `legend` | `ReactNode` (required) | — | Group label in `<legend>`. |
| `options` | `{value,label,description?,disabled?}[]` (required) | — | Card options. |
| `value` / `defaultValue` | `string` | `""` | Controlled / uncontrolled selected value. |
| `onChange` | `(value, event) => void` | — | Selection callback. |
| `columns` | `1 \| 2 \| 3` | `1` | Grid columns at the `sm` breakpoint and up. |
| `disabled` / `required` / `invalid` / `error` / `helperText` / `name` / `id` | — | — | Standard field props. |
## States
A group of selectable card radios inside a `<fieldset>`/`<legend>`. Only one card may be selected. Controlled and uncontrolled modes both supported. Each card is a clickable `<label>` wrapping a real `<input type="radio">`. The grid collapses to a single column below the `sm` breakpoint.
## Accessibility
`<fieldset>` + `<legend>`. Each card is a `<label htmlFor>` wrapping a native radio; descriptions linked with `aria-describedby`; `aria-invalid` + `role="alert"` error. Visible `focus-visible` ring; arrow keys navigate natively within the named group.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-primary)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This radio uses the semantic color, radius, spacing, and motion tokens.
## Notes
Use this when each option needs the visual weight of a card and exactly one selection is allowed. For multiple selections use `checkbox-card-group`. 156 lines UTF-8 · LF · Spaces: 2
DevSnips AI ready Claude · Cursor · Windsurf
Agent skill & Model Context Protocol
Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.
devsnips://components/radios/radio-card-group I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/radios/radio-card-group/ Install: npx devsnips add React/Components/Radios/radio-card-group This is the Radio Card Group — Group of selectable card radios in a fieldset/legend; only one may be selected. Use this DevSnips resource as the starting point for my project. Adapt it to my project's stack and conventions, fix any issues, and make it production-ready while preserving its intended design and behavior.
Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.
Resource URI
devsnips://components/radios/radio-card-group