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 usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

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 stacked

a11y

radiogroup with aria-orientation · each radio reflects aria-checked and aria-disabled · label and description bind through Fieldjest-axe

dependencies

@base-ui/react · class-variance-authority
live 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
usagecopy
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

proptypedefaultnotes
valueValuecontrolled selected value.
defaultValueValueinitial selected value when uncontrolled.
onValueChange(value: Value, eventDetails) => voidfires when the selected value changes.
namestringidentifies the group in form submissions.
orientation"horizontal" | "vertical""vertical"layout direction, announced to assistive tech.

Radio

proptypedefaultnotes
valueValuethe value this radio represents.
labelReactNodethe field label, clickable and tied to the control.
descriptionReactNodehelper text below, indented to line up under the label.
disabledbooleanfalsedims 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.jsoncopy
source · components/ui/radio.tsxexactly what this command copiescopy
"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";