Radio
React Aria radio group with the gradient selected dot, two sizes, and the bare RadioDot glyph for menu rows.
Group
A radio group with one disabled option.
function RadioDemo() {
return (
<RadioGroup defaultValue="monthly" aria-label="Billing period">
<Radio value="monthly">Monthly</Radio>
<Radio value="yearly">Yearly</Radio>
<Radio value="lifetime" isDisabled>
Lifetime
</Radio>
</RadioGroup>
);
}function RadioDemo() {
return (
<RadioGroup defaultValue="monthly" aria-label="Billing period">
<Radio value="monthly">Monthly</Radio>
<Radio value="yearly">Yearly</Radio>
<Radio value="lifetime" isDisabled>
Lifetime
</Radio>
</RadioGroup>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/radio.jsonnpx shadcn@latest add https://boardcn.dev/r/radio.jsonnpm packages
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import type { ReactNode, Ref } from "react";
import {
Radio as AriaRadio,
RadioGroup as AriaRadioGroup,
} from "react-aria-components";
import type {
RadioGroupProps as AriaRadioGroupProps,
RadioProps as AriaRadioProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";
export type RadioSize = "sm" | "md";
const dotStyles = {
sm: {
dot: "size-3.5",
inner: "size-[5px]",
selected:
"bg-gradient-to-b from-accent-500 to-accent-600 shadow-[inset_0px_0px_0px_0.875px_var(--color-accent-500),inset_0px_1.75px_0px_0px_rgba(255,255,255,0.25)]",
border: "border-[0.875px]",
gap: "gap-2",
label: "text-body-2-medium",
},
md: {
dot: "size-4",
inner: "size-1.5",
selected:
"bg-gradient-to-b from-accent-500 to-accent-600 shadow-[inset_0px_0px_0px_1px_var(--color-accent-500),inset_0px_2px_0px_0px_rgba(255,255,255,0.25)]",
border: "border",
gap: "gap-2",
label: "text-body-medium",
},
};
export interface RadioDotProps {
selected?: boolean;
size?: RadioSize;
/** Draws the keyboard focus ring around the glyph. */
focusVisible?: boolean;
className?: string;
}
/**
* The bare radio glyph — presentation only, no interaction or semantics.
*
* Select/deselect animates: the default bordered surface and the selected
* gradient are stacked layers cross-fading over 200ms (a gradient background
* can't transition directly), while the white inner dot scales in and out.
*/
export function RadioDot({ selected = false, size = "sm", focusVisible = false, className }: RadioDotProps) {
const s = dotStyles[size];
return (
<span
aria-hidden
className={cx(
"relative flex shrink-0 items-center justify-center rounded-full",
s.dot,
focusVisible && "ring-2 ring-border-focus-ring ring-offset-2",
className,
)}
>
{/* Default surface */}
<span
className={cx(
"absolute inset-0 rounded-full border-border-checkbox-default bg-background-primary-default shadow-xs",
"transition-opacity duration-200 ease",
s.border,
selected && "opacity-0",
)}
/>
{/* Selected gradient surface */}
<span
className={cx(
"absolute inset-0 rounded-full",
"transition-opacity duration-200 ease",
s.selected,
!selected && "opacity-0",
)}
/>
{/* Inner dot: absolutely centered (flex centering of fractional sizes can
land on subpixels) and no drop shadow — a downward shadow makes the
dot read as sitting below center. */}
<span
className={cx(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-control-indicator-background",
"transition-[scale,opacity] duration-200 ease",
s.inner,
selected ? "scale-100 opacity-100" : "scale-0 opacity-0",
)}
/>
</span>
);
}
/* ------------------------------------------------------------------- group */
export interface RadioGroupProps extends Omit<AriaRadioGroupProps, "children"> {
children?: ReactNode;
ref?: Ref<HTMLDivElement>;
}
/** Wraps a set of Radios: arrow-key navigation, single selection, form value. */
export function RadioGroup({ className, children, ref, ...props }: RadioGroupProps) {
return (
<AriaRadioGroup
ref={ref}
{...props}
className={(state) =>
cx(
"flex flex-col gap-2",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
</AriaRadioGroup>
);
}
/* ------------------------------------------------------------------- radio */
export interface RadioProps extends Omit<AriaRadioProps, "children"> {
children?: ReactNode;
size?: RadioSize;
ref?: Ref<HTMLLabelElement>;
}
export function Radio({ className, children, size = "md", ref, ...props }: RadioProps) {
const s = dotStyles[size];
return (
<AriaRadio
ref={ref}
{...props}
className={(state) =>
cx(
"group inline-flex items-center select-none",
s.gap,
state.isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
typeof className === "function" ? className(state) : className,
)
}
>
{(state) => (
<>
<RadioDot selected={state.isSelected} size={size} focusVisible={state.isFocusVisible} />
{children !== undefined && children !== null && (
<span className={cx(s.label, "text-text-primary")}>{children}</span>
)}
</>
)}
</AriaRadio>
);
}"use client";
import type { ReactNode, Ref } from "react";
import {
Radio as AriaRadio,
RadioGroup as AriaRadioGroup,
} from "react-aria-components";
import type {
RadioGroupProps as AriaRadioGroupProps,
RadioProps as AriaRadioProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";
export type RadioSize = "sm" | "md";
const dotStyles = {
sm: {
dot: "size-3.5",
inner: "size-[5px]",
selected:
"bg-gradient-to-b from-accent-500 to-accent-600 shadow-[inset_0px_0px_0px_0.875px_var(--color-accent-500),inset_0px_1.75px_0px_0px_rgba(255,255,255,0.25)]",
border: "border-[0.875px]",
gap: "gap-2",
label: "text-body-2-medium",
},
md: {
dot: "size-4",
inner: "size-1.5",
selected:
"bg-gradient-to-b from-accent-500 to-accent-600 shadow-[inset_0px_0px_0px_1px_var(--color-accent-500),inset_0px_2px_0px_0px_rgba(255,255,255,0.25)]",
border: "border",
gap: "gap-2",
label: "text-body-medium",
},
};
export interface RadioDotProps {
selected?: boolean;
size?: RadioSize;
/** Draws the keyboard focus ring around the glyph. */
focusVisible?: boolean;
className?: string;
}
/**
* The bare radio glyph — presentation only, no interaction or semantics.
*
* Select/deselect animates: the default bordered surface and the selected
* gradient are stacked layers cross-fading over 200ms (a gradient background
* can't transition directly), while the white inner dot scales in and out.
*/
export function RadioDot({ selected = false, size = "sm", focusVisible = false, className }: RadioDotProps) {
const s = dotStyles[size];
return (
<span
aria-hidden
className={cx(
"relative flex shrink-0 items-center justify-center rounded-full",
s.dot,
focusVisible && "ring-2 ring-border-focus-ring ring-offset-2",
className,
)}
>
{/* Default surface */}
<span
className={cx(
"absolute inset-0 rounded-full border-border-checkbox-default bg-background-primary-default shadow-xs",
"transition-opacity duration-200 ease",
s.border,
selected && "opacity-0",
)}
/>
{/* Selected gradient surface */}
<span
className={cx(
"absolute inset-0 rounded-full",
"transition-opacity duration-200 ease",
s.selected,
!selected && "opacity-0",
)}
/>
{/* Inner dot: absolutely centered (flex centering of fractional sizes can
land on subpixels) and no drop shadow — a downward shadow makes the
dot read as sitting below center. */}
<span
className={cx(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-control-indicator-background",
"transition-[scale,opacity] duration-200 ease",
s.inner,
selected ? "scale-100 opacity-100" : "scale-0 opacity-0",
)}
/>
</span>
);
}
/* ------------------------------------------------------------------- group */
export interface RadioGroupProps extends Omit<AriaRadioGroupProps, "children"> {
children?: ReactNode;
ref?: Ref<HTMLDivElement>;
}
/** Wraps a set of Radios: arrow-key navigation, single selection, form value. */
export function RadioGroup({ className, children, ref, ...props }: RadioGroupProps) {
return (
<AriaRadioGroup
ref={ref}
{...props}
className={(state) =>
cx(
"flex flex-col gap-2",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
</AriaRadioGroup>
);
}
/* ------------------------------------------------------------------- radio */
export interface RadioProps extends Omit<AriaRadioProps, "children"> {
children?: ReactNode;
size?: RadioSize;
ref?: Ref<HTMLLabelElement>;
}
export function Radio({ className, children, size = "md", ref, ...props }: RadioProps) {
const s = dotStyles[size];
return (
<AriaRadio
ref={ref}
{...props}
className={(state) =>
cx(
"group inline-flex items-center select-none",
s.gap,
state.isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
typeof className === "function" ? className(state) : className,
)
}
>
{(state) => (
<>
<RadioDot selected={state.isSelected} size={size} focusVisible={state.isFocusVisible} />
{children !== undefined && children !== null && (
<span className={cx(s.label, "text-text-primary")}>{children}</span>
)}
</>
)}
</AriaRadio>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
Radio
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | md | — |
RadioDot
The bare radio glyph — presentation only, no interaction or semantics. Select/deselect animates: the default bordered surface and the selected gradient are stacked layers cross-fading over 200ms (a gradient background can't transition directly), while the white inner dot scales in and out.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| focusVisible | boolean | false | Draws the keyboard focus ring around the glyph. |
| selected | boolean | false | — |
| size | "sm" | "md" | sm | — |