docs / core / radio-group
RadioGroup
a Radio on its own is half a control. the group is what holds the value, moves focus between the options and speaks for them as one question.
intensity · recedesrsc · client
provenance
shaped from sisu-pluslayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
one question, two to five options, all visible at onceorientation follows the shape of the answers: long labels stacknever a lone Radio outside a group. it has nothing to belong topast about five options this wants a Select insteada11y
give it anaria-label or a visible label · arrow keys move between options, tab moves past the whole groupjest-axedependencies
Base UI radio grouplive demo · try it out
customize
orientationhow the options stack
disabledlocks every option at once
usage
import { Radio, RadioGroup } from "usva/primitives/radio";
<RadioGroup
value={theme}
onValueChange={setTheme}
>
<Radio value="kajo">kajo</Radio>
<Radio value="sisu">sisu</Radio>
<Radio value="savi">savi</Radio>
</RadioGroup>props
| prop | type | default | notes |
|---|---|---|---|
| value | Value | — | the selected option. controlled, and generic, so the value does not have to be a string. |
| onValueChange | (value: Value) => void | — | fires with the newly selected value. |
| orientation | "horizontal" | "vertical" | "vertical" | how the options stack, and which arrow keys move between them. |
| disabled | boolean | — | locks every Radio inside at once, rather than one by one. |
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";