Forms

SwitchGroup

SwitchGroup is a full form field for independent on/off settings — pass `label`, `helpText`, and an `options` array. Value is a `string[]` of enabled option values.

Installation

npx shadcn add https://rck.vibeboxph.com//r/switch-group.json

Live preview & controls

Toggle each setting independently.

Controls
<SwitchGroup label="Privacy settings" defaultValue={["profile", "analytics"]} options={[...]} />

Anatomy

  • SwitchGroupField container with label, option list, and group help text.

Props

PropTypeDefaultDescription
options*{ value: string; text: string; disabled?: boolean; helpText?: React.ReactNode }[]Multi-toggle option list.
value / defaultValuestring[]Controlled / uncontrolled enabled values.
onValueChange(value: string[]) => voidFires when the enabled set changes.
labelReact.ReactNodeTop field label.
helpTextReact.ReactNodeGroup-level help text.
orientation"vertical" | "horizontal"verticalStack options vertically or wrap horizontally.
controlPosition"start" | "end"endInline-start/end placement for each option.

Accessibility

  • Uses `role="group"` with `aria-labelledby` for the shared field label.
  • Each switch is individually labeled and keyboard accessible.

Source

registry/rck/ui/switch-group.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useControllableState } from "@/hooks/use-controllable-state";
import { SwitchControl } from "@/registry/rck/ui/switch";
import {
  BooleanFieldLayout,
  FieldLabel,
  FieldMessage,
  type BooleanGroupOption,
  type ControlPosition,
} from "@/registry/rck/ui/field-chrome";

export type { BooleanGroupOption as SwitchGroupOption } from "@/registry/rck/ui/field-chrome";

export interface SwitchGroupProps {
  label?: React.ReactNode;
  helpText?: React.ReactNode;
  error?: React.ReactNode;
  required?: boolean;
  containerClassName?: string;
  className?: string;
  id?: string;
  disabled?: boolean;
  options: ReadonlyArray<BooleanGroupOption>;
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  controlPosition?: ControlPosition;
  orientation?: "vertical" | "horizontal";
}

export function SwitchGroup({
  label,
  helpText,
  error,
  required,
  containerClassName,
  className,
  id: providedId,
  disabled,
  options,
  value,
  defaultValue = [],
  onValueChange,
  controlPosition = "end",
  orientation = "vertical",
}: SwitchGroupProps) {
  const [selected, setSelected] = useControllableState<string[]>({
    prop: value,
    defaultProp: defaultValue,
    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 toggle(optionValue: string, checked: boolean) {
    setSelected((current) =>
      checked
        ? current.includes(optionValue)
          ? current
          : [...current, optionValue]
        : current.filter((item) => item !== optionValue),
    );
  }

  return (
    <div
      data-slot="switch-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="switch-group"
        aria-labelledby={label ? `${groupId}-label` : undefined}
        aria-describedby={describedBy}
        aria-invalid={error ? true : undefined}
        className={cn(
          orientation === "horizontal" ? "flex flex-wrap gap-4" : "grid gap-2",
          className,
        )}
      >
        {options.map((option) => (
          <BooleanFieldLayout
            key={option.value}
            text={option.text}
            helpText={option.helpText}
            controlPosition={controlPosition}
            disabled={disabled || option.disabled}
            className="items-center"
          >
            {({
              id: fieldId,
              describedBy: optionDescribedBy,
              disabled: fieldDisabled,
            }) => (
              <SwitchControl
                id={fieldId}
                disabled={fieldDisabled}
                aria-describedby={optionDescribedBy}
                checked={selected.includes(option.value)}
                onCheckedChange={(checked) => toggle(option.value, checked)}
              />
            )}
          </BooleanFieldLayout>
        ))}
      </div>
      {helpText && !error && <FieldMessage id={helpTextId}>{helpText}</FieldMessage>}
      {error && (
        <FieldMessage id={errorId} invalid role="alert">
          {error}
        </FieldMessage>
      )}
    </div>
  );
}
registry/rck/ui/switch.tsx
"use client";

import * as React from "react";
import * as SwitchPrimitive from "@/registry/rck/primitives/switch";
import { cn } from "@/lib/utils";
import { BooleanFieldLayout, type ControlPosition } from "@/registry/rck/ui/field-chrome";

export type SwitchControlProps = React.ComponentPropsWithoutRef<
  typeof SwitchPrimitive.Root
>;

/** Bare switch control — use `Switch` for a labeled field or compose inside groups. */
export const SwitchControl = React.forwardRef<
  React.ComponentRef<typeof SwitchPrimitive.Root>,
  SwitchControlProps
>(({ className, ...props }, ref) => (
  <SwitchPrimitive.Root
    ref={ref}
    data-slot="switch-control"
    className={cn(
      "inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent " +
        "bg-muted shadow-sm transition-colors focus-visible:ring-2 focus-visible:outline-none " +
        "focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-offset-2 " +
        "disabled:cursor-not-allowed disabled:opacity-50 " +
        "data-[state=checked]:bg-primary",
      className,
    )}
    {...props}
  >
    <SwitchPrimitive.Thumb
      data-slot="switch-thumb"
      className={cn(
        "bg-background pointer-events-none block size-4 rounded-full shadow-sm ring-0 transition-transform " +
          "data-[state=unchecked]:translate-x-0 " +
          "data-[state=checked]:translate-x-4 rtl:data-[state=checked]:-translate-x-4",
      )}
    />
  </SwitchPrimitive.Root>
));
SwitchControl.displayName = "SwitchControl";

export function Switch({
  className,
  containerClassName,
  label,
  text,
  helpText,
  error,
  required,
  controlPosition = "end",
  id,
  disabled,
  ...props
}: SwitchControlProps & {
  label?: React.ReactNode;
  text?: React.ReactNode;
  helpText?: React.ReactNode;
  error?: React.ReactNode;
  required?: boolean;
  controlPosition?: ControlPosition;
  id?: string;
  containerClassName?: string;
}) {
  return (
    <BooleanFieldLayout
      label={label}
      text={text}
      helpText={helpText}
      error={error}
      required={required}
      controlPosition={controlPosition}
      disabled={disabled}
      id={id}
      className={cn("items-center", className)}
      containerClassName={containerClassName}
    >
      {({ id: fieldId, describedBy, disabled: fieldDisabled }) => (
        <SwitchControl
          id={fieldId}
          disabled={fieldDisabled}
          aria-describedby={describedBy}
          aria-invalid={error ? true : undefined}
          required={required}
          {...props}
        />
      )}
    </BooleanFieldLayout>
  );
}

/** @deprecated Use `Switch` instead. */
export const SwitchField = Switch;

/** @deprecated Use `SwitchControl` instead. */
export const SwitchRoot = SwitchControl;
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