Forms
ButtonGroup
ButtonGroup is a full form field — pass `label`, `helpText`, `error`, and an `options` array. Use `multiple` for multi-select (`string[]`) or omit it for single-select (`string`). Options can include `{ type: "separator" }` items for toolbar-style dividers.
Installation
npx shadcn add https://rck.vibeboxph.com//r/button-group.jsonLive preview & controls
Choose a reporting period.
Controls
<ButtonGroup label="Time range" helpText="Choose a reporting period." defaultValue="7d" options={[...]} />Anatomy
ButtonGroupField container with label, merged option buttons, and group help text.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | ButtonGroupOption | { type: "separator" }[] | — | Option list rendered as merged buttons; separator items divide segments. |
| multiple | boolean | false | When true, value is string[] and options toggle independently. |
| value / defaultValue | string | string[] | — | Controlled / uncontrolled selected value(s). |
| onValueChange | (value: string | string[]) => void | — | Fires when the selection changes. |
| label | React.ReactNode | — | Top field label. |
| helpText | React.ReactNode | — | Group-level help text. |
| error | React.ReactNode | — | Error text; sets `aria-invalid` on the group. |
| required | boolean | — | Marks the field as required. |
| orientation | "horizontal" | "vertical" | horizontal | Layout direction for merged buttons. |
| size | Button size | sm | Size applied to every option button. |
| variant | Button variant | outline | Variant for unselected options. |
| selectedVariant | Button variant | solid | Variant for selected options. |
| disabled | boolean | — | Disables every option in the group. |
| className | string | — | Merged with the inner group classes. |
Accessibility
- Uses `role="group"` with `aria-labelledby` for the shared field label and `aria-describedby` for help or error text.
- Each option is a native button with `aria-pressed` reflecting selection state.
- Icon-only options should provide `helpText` for an accessible name.
- Separator items use `role="separator"` with the matching `aria-orientation`.
Source
registry/rck/ui/button-group.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useControllableState } from "@/hooks/use-controllable-state";
import { Button, type ButtonProps } from "@/registry/rck/ui/button";
import { FieldLabel, FieldMessage } from "@/registry/rck/ui/field-chrome";
export interface ButtonGroupOption {
value: string;
text: React.ReactNode;
disabled?: boolean;
helpText?: React.ReactNode;
icon?: React.ReactNode;
}
export type ButtonGroupItem = ButtonGroupOption | { type: "separator" };
function isSeparator(item: ButtonGroupItem): item is { type: "separator" } {
return "type" in item && item.type === "separator";
}
export interface ButtonGroupProps {
label?: React.ReactNode;
helpText?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
containerClassName?: string;
className?: string;
id?: string;
disabled?: boolean;
options: ReadonlyArray<ButtonGroupItem>;
/** When false (default), value is a single string. When true, value is string[]. */
multiple?: boolean;
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
orientation?: "horizontal" | "vertical";
size?: ButtonProps["size"];
/** Variant for unselected options. */
variant?: ButtonProps["variant"];
/** Variant for selected options. */
selectedVariant?: ButtonProps["variant"];
}
function ButtonGroupSeparator({
orientation,
}: {
orientation: "horizontal" | "vertical";
}) {
return (
<div
role="separator"
aria-orientation={orientation}
data-slot="button-group-separator"
className={cn(
"bg-border shrink-0",
orientation === "horizontal"
? "mx-px h-auto w-px self-stretch"
: "my-px h-px w-auto",
)}
/>
);
}
/** Segmented button field with single or multi-select and merged button borders. */
export function ButtonGroup({
label,
helpText,
error,
required,
containerClassName,
className,
id: providedId,
disabled,
options,
multiple = false,
value,
defaultValue,
onValueChange,
orientation = "horizontal",
size = "sm",
variant = "outline",
selectedVariant = "solid",
}: ButtonGroupProps) {
const [selected, setSelected] = useControllableState<string | string[]>({
prop: value,
defaultProp: defaultValue ?? (multiple ? [] : ""),
onChange: onValueChange,
});
const generatedId = React.useId();
const groupId = providedId ?? generatedId;
const helpTextId = helpText && !error ? `${groupId}-help-text` : undefined;
const errorId = error ? `${groupId}-error` : undefined;
const describedBy = cn(helpTextId, errorId) || undefined;
function isOptionSelected(optionValue: string): boolean {
if (multiple) {
return Array.isArray(selected) && selected.includes(optionValue);
}
return selected === optionValue;
}
function selectOption(optionValue: string) {
if (multiple) {
const current = Array.isArray(selected) ? selected : [];
setSelected(
current.includes(optionValue)
? current.filter((item) => item !== optionValue)
: [...current, optionValue],
);
return;
}
if (selected !== optionValue) {
setSelected(optionValue);
}
}
return (
<div
data-slot="button-group-container"
className={cn("flex flex-col gap-1.5", containerClassName)}
>
{label && (
<FieldLabel id={`${groupId}-label`} required={required}>
{label}
</FieldLabel>
)}
<div
role="group"
id={groupId}
data-slot="button-group"
data-orientation={orientation}
aria-labelledby={label ? `${groupId}-label` : undefined}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
className={cn(
"inline-flex w-fit",
orientation === "horizontal"
? cn(
"[&>[data-slot=button]:not(:first-child)]:rounded-l-none",
"[&>[data-slot=button]:not(:last-child)]:rounded-r-none",
"[&>[data-slot=button]:not(:first-child)]:border-l-0",
)
: cn(
"flex-col",
"[&>[data-slot=button]:not(:first-child)]:rounded-t-none",
"[&>[data-slot=button]:not(:last-child)]:rounded-b-none",
"[&>[data-slot=button]:not(:first-child)]:border-t-0",
),
className,
)}
>
{options.map((item, index) => {
if (isSeparator(item)) {
return (
<ButtonGroupSeparator
key={`separator-${index}`}
orientation={orientation}
/>
);
}
const optionId = `${groupId}-option-${item.value}`;
const optionHelpTextId = item.helpText ? `${optionId}-help` : undefined;
const pressed = isOptionSelected(item.value);
const optionDisabled = disabled || item.disabled;
const accessibleName =
typeof item.text === "string" && item.text.trim().length > 0
? undefined
: typeof item.helpText === "string"
? item.helpText
: item.value;
return (
<Button
key={item.value}
type="button"
size={size}
variant={pressed ? selectedVariant : variant}
disabled={optionDisabled}
aria-pressed={pressed}
aria-label={accessibleName}
aria-describedby={
optionHelpTextId && accessibleName ? optionHelpTextId : undefined
}
leadingIcon={item.icon}
onClick={() => selectOption(item.value)}
>
{item.text}
{item.helpText && accessibleName && (
<span id={optionHelpTextId} className="sr-only">
{item.helpText}
</span>
)}
</Button>
);
})}
</div>
{helpText && !error && <FieldMessage id={helpTextId}>{helpText}</FieldMessage>}
{error && (
<FieldMessage id={errorId} invalid role="alert">
{error}
</FieldMessage>
)}
</div>
);
}
registry/rck/ui/button.tsx
import * as React from "react";
import { cva, type VariantProps } from "@/lib/cva";
import { LoaderCircle } from "@/lib/icons";
import { Slot } from "@/registry/rck/primitives/slot";
import { cn } from "@/lib/utils";
export const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-[11px] font-semibold " +
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring " +
"focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none " +
"disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 " +
"[&_[data-icon=inline-start]]:-ml-0.5 [&_[data-icon=inline-end]]:-mr-0.5",
{
variants: {
variant: {
solid: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
outline:
"border border-border-strong bg-transparent text-foreground shadow-sm hover:bg-surface-2",
soft: "bg-primary-soft text-primary-soft-foreground hover:bg-primary-soft/80",
ghost: "bg-transparent text-foreground hover:bg-surface-2",
link: "bg-transparent text-primary underline-offset-4 hover:underline",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
},
size: {
xs: "h-7 px-2 text-[10px] [&_svg]:size-3",
sm: "h-8 px-2.5 text-[10px] [&_svg]:size-3",
md: "h-[39px] px-[15px] [&_svg]:size-3.5",
lg: "h-11 px-5 text-xs [&_svg]:size-4",
icon: "size-[39px] p-0 [&_svg]:size-4",
"icon-sm": "size-8 p-0 [&_svg]:size-3.5",
"icon-lg": "size-11 p-0 [&_svg]:size-4",
},
fullWidth: {
true: "w-full",
},
},
defaultVariants: {
variant: "solid",
size: "md",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
/** Render the child element instead of a `<button>`, forwarding all props (Radix Slot). */
asChild?: boolean;
/** Show a spinner and disable interaction, keeping the button's width. */
loading?: boolean;
/** Screen-reader-only label announced while `loading` is true. */
loadingLabel?: string;
/** Element rendered before the label. */
leadingIcon?: React.ReactNode;
/** Element rendered after the label. */
trailingIcon?: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
fullWidth,
asChild = false,
loading = false,
loadingLabel = "Loading",
disabled,
leadingIcon,
trailingIcon,
type,
children,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="button"
type={asChild ? undefined : (type ?? "button")}
className={cn(buttonVariants({ variant, size, fullWidth }), className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
aria-disabled={asChild && (disabled || loading) ? true : undefined}
{...props}
>
{asChild ? (
children
) : (
<>
{loading ? (
<LoaderCircle
className="animate-spin"
aria-hidden="true"
data-icon="inline-start"
/>
) : leadingIcon ? (
<span data-icon="inline-start">{leadingIcon}</span>
) : null}
{children}
{loading ? (
<span className="sr-only">{loadingLabel}</span>
) : trailingIcon ? (
<span data-icon="inline-end">{trailingIcon}</span>
) : null}
</>
)}
</Comp>
);
},
);
Button.displayName = "Button";
registry/rck/ui/field-chrome.tsx
import * as React from "react";
import { cva, type VariantProps } from "@/lib/cva";
import { cn } from "@/lib/utils";
export const fieldWrapperVariants = cva(
"flex w-full items-center gap-2 rounded-md border bg-background px-3 shadow-sm " +
"transition-colors has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 " +
"has-[textarea:disabled]:cursor-not-allowed has-[textarea:disabled]:opacity-50",
{
variants: {
size: {
sm: "h-8 text-[10px]",
md: "h-[39px] text-[11px]",
lg: "h-11 text-xs",
},
invalid: {
true: "border-destructive focus-within:ring-2 focus-within:ring-destructive/40",
false:
"border-border-strong focus-within:border-primary focus-within:ring-2 focus-within:ring-ring/30",
},
},
defaultVariants: {
size: "md",
invalid: false,
},
},
);
/** Built-in field label — shared by Input, Textarea, and boolean field wrappers. */
export function FieldLabel({
className,
required,
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement> & { required?: boolean }) {
return (
<label
data-slot="field-label"
className={cn("text-foreground text-sm leading-none font-medium", className)}
{...props}
>
{children}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
);
}
export function FieldMessage({
className,
invalid,
...props
}: React.HTMLAttributes<HTMLParagraphElement> & { invalid?: boolean }) {
return (
<p
data-slot="field-message"
className={cn(
"text-xs",
invalid ? "text-destructive" : "text-muted-foreground",
className,
)}
{...props}
/>
);
}
export type ControlPosition = "start" | "end";
export interface BooleanGroupOption {
value: string;
/** Inline label beside the control. */
text: string;
disabled?: boolean;
helpText?: React.ReactNode;
}
export interface BooleanFieldLayoutProps {
label?: React.ReactNode;
text?: React.ReactNode;
helpText?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
/** Places the control at inline-start (default) or inline-end — flips automatically in RTL. */
controlPosition?: ControlPosition;
disabled?: boolean;
id?: string;
className?: string;
containerClassName?: string;
children: (args: {
id: string;
describedBy?: string;
disabled?: boolean;
}) => React.ReactNode;
}
export function BooleanFieldLayout({
label,
text,
helpText,
error,
required,
controlPosition = "start",
disabled,
id: providedId,
className,
containerClassName,
children,
}: BooleanFieldLayoutProps) {
const generatedId = React.useId();
const id = providedId ?? generatedId;
const helpTextId = helpText && !error ? `${id}-help-text` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = cn(helpTextId, errorId) || undefined;
const control = children({ id, describedBy, disabled });
return (
<div
data-slot="boolean-field"
className={cn("flex flex-col gap-1.5", containerClassName)}
>
{label && (
<FieldLabel htmlFor={id} required={required}>
{label}
</FieldLabel>
)}
<div
className={cn(
"flex w-full items-start gap-2",
controlPosition === "end" && "flex-row-reverse justify-between",
className,
)}
>
{control}
{text && (
<label
htmlFor={id}
className={cn(
"text-foreground text-sm leading-none font-medium",
controlPosition === "end" && "flex-1",
disabled && "cursor-not-allowed opacity-50",
)}
>
{text}
</label>
)}
</div>
{helpText && !error && <FieldMessage id={helpTextId}>{helpText}</FieldMessage>}
{error && (
<FieldMessage id={errorId} invalid role="alert">
{error}
</FieldMessage>
)}
</div>
);
}
export type FieldWrapperVariantProps = VariantProps<typeof fieldWrapperVariants>;
Related examples
Options APISingle-select time range and multi-select formatting toolbar with a separator.
VerticalStacked zoom controls with label and help text.