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-groupThis 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
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue | unknown | — | The selected value. |
| onValueChange | (value) => void | — | Called when the selection changes. |
| aria-labelledby / aria-label | string | — | Names the group. |
| disabled | boolean | — | Disables every option. |
Radio
| Prop | Type | Default | Description |
|---|---|---|---|
| value | unknown | — | Value this option represents. |
| disabled | boolean | — | Disables this option. |
| motion | "expressive" | "standard" | "fluid" | "none" | theme default | Motion 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-labelledbypointing 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.
"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>
);
}