Expressive

Selection & input

Radio group

Radio buttons with arrow-key navigation.

Shipping

Radio buttons let people pick exactly one option from a short list. Show all options when there are few; use a menu or select for long lists.

Installation

npx @grenishrai/expressive add radio-group

This copies radio-group.tsx into your components folder, along with what it needs from Expressive (utils, motion, styles), and installs @base-ui/react.

Usage

import { Radio, RadioGroup } from "@/components/expressive/radio-group";

<RadioGroup aria-labelledby="shipping" defaultValue="standard">
  <label><Radio value="standard" /> Standard</label>
  <label><Radio value="express" /> Express</label>
</RadioGroup>

Props

RadioGroup

PropTypeDefaultDescription
value / defaultValueunknown—The selected value.
onValueChange(value) => void—Called when the selection changes.
aria-labelledby / aria-labelstring—Names the group.
disabledboolean—Disables every option.

Radio

PropTypeDefaultDescription
valueunknown—Value this option represents.
disabledboolean—Disables this option.
motion"expressive" | "standard" | "fluid" | "none"theme defaultMotion scheme for this component's transitions. none turns animation off.

Accessibility

  • Arrow keys move the selection; Tab moves into and out of the group as one stop.
  • Give the group a name with aria-labelledby pointing at a visible heading.

Source

This is the file the CLI writes into your project, shown with the default import paths. It's yours to change.

components/radio-group.tsx
"use client";

import * as React from "react";
import { Radio as BaseRadio } from "@base-ui/react/radio";
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
import { cn } from "@/lib/expressive/utils";
import type { MotionProp } from "@/lib/expressive/use-motion";
import { useComposedRef, useFluidPop } from "@/lib/expressive/use-fluid";

export interface RadioGroupProps extends Omit<BaseRadioGroup.Props, "className"> {
  className?: string;
}

/** Arrow keys move between options; label it with `aria-labelledby` or `aria-label`. */
export function RadioGroup({ className, ...props }: RadioGroupProps) {
  return <BaseRadioGroup data-slot="radio-group" className={cn("flex flex-col gap-1", className)} {...props} />;
}

export interface RadioProps extends Omit<BaseRadio.Root.Props, "className"> {
  className?: string;
  motion?: MotionProp;
}

/* 20px ring inside a 40px state-layer target; the dot springs in when selected. */
export function Radio({ className, motion, ref, ...props }: RadioProps) {
  const root = React.useRef<HTMLElement>(null);
  const ring = React.useRef<HTMLSpanElement>(null);
  useFluidPop(root, { attributes: ["data-checked"], target: ring, from: 0.75, motion });
  return (
    <BaseRadio.Root
      ref={useComposedRef(root, ref)}
      data-slot="radio"
      data-motion={motion}
      className={cn(
        "group state-layer focus-ring relative inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-shape-full text-on-surface-variant",
        "data-checked:text-primary data-invalid:text-error",
        "data-disabled:cursor-default data-disabled:opacity-(--ex-disabled-content) data-disabled:before:hidden",
        className,
      )}
      {...props}
    >
      <span ref={ring} className="flex size-5 items-center justify-center rounded-shape-full border-2 border-current transition-colors spring-effects-fast">
        <BaseRadio.Indicator
          keepMounted
          className="size-2.5 scale-0 rounded-shape-full bg-current transition-transform spring-spatial-fast data-checked:scale-100"
        />
      </span>
    </BaseRadio.Root>
  );
}