Radio Group
Radio Group
radio.tsxTSXcomponents/previews/motion/radio.preview.tsx
"use client";
import { useState } from "react";
import { RadioGroup, RadioGroupItem } from "@/components/motion/radio";
export function RadioPreview() {
const [plan, setPlan] = useState("pro");
return (
<RadioGroup value={plan} onValueChange={setPlan} className="min-w-48">
<RadioGroupItem value="starter" label="Starter, free" />
<RadioGroupItem value="pro" label="Pro, $12/mo" />
<RadioGroupItem value="team" label="Team, $29/mo" />
<RadioGroupItem value="legacy" label="Legacy plan" disabled />
</RadioGroup>
);
}
TSXcomponents/motion/radio.tsx
"use client";
// beui.dev/components/motion/radio
import { motion, MotionConfig, useReducedMotion } from "motion/react";
import {
createContext,
type KeyboardEvent,
useCallback,
useContext,
useId,
useLayoutEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
type RadioCtx = {
value: string;
setValue: (value: string) => void;
layoutId: string;
/**
* The one option Tab reaches: the selected option, or the first enabled one
* when nothing enabled is selected. `null` until the options have
* registered, when every enabled option stays reachable.
*/
tabStop: string | null;
register: (value: string, disabled: boolean) => void;
unregister: (value: string) => void;
};
const RadioCtx = createContext<RadioCtx | null>(null);
function useRadioGroup() {
const ctx = useContext(RadioCtx);
if (!ctx) {
throw new Error("RadioGroupItem must be used inside <RadioGroup>");
}
return ctx;
}
export interface RadioGroupProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
children: ReactNode;
className?: string;
orientation?: "vertical" | "horizontal";
/** Accessible name for the group when no visible label names it. */
"aria-label"?: string;
/** Id of a visible element that names the group. */
"aria-labelledby"?: string;
}
export function RadioGroup({
value,
defaultValue = "",
onValueChange,
children,
className,
orientation = "vertical",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: RadioGroupProps) {
const [internal, setInternal] = useState(defaultValue);
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
},
[controlled, onValueChange],
);
const [options, setOptions] = useState<Map<string, boolean>>(new Map());
const register = useCallback((option: string, disabled: boolean) => {
setOptions((map) =>
map.get(option) === disabled ? map : new Map(map).set(option, disabled),
);
}, []);
const unregister = useCallback((option: string) => {
setOptions((map) => {
if (!map.has(option)) return map;
const next = new Map(map);
next.delete(option);
return next;
});
}, []);
// Resolved during render so the group has exactly one tab stop in the same
// commit that changes the selection or the options.
let tabStop: string | null = null;
if (options.size > 0) {
const enabled = Array.from(options).filter(([, disabled]) => !disabled);
tabStop = enabled.some(([option]) => option === current)
? current
: (enabled[0]?.[0] ?? "");
}
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, tabStop, register, unregister }),
[current, layoutId, setValue, tabStop, register, unregister],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : SPRING_LAYOUT}>
<RadioCtx.Provider value={contextValue}>
<div
role="radiogroup"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn(
"flex gap-3",
orientation === "vertical" ? "flex-col" : "flex-row flex-wrap",
className,
)}
>
{children}
</div>
</RadioCtx.Provider>
</MotionConfig>
);
}
export interface RadioGroupItemProps {
value: string;
label?: string;
/** Supporting text under the label, announced as the description rather than the name. */
description?: string;
disabled?: boolean;
className?: string;
id?: string;
}
export function RadioGroupItem({
value,
label,
description,
disabled,
className,
id: idProp,
}: RadioGroupItemProps) {
const {
value: groupValue,
setValue,
layoutId,
tabStop,
register,
unregister,
} = useRadioGroup();
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const selected = groupValue === value;
const descriptionId = description ? `${id}-description` : undefined;
// The wrapping <label> would fold the description into the name, so name from the label text alone.
const labelId = description && label ? `${id}-label` : undefined;
useLayoutEffect(() => {
register(value, Boolean(disabled));
}, [register, value, disabled]);
useLayoutEffect(() => () => unregister(value), [unregister, value]);
const reachable =
!disabled && (tabStop === null ? true : tabStop === value);
// Arrow keys move to the next enabled option in the group, wrapping at the
// ends, and select it as focus arrives, like native radio buttons.
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const step =
event.key === "ArrowDown" || event.key === "ArrowRight"
? 1
: event.key === "ArrowUp" || event.key === "ArrowLeft"
? -1
: 0;
if (!step) return;
const group = event.currentTarget.closest('[role="radiogroup"]');
const radios = Array.from(
group?.querySelectorAll<HTMLButtonElement>('[role="radio"]') ?? [],
).filter(
(radio) =>
!radio.disabled &&
radio.closest('[role="radiogroup"]') === group,
);
const at = radios.indexOf(event.currentTarget);
if (at < 0 || radios.length < 2) return;
event.preventDefault();
const next = radios[(at + step + radios.length) % radios.length];
next.focus();
next.click();
};
return (
<label
htmlFor={id}
className={cn(
"inline-flex gap-3",
description ? "items-start" : "items-center",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="radio"
aria-checked={selected}
aria-labelledby={labelId}
aria-describedby={descriptionId}
disabled={disabled}
tabIndex={reachable ? 0 : -1}
onClick={() => !disabled && setValue(value)}
onKeyDown={onKeyDown}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={selected ? "checked" : "unchecked"}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary"
: "border-muted-foreground/50 hover:border-muted-foreground",
)}
>
{selected ? (
<motion.span
layoutId={layoutId}
className="absolute inset-1 rounded-full bg-primary"
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
/>
) : null}
</motion.button>
{label || description ? (
<span className={cn("flex min-w-0 flex-col gap-0.5 select-none", disabled && "opacity-60")}>
{label ? (
<span
id={labelId}
// Geist sits low in a 20px line; a 16px line lifts the text onto the control.
className={cn("text-sm text-foreground", description && "leading-4")}
>
{label}
</span>
) : null}
{description ? (
<span id={descriptionId} className="text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
) : null}
</label>
);
}
With Description
radio.tsxTSXcomponents/previews/motion/radio-description.preview.tsx
"use client";
import { useState } from "react";
import { RadioGroup, RadioGroupItem } from "@/components/motion/radio";
export function RadioDescriptionPreview() {
const [schedule, setSchedule] = useState("once");
return (
<RadioGroup value={schedule} onValueChange={setSchedule} className="w-full max-w-64">
<RadioGroupItem
value="once"
label="Run once"
description="Runs a single time, right now."
/>
<RadioGroupItem
value="schedule"
label="Run on a schedule"
description="Repeats on a set interval until stopped."
/>
<RadioGroupItem
value="trigger"
label="Run on a trigger"
description="Waits for an event before it starts."
/>
<RadioGroupItem
value="manual"
label="Manual approval"
description="Not available on your plan."
disabled
/>
</RadioGroup>
);
}
TSXcomponents/motion/radio.tsx
"use client";
// beui.dev/components/motion/radio
import { motion, MotionConfig, useReducedMotion } from "motion/react";
import {
createContext,
type KeyboardEvent,
useCallback,
useContext,
useId,
useLayoutEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
type RadioCtx = {
value: string;
setValue: (value: string) => void;
layoutId: string;
/**
* The one option Tab reaches: the selected option, or the first enabled one
* when nothing enabled is selected. `null` until the options have
* registered, when every enabled option stays reachable.
*/
tabStop: string | null;
register: (value: string, disabled: boolean) => void;
unregister: (value: string) => void;
};
const RadioCtx = createContext<RadioCtx | null>(null);
function useRadioGroup() {
const ctx = useContext(RadioCtx);
if (!ctx) {
throw new Error("RadioGroupItem must be used inside <RadioGroup>");
}
return ctx;
}
export interface RadioGroupProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
children: ReactNode;
className?: string;
orientation?: "vertical" | "horizontal";
/** Accessible name for the group when no visible label names it. */
"aria-label"?: string;
/** Id of a visible element that names the group. */
"aria-labelledby"?: string;
}
export function RadioGroup({
value,
defaultValue = "",
onValueChange,
children,
className,
orientation = "vertical",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: RadioGroupProps) {
const [internal, setInternal] = useState(defaultValue);
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
},
[controlled, onValueChange],
);
const [options, setOptions] = useState<Map<string, boolean>>(new Map());
const register = useCallback((option: string, disabled: boolean) => {
setOptions((map) =>
map.get(option) === disabled ? map : new Map(map).set(option, disabled),
);
}, []);
const unregister = useCallback((option: string) => {
setOptions((map) => {
if (!map.has(option)) return map;
const next = new Map(map);
next.delete(option);
return next;
});
}, []);
// Resolved during render so the group has exactly one tab stop in the same
// commit that changes the selection or the options.
let tabStop: string | null = null;
if (options.size > 0) {
const enabled = Array.from(options).filter(([, disabled]) => !disabled);
tabStop = enabled.some(([option]) => option === current)
? current
: (enabled[0]?.[0] ?? "");
}
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, tabStop, register, unregister }),
[current, layoutId, setValue, tabStop, register, unregister],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : SPRING_LAYOUT}>
<RadioCtx.Provider value={contextValue}>
<div
role="radiogroup"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn(
"flex gap-3",
orientation === "vertical" ? "flex-col" : "flex-row flex-wrap",
className,
)}
>
{children}
</div>
</RadioCtx.Provider>
</MotionConfig>
);
}
export interface RadioGroupItemProps {
value: string;
label?: string;
/** Supporting text under the label, announced as the description rather than the name. */
description?: string;
disabled?: boolean;
className?: string;
id?: string;
}
export function RadioGroupItem({
value,
label,
description,
disabled,
className,
id: idProp,
}: RadioGroupItemProps) {
const {
value: groupValue,
setValue,
layoutId,
tabStop,
register,
unregister,
} = useRadioGroup();
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const selected = groupValue === value;
const descriptionId = description ? `${id}-description` : undefined;
// The wrapping <label> would fold the description into the name, so name from the label text alone.
const labelId = description && label ? `${id}-label` : undefined;
useLayoutEffect(() => {
register(value, Boolean(disabled));
}, [register, value, disabled]);
useLayoutEffect(() => () => unregister(value), [unregister, value]);
const reachable =
!disabled && (tabStop === null ? true : tabStop === value);
// Arrow keys move to the next enabled option in the group, wrapping at the
// ends, and select it as focus arrives, like native radio buttons.
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const step =
event.key === "ArrowDown" || event.key === "ArrowRight"
? 1
: event.key === "ArrowUp" || event.key === "ArrowLeft"
? -1
: 0;
if (!step) return;
const group = event.currentTarget.closest('[role="radiogroup"]');
const radios = Array.from(
group?.querySelectorAll<HTMLButtonElement>('[role="radio"]') ?? [],
).filter(
(radio) =>
!radio.disabled &&
radio.closest('[role="radiogroup"]') === group,
);
const at = radios.indexOf(event.currentTarget);
if (at < 0 || radios.length < 2) return;
event.preventDefault();
const next = radios[(at + step + radios.length) % radios.length];
next.focus();
next.click();
};
return (
<label
htmlFor={id}
className={cn(
"inline-flex gap-3",
description ? "items-start" : "items-center",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="radio"
aria-checked={selected}
aria-labelledby={labelId}
aria-describedby={descriptionId}
disabled={disabled}
tabIndex={reachable ? 0 : -1}
onClick={() => !disabled && setValue(value)}
onKeyDown={onKeyDown}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={selected ? "checked" : "unchecked"}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary"
: "border-muted-foreground/50 hover:border-muted-foreground",
)}
>
{selected ? (
<motion.span
layoutId={layoutId}
className="absolute inset-1 rounded-full bg-primary"
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
/>
) : null}
</motion.button>
{label || description ? (
<span className={cn("flex min-w-0 flex-col gap-0.5 select-none", disabled && "opacity-60")}>
{label ? (
<span
id={labelId}
// Geist sits low in a 20px line; a 16px line lifts the text onto the control.
className={cn("text-sm text-foreground", description && "leading-4")}
>
{label}
</span>
) : null}
{description ? (
<span id={descriptionId} className="text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
) : null}
</label>
);
}
API Reference
RadioGroup
PropTypeDefault
value?stringDefault
—defaultValue?stringDefault
onValueChange?((value: string) => void)Default
—className?stringDefault
—orientation?"horizontal" | "vertical"Default
verticalaria-label?stringAccessible name for the group when no visible label names it.
Default
—aria-labelledby?stringId of a visible element that names the group.
Default
—RadioGroupItem
PropTypeDefault
valuestringDefault
—label?stringDefault
—description?stringSupporting text under the label, announced as the description rather than the name.
Default
—disabled?booleanDefault
—className?stringDefault
—id?stringDefault
—Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/radioradioradio
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
/** Accelerating curve for things that fall (a dropped ball, a toppled block). Not for UI entrances. */
export const EASE_IN = [0.55, 0, 1, 0.45] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/radio.tsx
"use client";
// beui.dev/components/motion/radio
import { motion, MotionConfig, useReducedMotion } from "motion/react";
import {
createContext,
type KeyboardEvent,
useCallback,
useContext,
useId,
useLayoutEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
type RadioCtx = {
value: string;
setValue: (value: string) => void;
layoutId: string;
/**
* The one option Tab reaches: the selected option, or the first enabled one
* when nothing enabled is selected. `null` until the options have
* registered, when every enabled option stays reachable.
*/
tabStop: string | null;
register: (value: string, disabled: boolean) => void;
unregister: (value: string) => void;
};
const RadioCtx = createContext<RadioCtx | null>(null);
function useRadioGroup() {
const ctx = useContext(RadioCtx);
if (!ctx) {
throw new Error("RadioGroupItem must be used inside <RadioGroup>");
}
return ctx;
}
export interface RadioGroupProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
children: ReactNode;
className?: string;
orientation?: "vertical" | "horizontal";
/** Accessible name for the group when no visible label names it. */
"aria-label"?: string;
/** Id of a visible element that names the group. */
"aria-labelledby"?: string;
}
export function RadioGroup({
value,
defaultValue = "",
onValueChange,
children,
className,
orientation = "vertical",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: RadioGroupProps) {
const [internal, setInternal] = useState(defaultValue);
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
},
[controlled, onValueChange],
);
const [options, setOptions] = useState<Map<string, boolean>>(new Map());
const register = useCallback((option: string, disabled: boolean) => {
setOptions((map) =>
map.get(option) === disabled ? map : new Map(map).set(option, disabled),
);
}, []);
const unregister = useCallback((option: string) => {
setOptions((map) => {
if (!map.has(option)) return map;
const next = new Map(map);
next.delete(option);
return next;
});
}, []);
// Resolved during render so the group has exactly one tab stop in the same
// commit that changes the selection or the options.
let tabStop: string | null = null;
if (options.size > 0) {
const enabled = Array.from(options).filter(([, disabled]) => !disabled);
tabStop = enabled.some(([option]) => option === current)
? current
: (enabled[0]?.[0] ?? "");
}
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, tabStop, register, unregister }),
[current, layoutId, setValue, tabStop, register, unregister],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : SPRING_LAYOUT}>
<RadioCtx.Provider value={contextValue}>
<div
role="radiogroup"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn(
"flex gap-3",
orientation === "vertical" ? "flex-col" : "flex-row flex-wrap",
className,
)}
>
{children}
</div>
</RadioCtx.Provider>
</MotionConfig>
);
}
export interface RadioGroupItemProps {
value: string;
label?: string;
/** Supporting text under the label, announced as the description rather than the name. */
description?: string;
disabled?: boolean;
className?: string;
id?: string;
}
export function RadioGroupItem({
value,
label,
description,
disabled,
className,
id: idProp,
}: RadioGroupItemProps) {
const {
value: groupValue,
setValue,
layoutId,
tabStop,
register,
unregister,
} = useRadioGroup();
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const selected = groupValue === value;
const descriptionId = description ? `${id}-description` : undefined;
// The wrapping <label> would fold the description into the name, so name from the label text alone.
const labelId = description && label ? `${id}-label` : undefined;
useLayoutEffect(() => {
register(value, Boolean(disabled));
}, [register, value, disabled]);
useLayoutEffect(() => () => unregister(value), [unregister, value]);
const reachable =
!disabled && (tabStop === null ? true : tabStop === value);
// Arrow keys move to the next enabled option in the group, wrapping at the
// ends, and select it as focus arrives, like native radio buttons.
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const step =
event.key === "ArrowDown" || event.key === "ArrowRight"
? 1
: event.key === "ArrowUp" || event.key === "ArrowLeft"
? -1
: 0;
if (!step) return;
const group = event.currentTarget.closest('[role="radiogroup"]');
const radios = Array.from(
group?.querySelectorAll<HTMLButtonElement>('[role="radio"]') ?? [],
).filter(
(radio) =>
!radio.disabled &&
radio.closest('[role="radiogroup"]') === group,
);
const at = radios.indexOf(event.currentTarget);
if (at < 0 || radios.length < 2) return;
event.preventDefault();
const next = radios[(at + step + radios.length) % radios.length];
next.focus();
next.click();
};
return (
<label
htmlFor={id}
className={cn(
"inline-flex gap-3",
description ? "items-start" : "items-center",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="radio"
aria-checked={selected}
aria-labelledby={labelId}
aria-describedby={descriptionId}
disabled={disabled}
tabIndex={reachable ? 0 : -1}
onClick={() => !disabled && setValue(value)}
onKeyDown={onKeyDown}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={selected ? "checked" : "unchecked"}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary"
: "border-muted-foreground/50 hover:border-muted-foreground",
)}
>
{selected ? (
<motion.span
layoutId={layoutId}
className="absolute inset-1 rounded-full bg-primary"
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
/>
) : null}
</motion.button>
{label || description ? (
<span className={cn("flex min-w-0 flex-col gap-0.5 select-none", disabled && "opacity-60")}>
{label ? (
<span
id={labelId}
// Geist sits low in a 20px line; a 16px line lifts the text onto the control.
className={cn("text-sm text-foreground", description && "leading-4")}
>
{label}
</span>
) : null}
{description ? (
<span id={descriptionId} className="text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
) : null}
</label>
);
}
Related components
Checkbox
Form choice control with a draw-on checkmark, spring press feedback and indeterminate state support.
Select
Composable select primitives whose panel unfolds out of the trigger on one spring and pulls away into its own pill, plus a Morph variant where the trigger grows into the panel via shared layout.
Input
Text input with label, left/right icons, optional stable error row, error shake and success check draw.
Updated