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.jsonLive 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
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | { value: string; text: string; disabled?: boolean; helpText?: React.ReactNode }[] | — | Multi-toggle option list. |
| value / defaultValue | string[] | — | Controlled / uncontrolled enabled values. |
| onValueChange | (value: string[]) => void | — | Fires when the enabled set changes. |
| label | React.ReactNode | — | Top field label. |
| helpText | React.ReactNode | — | Group-level help text. |
| orientation | "vertical" | "horizontal" | vertical | Stack options vertically or wrap horizontally. |
| controlPosition | "start" | "end" | end | Inline-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>;