docs / core / radio
Radio
one choice from a visible set of options. use it when every option should be seen at once; a long list wants a select.
intensity · recedesrsc · client
provenance
authored in usvalayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
settings forms, plan pickers, anywhere 2 to 5 options fit on screenorientation horizontal for short, label-only optionsnot for more than a handful of options. that is a selectdescriptions on horizontal groups, the indent only works stackeda11y
radiogroup with aria-orientation · each radio reflects aria-checked and aria-disabled · label and description bind through Fieldjest-axedependencies
@base-ui/react · class-variance-authoritylive demo · try it out
Good for trying things out.
For growing teams.
Advanced controls and support.
customize
orientationstacked or inline
sizecontrol diameter
descriptionshelper text under each label
disableLastdim and block the last option
usage
import { Radio, RadioGroup } from "usva/primitives/radio";
<RadioGroup name="plan" defaultValue="free">
<Radio value="free" label="Free" description="Good for trying things out." />
<Radio value="pro" label="Pro" description="For growing teams." />
<Radio value="enterprise" label="Enterprise" description="Advanced controls and support." disabled />
</RadioGroup>RadioGroup
| prop | type | default | notes |
|---|---|---|---|
| value | Value | — | controlled selected value. |
| defaultValue | Value | — | initial selected value when uncontrolled. |
| onValueChange | (value: Value, eventDetails) => void | — | fires when the selected value changes. |
| name | string | — | identifies the group in form submissions. |
| orientation | "horizontal" | "vertical" | "vertical" | layout direction, announced to assistive tech. |
Radio
| prop | type | default | notes |
|---|---|---|---|
| value | Value | — | the value this radio represents. |
| label | ReactNode | — | the field label, clickable and tied to the control. |
| description | ReactNode | — | helper text below, indented to line up under the label. |
| disabled | boolean | false | dims to 50% and blocks selection. |
| size | "sm" | "md" | "md" | control size. the hit area extends well past both. |
get it
npx shadcn add https://usva.build/r/radio.jsonsource · components/ui/radio.tsxexactly what this command copies
"use client";
import { Field } from "@base-ui/react/field";
import { Radio as Base } from "@base-ui/react/radio";
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface RadioGroupProps<Value = string>
extends Omit<BaseRadioGroup.Props<Value>, "className"> {
className?: string;
orientation?: "horizontal" | "vertical";
children?: React.ReactNode;
}
function RadioGroupImpl<Value = string>(
{ className, orientation = "vertical", ...props }: RadioGroupProps<Value>,
ref: React.Ref<HTMLDivElement>,
) {
return (
<BaseRadioGroup
ref={ref}
aria-orientation={orientation}
className={cn(
"flex gap-3",
orientation === "horizontal" ? "flex-row" : "flex-col",
className,
)}
{...props}
/>
);
}
export const RadioGroup = React.forwardRef(RadioGroupImpl) as <Value = string>(
props: RadioGroupProps<Value> & { ref?: React.Ref<HTMLDivElement> },
) => React.ReactElement;
(RadioGroup as { displayName?: string }).displayName = "RadioGroup";
const rootVariants = cva(
"relative flex shrink-0 items-center justify-center rounded-full border border-border bg-surface outline-none transition-control duration-base ease-soft before:absolute before:content-[''] data-[unchecked]:hover:border-border-strong data-[checked]:border-accent data-[checked]:glow-ring active:scale-[0.98] motion-reduce:transition-none motion-reduce:transform-none focus-visible:border-transparent focus-visible:ring-focus aria-invalid:border-danger data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
{
variants: {
size: {
sm: "h-4 w-4 before:-inset-3.5",
md: "h-5 w-5 before:-inset-3",
},
},
defaultVariants: { size: "md" },
},
);
const indicatorVariants = cva("rounded-full bg-accent bg-gradient-accent", {
variants: {
size: {
sm: "h-2 w-2",
md: "h-2.5 w-2.5",
},
},
defaultVariants: { size: "md" },
});
// Indent the description to line up under the label: control width + the gap-2.
const descIndent: Record<NonNullable<RadioProps["size"]>, string> = {
sm: "pl-6",
md: "pl-7",
};
export interface RadioProps
extends Omit<
React.ComponentPropsWithoutRef<typeof Base.Root>,
"render" | "className"
>,
VariantProps<typeof rootVariants> {
className?: string;
label?: React.ReactNode;
description?: React.ReactNode;
}
export const Radio = React.forwardRef<HTMLButtonElement, RadioProps>(
({ className, size, label, description, id, disabled, ...props }, ref) => {
const generatedId = React.useId();
const radioId = id ?? generatedId;
return (
<Field.Root className="flex flex-col gap-1" disabled={disabled}>
<div className="flex items-center gap-2">
<Base.Root
ref={ref}
id={radioId}
disabled={disabled}
className={cn(rootVariants({ size }), className)}
{...props}
>
<Base.Indicator
keepMounted
className={cn(
"flex items-center justify-center transition-control duration-base ease-spring motion-reduce:transition-none motion-reduce:transform-none data-[unchecked]:scale-0 data-[checked]:scale-100",
)}
>
<span className={indicatorVariants({ size })} />
</Base.Indicator>
</Base.Root>
{label ? (
<Field.Label
htmlFor={radioId}
className="text-sm text-ink select-none data-[disabled]:opacity-50"
>
{label}
</Field.Label>
) : null}
</div>
{description ? (
<Field.Description
className={cn("text-xs text-muted", descIndent[size ?? "md"])}
>
{description}
</Field.Description>
) : null}
</Field.Root>
);
},
);
Radio.displayName = "Radio";