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.json

Live 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

PropTypeDefaultDescription
options*ButtonGroupOption | { type: "separator" }[]Option list rendered as merged buttons; separator items divide segments.
multiplebooleanfalseWhen true, value is string[] and options toggle independently.
value / defaultValuestring | string[]Controlled / uncontrolled selected value(s).
onValueChange(value: string | string[]) => voidFires when the selection changes.
labelReact.ReactNodeTop field label.
helpTextReact.ReactNodeGroup-level help text.
errorReact.ReactNodeError text; sets `aria-invalid` on the group.
requiredbooleanMarks the field as required.
orientation"horizontal" | "vertical"horizontalLayout direction for merged buttons.
sizeButton sizesmSize applied to every option button.
variantButton variantoutlineVariant for unselected options.
selectedVariantButton variantsolidVariant for selected options.
disabledbooleanDisables every option in the group.
classNamestringMerged 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

Browse the full examples gallery