Component
Radio Card
Single selectable card wrapping a native radio; the whole card is a click target.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/radios/radio-card/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Radios/radio-card React/Components/Radios/radio-card 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 RadioCardProps {
label: ReactNode;
description?: ReactNode;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
name?: string;
value: string | number | readonly string[];
id?: string;
"aria-describedby"?: string;
className?: string;
}
/**
* A single selectable card wrapping a native radio. The entire card is an
* interactive target (clickable label) while the real `<input type="radio">`
* carries the semantics and value. The selected state is shown with an
* accent border, not color alone.
*/
export function RadioCard({
label,
description,
checked,
defaultChecked,
onChange,
disabled,
required,
invalid,
name,
value,
id,
"aria-describedby": ariaDescribedby,
className,
}: RadioCardProps) {
const generatedId = useId();
const inputId = id ?? `radio-card-${generatedId}`;
const descId = `${inputId}-desc`;
const isControlled = checked !== undefined;
const [internal, setInternal] = useState<boolean>(defaultChecked ?? false);
const isChecked = isControlled ? checked : internal;
const describedby = [description ? descId : null, ariaDescribedby].filter(Boolean).join(" ") || undefined;
function handleChange(event: ChangeEvent<HTMLInputElement>) {
if (!isControlled) setInternal(event.target.checked);
onChange?.(event);
}
return (
<label
htmlFor={inputId}
className={cx(
"relative flex w-full cursor-pointer 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",
disabled && "cursor-not-allowed opacity-60 hover:bg-[var(--ds-color-surface)]",
invalid || !isChecked ? "border-[var(--ds-color-border)]" : "border-[var(--ds-color-primary)]",
invalid && "border-[var(--ds-color-destructive)]",
className,
)}
>
<span className="relative mt-0.5 inline-flex size-[18px] shrink-0 items-center justify-center">
<input
id={inputId}
type="radio"
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
? "border-[var(--ds-color-destructive)]"
: "border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)]",
)}
checked={isControlled ? isChecked : undefined}
defaultChecked={isControlled ? undefined : defaultChecked}
disabled={disabled}
required={required}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedby}
name={name}
value={value}
onChange={handleChange}
/>
<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",
isChecked ? "opacity-100" : "opacity-0",
)}
>
<span
className={cx(
"block size-[8px] rounded-full",
invalid ? "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)]">
{label}
{required ? (
<span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span>
) : null}
</span>
{description ? (
<span id={descId} className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
{description}
</span>
) : null}
</span>
</label>
);
}
export default RadioCard; /* 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 RadioCard({
label,
description,
checked,
defaultChecked,
onChange,
disabled,
required,
invalid,
name,
value,
id,
"aria-describedby": ariaDescribedby,
className
}) {
const generatedId = useId();
const inputId = id ?? `radio-card-${generatedId}`;
const descId = `${inputId}-desc`;
const isControlled = checked !== undefined;
const [internal, setInternal] = useState(defaultChecked ?? false);
const isChecked = isControlled ? checked : internal;
const describedby = [description ? descId : null, ariaDescribedby].filter(Boolean).join(" ") || undefined;
function handleChange(event) {
if (!isControlled) setInternal(event.target.checked);
onChange?.(event);
}
return <label
htmlFor={inputId}
className={cx(
"relative flex w-full cursor-pointer 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",
disabled && "cursor-not-allowed opacity-60 hover:bg-[var(--ds-color-surface)]",
invalid || !isChecked ? "border-[var(--ds-color-border)]" : "border-[var(--ds-color-primary)]",
invalid && "border-[var(--ds-color-destructive)]",
className
)}
>
<span className="relative mt-0.5 inline-flex size-[18px] shrink-0 items-center justify-center">
<input
id={inputId}
type="radio"
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 ? "border-[var(--ds-color-destructive)]" : "border-[var(--ds-color-border)] checked:border-[var(--ds-color-primary)]"
)}
checked={isControlled ? isChecked : undefined}
defaultChecked={isControlled ? undefined : defaultChecked}
disabled={disabled}
required={required}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedby}
name={name}
value={value}
onChange={handleChange}
/>
<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",
isChecked ? "opacity-100" : "opacity-0"
)}
>
<span
className={cx(
"block size-[8px] rounded-full",
invalid ? "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)]">
{label}
{required ? <span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span> : null}
</span>
{description ? <span id={descId} className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
{description}
</span> : null}
</span>
</label>;
}
export default RadioCard; # Radio Card
Single selectable card wrapping a native radio; the whole card is a click target.
## Usage
```tsx
<RadioCard label="Team workspace" description="Shared with your organization." name="workspace" value="team" defaultChecked />
```
## 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
<RadioCard label="Team workspace" description="Shared with your organization." name="workspace" value="team" defaultChecked />
```
## Props
| Name | Type | Default | Description |
|---|---|---:|---|
| `label` | `ReactNode` (required) | — | Visible label (omit for an icon-only / aria-label control). |
| `checked` / `defaultChecked` | `boolean` | `false` | Controlled / uncontrolled checked state. |
| `onChange` | `(event) => void` | — | Change callback. |
| `disabled` | `boolean` | — | Disables the control. |
| `required` | `boolean` | — | Marks the field required (renders `*`). |
| `invalid` | `boolean` | — | Sets `aria-invalid` + destructive styling. |
| `name` | `string` | — | Shared group name (groups radios). |
| `value` | `string \| number \| readonly string[]` (required) | — | Native form value. |
| `id` | `string` | generated | Input id (also the label `htmlFor`). |
| `aria-label` / `aria-labelledby` / `aria-describedby` | `string` | — | Override association. |
| `description` | `ReactNode` | — | Supporting description linked with `aria-describedby`. |
## States
A single selectable card wrapping a native radio. The entire card is a `<label htmlFor>` (clickable) while the real `<input type="radio">` carries the semantics and value. The selected state is shown with an accent border, not color alone.
## Accessibility
`<label htmlFor>` card + native `<input type="radio">`. `aria-invalid` when `invalid`, description linked with `aria-describedby`, visible `focus-visible` ring on the input (the card shows `focus-within` border emphasis).
## 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 an option needs more visual weight than a plain row — e.g. a plan or workspace choice. For a group of these use `radio-card-group`. 121 lines UTF-8 · LF · Spaces: 2
Continue browsing