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-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

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 instead

a11y

give it an aria-label or a visible label · arrow keys move between options, tab moves past the whole groupjest-axe

dependencies

Base UI radio group
live demo · try it out
customize
orientationhow the options stack
disabledlocks every option at once
usagecopy
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

proptypedefaultnotes
valueValuethe selected option. controlled, and generic, so the value does not have to be a string.
onValueChange(value: Value) => voidfires with the newly selected value.
orientation"horizontal" | "vertical""vertical"how the options stack, and which arrow keys move between them.
disabledbooleanlocks every Radio inside at once, rather than one by one.

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";