Component
Radio With Icons
Radio with an optional leading icon and selected indicator icon that communicate meaning.
- 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-with-icons/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Radios/radio-with-icons React/Components/Radios/radio-with-icons 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 RadioWithIconsProps {
label: ReactNode;
icon?: ReactNode;
/** Optional trailing indicator icon shown when selected. */
selectedIcon?: 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 radio with an optional leading icon that communicates meaning (e.g. a
* workspace-type glyph). Icons are ReactNode and must not be purely
* decorative — omit `icon` when none adds meaning. Built on the native
* `<input type="radio">`; the icon sits in the clickable label.
*/
export function RadioWithIcons({
label,
icon,
selectedIcon,
checked,
defaultChecked,
onChange,
disabled,
required,
invalid,
name,
value,
id,
"aria-describedby": ariaDescribedby,
className,
}: RadioWithIconsProps) {
const generatedId = useId();
const inputId = id ?? `radio-${generatedId}`;
const isControlled = checked !== undefined;
const [internal, setInternal] = useState<boolean>(defaultChecked ?? false);
const isChecked = isControlled ? checked : internal;
function handleChange(event: ChangeEvent<HTMLInputElement>) {
if (!isControlled) setInternal(event.target.checked);
onChange?.(event);
}
return (
<label
htmlFor={inputId}
className={cx(
"inline-flex items-center gap-2.5 text-sm leading-5 text-[var(--ds-color-foreground)]",
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
className,
)}
>
<span className="relative 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={ariaDescribedby}
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>
{icon ? (
<span
className={cx(
"inline-flex size-4 shrink-0 items-center justify-center",
isChecked ? "text-[var(--ds-color-primary)]" : "text-[var(--ds-color-muted-foreground)]",
)}
>
{icon}
</span>
) : null}
<span className="select-none">
{label}
{required ? (
<span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span>
) : null}
</span>
{selectedIcon && isChecked ? (
<span className="ml-auto inline-flex size-4 shrink-0 items-center justify-center text-[var(--ds-color-primary)]" aria-hidden="true">
{selectedIcon}
</span>
) : null}
</label>
);
}
export default RadioWithIcons; /* 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 RadioWithIcons({
label,
icon,
selectedIcon,
checked,
defaultChecked,
onChange,
disabled,
required,
invalid,
name,
value,
id,
"aria-describedby": ariaDescribedby,
className
}) {
const generatedId = useId();
const inputId = id ?? `radio-${generatedId}`;
const isControlled = checked !== undefined;
const [internal, setInternal] = useState(defaultChecked ?? false);
const isChecked = isControlled ? checked : internal;
function handleChange(event) {
if (!isControlled) setInternal(event.target.checked);
onChange?.(event);
}
return <label
htmlFor={inputId}
className={cx(
"inline-flex items-center gap-2.5 text-sm leading-5 text-[var(--ds-color-foreground)]",
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
className
)}
>
<span className="relative 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={ariaDescribedby}
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>
{icon ? <span
className={cx(
"inline-flex size-4 shrink-0 items-center justify-center",
isChecked ? "text-[var(--ds-color-primary)]" : "text-[var(--ds-color-muted-foreground)]"
)}
>
{icon}
</span> : null}
<span className="select-none">
{label}
{required ? <span aria-hidden="true" className="ml-0.5 text-[var(--ds-color-destructive)]">*</span> : null}
</span>
{selectedIcon && isChecked ? <span className="ml-auto inline-flex size-4 shrink-0 items-center justify-center text-[var(--ds-color-primary)]" aria-hidden="true">
{selectedIcon}
</span> : null}
</label>;
}
export default RadioWithIcons; # Radio With Icons
Radio with an optional leading icon and selected indicator icon that communicate meaning.
## Usage
```tsx
<RadioWithIcons label="Team workspace" icon={<Icon name="user" />} 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
<RadioWithIcons label="Team workspace" icon={<Icon name="user" />} 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. |
| `icon` | `ReactNode` | — | Leading icon that communicates meaning (color shifts on select). |
| `selectedIcon` | `ReactNode` | — | Trailing indicator shown when selected. |
## States
A radio with an optional leading icon that communicates meaning (e.g. a workspace-type glyph). Icons are ReactNode and must not be purely decorative — omit `icon` when none adds meaning. A trailing `selectedIcon` may be shown when the option is selected. Built on the native `<input type="radio">`; the icon sits in the clickable label.
## Accessibility
`<label htmlFor>` + native `<input type="radio">`. Icons are `aria-hidden` decoration — the label carries the accessible name. `aria-invalid` when `invalid`, visible `focus-visible` ring.
## 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
Only use icons that add meaning (e.g. distinguishing workspace types). Do not add a decorative icon to every basic radio. The leading icon is optional precisely so a plain labeled radio stays the default. 128 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-with-icons I want to use this DevSnips resource in production: Page: https://devsnips.site/library/react/components/radios/radio-with-icons/ Install: npx devsnips add React/Components/Radios/radio-with-icons This is the Radio With Icons — Radio with an optional leading icon and selected indicator icon that communicate meaning. 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-with-icons