Checkbox Group
Several checkboxes that share one value (the list of checked items), with a group label, description and error message. Built from Checkbox, Field and Fieldset.
Installation
Add Checkbox Group to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.
npx ck-ui-cli@latest add checkbox-group- Adds
CheckboxGroup.tsxCheckbox.tsxField.tsxFieldset.tsxLabel.tsxto your CK-UI folder (src/components/ck-ui by default)- Installs
@radix-ui/react-checkbox@radix-ui/react-label@radix-ui/react-slotif your project does not have them yet
First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.
Manual installationInstall the packages and copy the files yourself.
Install the following dependencies
Run these commands in your terminal to install the required packages
npm install @radix-ui/react-checkbox @radix-ui/react-slot @radix-ui/react-labelAdd the Checkbox component
CheckboxGroup imports ./Checkbox, so add this file next to it
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { cn } from "@/lib/utils";
import { useFieldControlProps } from "./Field";
export type CheckboxRootProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
/** Error state (aria-invalid). Also taken from Field.Root. */
invalid?: boolean;
};
/*
* The 20px control stacks its layers in a single grid cell: a 16px coloured plate, a raised surface over it while the
* box is empty, and the two marks. Each layer is styled from the Radix root's data-state (and disabled/aria-invalid),
* so the component keeps no state of its own. The check mark uses pathLength=1, so its draw-in needs no measured length.
*/
const layer = "col-start-1 row-start-1";
const Root = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, CheckboxRootProps>(({ className, invalid, ...props }, ref) => {
const controlProps = useFieldControlProps(props, { invalid });
return (
<CheckboxPrimitive.Root
ref={ref}
className={cn("group/checkbox relative grid size-5 shrink-0 place-items-center rounded-md focus-ring disabled:cursor-not-allowed", className)}
{...controlProps}
>
<span
aria-hidden="true"
data-slot="plate"
className={cn(
layer,
"size-4 rounded-[4px] bg-input transition-colors duration-base ease-out",
"group-hover/checkbox:bg-muted-foreground/40",
"group-aria-[invalid=true]/checkbox:bg-destructive",
"group-data-[state=checked]/checkbox:bg-primary group-data-[state=indeterminate]/checkbox:bg-primary",
"group-hover/checkbox:group-data-[state=checked]/checkbox:bg-primary-hover group-hover/checkbox:group-data-[state=indeterminate]/checkbox:bg-primary-hover",
"group-disabled/checkbox:bg-muted group-disabled/checkbox:group-data-[state=checked]/checkbox:bg-muted group-disabled/checkbox:group-data-[state=indeterminate]/checkbox:bg-muted"
)}
/>
{/* The surface sits 1.5px inside the plate (concentric corners), lifted by the design system's regular shadow. */}
<span
aria-hidden="true"
data-slot="surface"
className={cn(
layer,
"size-[13px] rounded-[2.5px] bg-background shadow-regular transition-opacity duration-base ease-out",
"group-data-[state=checked]/checkbox:opacity-0 group-data-[state=indeterminate]/checkbox:opacity-0 group-disabled/checkbox:hidden"
)}
/>
<CheckboxPrimitive.Indicator forceMount className={cn(layer, "grid size-4 place-items-center")}>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" data-slot="check" className={cn(layer, "size-4 opacity-0 group-data-[state=checked]/checkbox:opacity-100")}>
<path
d="M4.25 8 6.75 10.5 11.75 5.5"
pathLength={1}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
"stroke-primary-foreground dark:stroke-background",
"[stroke-dasharray:1] [stroke-dashoffset:1] group-data-[state=checked]/checkbox:[stroke-dashoffset:0]",
"transition-[stroke-dashoffset] duration-slow ease-out motion-reduce:transition-none"
)}
/>
</svg>
<span
aria-hidden="true"
data-slot="dash"
className={cn(
layer,
"h-[1.5px] w-2 origin-left scale-x-0 rounded-full bg-primary-foreground dark:bg-background",
"transition-transform duration-slow ease-out motion-reduce:transition-none group-data-[state=indeterminate]/checkbox:scale-x-100"
)}
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});
Root.displayName = "Checkbox.Root";
export { Root };
Add the Field component
CheckboxGroup imports ./Field, so add this file next to it
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";
/*
* Field wires a label, description and message to one form control:
* Field.Label → <label for={controlId}>
* Field.Description → referenced by aria-describedby
* Field.Message → referenced by aria-describedby (error text when invalid)
* invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
* Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
* useFieldControlProps. It is plain React state: it works with native forms and form libraries.
*/
type FieldContextValue = {
controlId: string;
labelId: string;
descriptionId: string;
messageId: string;
invalid: boolean;
required: boolean;
disabled: boolean;
hasDescription: boolean;
hasMessage: boolean;
setHasDescription: (value: boolean) => void;
setHasMessage: (value: boolean) => void;
};
const FieldContext = React.createContext<FieldContextValue | null>(null);
export function useField() {
return React.useContext(FieldContext);
}
/**
* Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
* this lets each Field.Root reflect that state (label styling, `disabled` on the control).
*/
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);
export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
/** Id for the control; generated when omitted. */
id?: string;
invalid?: boolean;
required?: boolean;
disabled?: boolean;
}
const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
const fieldset = React.useContext(FieldsetContext);
const disabled = disabledProp || Boolean(fieldset?.disabled);
const generatedId = React.useId();
const controlId = id ?? `field-${generatedId}`;
const [hasDescription, setHasDescription] = React.useState(false);
const [hasMessage, setHasMessage] = React.useState(false);
const value = React.useMemo<FieldContextValue>(
() => ({
controlId,
labelId: `${controlId}-label`,
descriptionId: `${controlId}-description`,
messageId: `${controlId}-message`,
invalid,
required,
disabled,
hasDescription,
hasMessage,
setHasDescription,
setHasMessage,
}),
[controlId, invalid, required, disabled, hasDescription, hasMessage]
);
return (
<FieldContext.Provider value={value}>
<div
ref={ref}
className={cn("flex flex-col gap-1.5", className)}
data-invalid={invalid || undefined}
data-disabled={disabled || undefined}
{...props}
>
{children}
</div>
</FieldContext.Provider>
);
});
Root.displayName = "Field.Root";
type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;
const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
const field = useField();
return (
<Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
{children}
{field?.required && <Label.Asterisk />}
</Label.Root>
);
});
FieldLabel.displayName = "Field.Label";
const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
const field = useField();
const setHasDescription = field?.setHasDescription;
React.useEffect(() => {
if (!setHasDescription) return;
setHasDescription(true);
return () => setHasDescription(false);
}, [setHasDescription]);
return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";
export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
/** Only render while the field is invalid (default true). */
showWhenValid?: boolean;
}
const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
const field = useField();
const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
const setHasMessage = field?.setHasMessage;
React.useEffect(() => {
if (!setHasMessage || !visible) return;
setHasMessage(true);
return () => setHasMessage(false);
}, [setHasMessage, visible]);
if (!visible) return null;
return (
<p
ref={ref}
id={field?.messageId}
aria-live="polite"
className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
{...props}
>
{children}
</p>
);
});
Message.displayName = "Field.Message";
type ControlAttributes = {
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
"aria-required"?: React.AriaAttributes["aria-required"];
required?: boolean;
disabled?: boolean;
};
/**
* Merges Field context into a control's props. Explicit props win; describedby ids are combined.
*/
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
const field = useField();
const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");
const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
.filter(Boolean)
.join(" ");
return {
...props,
id: props.id ?? field?.controlId,
"aria-describedby": describedBy || undefined,
"aria-invalid": invalid || undefined,
required: props.required ?? (field?.required || undefined),
disabled: props.disabled ?? (field?.disabled || undefined),
};
}
/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
const controlProps = useFieldControlProps(props);
return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";
export { Root, FieldLabel as Label, Description, Message, Control };
Add the Fieldset component
CheckboxGroup imports ./Fieldset, so add this file next to it
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { FieldsetContext } from "./Field";
import * as Label from "./Label";
/*
* Fieldset groups related controls under a real <fieldset> / <legend>:
* Fieldset.Legend → the group's accessible name
* Fieldset.Description → referenced by aria-describedby on the fieldset
* Fieldset.Message → referenced by aria-describedby while the group is invalid
* disabled → native <fieldset disabled>; Field.Root children follow it
*/
type FieldsetPartsContextValue = {
descriptionId: string;
messageId: string;
invalid: boolean;
required: boolean;
disabled: boolean;
setHasDescription: (value: boolean) => void;
setHasMessage: (value: boolean) => void;
};
const FieldsetPartsContext = React.createContext<FieldsetPartsContextValue | null>(null);
export function useFieldset() {
return React.useContext(FieldsetPartsContext);
}
export interface FieldsetRootProps extends React.FieldsetHTMLAttributes<HTMLFieldSetElement> {
/** Shows Fieldset.Message and marks the group as having an error. */
invalid?: boolean;
/** Adds a required marker to the legend. Validation stays on the controls. */
required?: boolean;
}
const Root = React.forwardRef<HTMLFieldSetElement, FieldsetRootProps>(
({ id, invalid = false, required = false, disabled = false, className, children, "aria-describedby": ariaDescribedBy, ...props }, ref) => {
const generatedId = React.useId();
const baseId = id ?? `fieldset-${generatedId}`;
const [hasDescription, setHasDescription] = React.useState(false);
const [hasMessage, setHasMessage] = React.useState(false);
const parts = React.useMemo<FieldsetPartsContextValue>(
() => ({
descriptionId: `${baseId}-description`,
messageId: `${baseId}-message`,
invalid,
required,
disabled,
setHasDescription,
setHasMessage,
}),
[baseId, invalid, required, disabled]
);
const fieldset = React.useMemo(() => ({ disabled }), [disabled]);
const describedBy = [ariaDescribedBy, hasDescription ? parts.descriptionId : undefined, hasMessage ? parts.messageId : undefined].filter(Boolean).join(" ");
return (
<FieldsetPartsContext.Provider value={parts}>
<FieldsetContext.Provider value={fieldset}>
<fieldset
ref={ref}
id={id}
disabled={disabled}
aria-describedby={describedBy || undefined}
data-invalid={invalid || undefined}
data-disabled={disabled || undefined}
className={cn("group/fieldset relative m-0 min-w-0 border-0 p-0", className)}
{...props}
>
{children}
</fieldset>
</FieldsetContext.Provider>
</FieldsetPartsContext.Provider>
);
}
);
Root.displayName = "Fieldset.Root";
const Legend = React.forwardRef<HTMLLegendElement, React.HTMLAttributes<HTMLLegendElement>>(({ className, children, ...props }, ref) => {
const fieldset = useFieldset();
return (
<legend
ref={ref}
// Floated so it never sits on the fieldset border (native legend rendering); later parts clear it.
className={cn("float-left mb-1 w-full p-0 size-label-sm text-foreground group-data-[disabled]/fieldset:text-muted-foreground/60", className)}
{...props}
>
{children}
{fieldset?.required && (
<>
<Label.Asterisk className="ml-px" />{" "}
<span className="sr-only">(required)</span>
</>
)}
</legend>
);
});
Legend.displayName = "Fieldset.Legend";
const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
const fieldset = useFieldset();
const setHasDescription = fieldset?.setHasDescription;
React.useEffect(() => {
if (!setHasDescription) return;
setHasDescription(true);
return () => setHasDescription(false);
}, [setHasDescription]);
return <p ref={ref} id={fieldset?.descriptionId} className={cn("clear-left size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Fieldset.Description";
/** Layout wrapper for the controls in the group. */
const Content = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("clear-left mt-3 flex flex-col gap-4", className)} {...props} />
));
Content.displayName = "Fieldset.Content";
export interface FieldsetMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
/** Also render while the group is valid (default false). */
showWhenValid?: boolean;
}
const Message = React.forwardRef<HTMLParagraphElement, FieldsetMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
const fieldset = useFieldset();
const visible = Boolean(children) && (showWhenValid || !fieldset || fieldset.invalid);
const setHasMessage = fieldset?.setHasMessage;
React.useEffect(() => {
if (!setHasMessage || !visible) return;
setHasMessage(true);
return () => setHasMessage(false);
}, [setHasMessage, visible]);
if (!visible) return null;
return (
<p
ref={ref}
id={fieldset?.messageId}
aria-live="polite"
className={cn("clear-left mt-2 size-p-xs", fieldset?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
{...props}
>
{children}
</p>
);
});
Message.displayName = "Fieldset.Message";
export { Root, Legend, Description, Content, Message };
Add the Label component
CheckboxGroup imports ./Label, so add this file next to it
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
/** Dims the label. The control itself must also be disabled. */
disabled?: boolean;
}
const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
return (
<LabelPrimitive.Root
ref={ref}
data-disabled={disabled || undefined}
className={cn(
"group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
className
)}
{...props}
/>
);
});
Root.displayName = "Label.Root";
// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
<span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
{children || "*"}
</span>
));
Asterisk.displayName = "Label.Asterisk";
const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
<span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";
export { Root, Asterisk, Sub };
Create or update your CheckboxGroup component
Copy this code to create your CheckboxGroup component
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import * as Checkbox from "./Checkbox";
import * as Field from "./Field";
import * as Fieldset from "./Fieldset";
/*
* A group of Checkbox controls inside a Fieldset. The value is the list of checked item values.
* Each item submits `name=value` with native forms (Radix renders a hidden input per checked box).
* `required` means "at least one": it is enforced by one hidden, unnamed native checkbox so the browser
* blocks submission, and the group then shows CheckboxGroup.Message instead of a browser bubble.
*/
export type CheckboxGroupOrientation = "vertical" | "horizontal";
type CheckboxGroupContextValue = {
name?: string;
form?: string;
value: string[];
setChecked: (itemValue: string, checked: boolean) => void;
invalid: boolean;
orientation: CheckboxGroupOrientation;
};
const CheckboxGroupContext = React.createContext<CheckboxGroupContextValue | null>(null);
function useCheckboxGroup(part: string) {
const context = React.useContext(CheckboxGroupContext);
if (!context) throw new Error(`<CheckboxGroup.${part}> must be rendered inside <CheckboxGroup.Root>.`);
return context;
}
function useControllableValue(value: string[] | undefined, defaultValue: string[] | undefined, onValueChange: ((value: string[]) => void) | undefined) {
const [uncontrolled, setUncontrolled] = React.useState<string[]>(defaultValue ?? []);
const isControlled = value !== undefined;
const setValue = React.useCallback(
(next: string[]) => {
if (!isControlled) setUncontrolled(next);
onValueChange?.(next);
},
[isControlled, onValueChange]
);
return [isControlled ? value : uncontrolled, setValue] as const;
}
export interface CheckboxGroupRootProps extends Omit<Fieldset.FieldsetRootProps, "defaultValue" | "onChange"> {
/** Submitted once per checked item (`name=value`). */
name?: string;
/** Checked item values (controlled). */
value?: string[];
/** Initially checked item values (uncontrolled). */
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** At least one item must be checked before the form submits. */
required?: boolean;
/** Error state: shows CheckboxGroup.Message and marks every checkbox aria-invalid. */
invalid?: boolean;
/** Lays the items out in a column (default) or a wrapping row. */
orientation?: CheckboxGroupOrientation;
}
const Root = React.forwardRef<HTMLFieldSetElement, CheckboxGroupRootProps>(
({ name, form, value: valueProp, defaultValue, onValueChange, required = false, invalid = false, disabled = false, orientation = "vertical", children, ...props }, ref) => {
const [value, setValue] = useControllableValue(valueProp, defaultValue, onValueChange);
const [missingRequired, setMissingRequired] = React.useState(false);
const validationRef = React.useRef<HTMLInputElement>(null);
const fieldsetRef = React.useRef<HTMLFieldSetElement | null>(null);
const composedRef = React.useCallback(
(node: HTMLFieldSetElement | null) => {
fieldsetRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref]
);
const showRequiredError = required && missingRequired && value.length === 0;
const isInvalid = invalid || showRequiredError;
const setChecked = React.useCallback(
(itemValue: string, checked: boolean) => {
const next = checked ? (value.includes(itemValue) ? value : [...value, itemValue]) : value.filter((entry) => entry !== itemValue);
if (next !== value) setValue(next);
},
[value, setValue]
);
// Clear the "at least one" error when the form is reset.
React.useEffect(() => {
const formElement = validationRef.current?.form;
if (!formElement) return;
const onReset = () => setMissingRequired(false);
formElement.addEventListener("reset", onReset);
return () => formElement.removeEventListener("reset", onReset);
}, [required]);
const handleInvalid = (event: React.FormEvent<HTMLInputElement>) => {
// Replace the browser bubble (it would point at a hidden input) with the group's own message.
event.preventDefault();
setMissingRequired(true);
fieldsetRef.current?.querySelector<HTMLButtonElement>("button[role=checkbox]:not(:disabled)")?.focus();
};
const context = React.useMemo(() => ({ name, form, value, setChecked, invalid: isInvalid, orientation }), [name, form, value, setChecked, isInvalid, orientation]);
return (
<CheckboxGroupContext.Provider value={context}>
<Fieldset.Root
ref={composedRef}
form={form}
required={required}
invalid={isInvalid}
disabled={disabled}
data-orientation={orientation}
{...props}
>
{children}
{required && (
<input
ref={validationRef}
type="checkbox"
aria-hidden="true"
tabIndex={-1}
form={form}
required
checked={value.length > 0}
onChange={() => {}}
onInvalid={handleInvalid}
className="pointer-events-none absolute left-0 top-0 m-0 size-px opacity-0"
/>
)}
</Fieldset.Root>
</CheckboxGroupContext.Provider>
);
}
);
Root.displayName = "CheckboxGroup.Root";
/** The group label, rendered as the fieldset's <legend>. */
const Label = React.forwardRef<HTMLLegendElement, React.HTMLAttributes<HTMLLegendElement>>((props, ref) => <Fieldset.Legend ref={ref} {...props} />);
Label.displayName = "CheckboxGroup.Label";
const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>((props, ref) => <Fieldset.Description ref={ref} {...props} />);
Description.displayName = "CheckboxGroup.Description";
/** Wraps the items and lays them out according to `orientation`. */
const Content = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => {
const { orientation } = useCheckboxGroup("Content");
return (
<Fieldset.Content
ref={ref}
data-orientation={orientation}
className={cn(orientation === "horizontal" ? "flex-row flex-wrap gap-x-6 gap-y-3" : "gap-3", className)}
{...props}
/>
);
});
Content.displayName = "CheckboxGroup.Content";
export interface CheckboxGroupItemProps
extends Omit<Checkbox.CheckboxRootProps, "checked" | "defaultChecked" | "onCheckedChange" | "value" | "name" | "form" | "children" | "invalid"> {
/** Submitted as `name=value` when checked. */
value: string;
/** The item's label. */
children: React.ReactNode;
/** Secondary text under the label (wired with aria-describedby). */
description?: React.ReactNode;
}
/** One labelled checkbox. `className` goes on the item row; other props go to the checkbox. */
const Item = React.forwardRef<HTMLButtonElement, CheckboxGroupItemProps>(({ value, children, description, disabled, id, className, ...props }, ref) => {
const group = useCheckboxGroup("Item");
return (
<Field.Root id={id} disabled={disabled} invalid={group.invalid} className={cn("flex-row items-start gap-2.5", className)}>
<Checkbox.Root
ref={ref}
name={group.name}
form={group.form}
value={value}
checked={group.value.includes(value)}
onCheckedChange={(checked) => group.setChecked(value, checked === true)}
{...props}
/>
<div className="flex min-w-0 flex-col gap-0.5">
<Field.Label className="leading-5">{children}</Field.Label>
{description && <Field.Description>{description}</Field.Description>}
</div>
</Field.Root>
);
});
Item.displayName = "CheckboxGroup.Item";
/** Error text for the group. Rendered while the group is invalid (including a missed `required`). */
const Message = React.forwardRef<HTMLParagraphElement, Fieldset.FieldsetMessageProps>((props, ref) => <Fieldset.Message ref={ref} {...props} />);
Message.displayName = "CheckboxGroup.Message";
export { Root, Label, Description, Content, Item, Message };
Test Your Component
- 1. Add the component to a test page
- 2. Check all props and variants work as expected
- 3. Verify responsive behavior
- 4. Test with different content/data
- 5. Ensure accessibility features work correctly
Horizontal
orientation="horizontal" lays the items out in a wrapping row.
Required
required means at least one item. Submitting with none checked is blocked by the browser; the group then shows CheckboxGroup.Message, marks the checkboxes invalid and focuses the first one. Checked items are submitted as name=value pairs.
Controlled
Own the value with value and onValueChange, for example to limit the number of choices.
Disabled
Disable the whole group, or single items with their own disabled.
Accessibility
- Rendered as a fieldset (role group) named by
CheckboxGroup.Label(the legend), with the description and error message on its aria-describedby. - Each checkbox has its own label and optional description through Field.
- Tab moves between checkboxes and Space toggles them, as with native checkboxes. Arrow keys are not used.
- Required groups add visually hidden "(required)" text to the label, because aria-required is not supported on groups.
- The "at least one" check uses a hidden, unnamed native checkbox (aria-hidden, tabIndex -1) that is never submitted. Its browser message is replaced by CheckboxGroup.Message.
- A disabled group disables every checkbox natively; disabled items are skipped by Tab and not submitted.
API Reference
Import with import * as CheckboxGroup from "@/components/ui/CheckboxGroup".
CheckboxGroup.Root
A Fieldset.Root holding the group's value.
| Prop | Type | Default | Description |
|---|---|---|---|
name | "string" | "undefined" | Submitted once per checked item. |
value | "string[]" | "undefined" | Checked values (controlled). |
defaultValue | "string[]" | "[]" | Initially checked values (uncontrolled). |
onValueChange | "(value: string[]) => void" | "undefined" | Called with the new list. |
required | "boolean" | "false" | At least one item must be checked to submit. |
invalid | "boolean" | "false" | Error state from your own validation. |
disabled | "boolean" | "false" | Disables every item. |
orientation | "vertical""horizontal" | "vertical" | Item layout. |
form | "string" | "undefined" | Associates the checkboxes with a form by id. |
className | "string" | "undefined" | Additional classes, merged last. |
CheckboxGroup.Label
The group name (legend).
| Prop | Type | Default | Description |
|---|---|---|---|
className | "string" | "undefined" | Additional classes, merged last. |
CheckboxGroup.Description
Help text for the group.
| Prop | Type | Default | Description |
|---|---|---|---|
className | "string" | "undefined" | Additional classes, merged last. |
CheckboxGroup.Content
Lays out the items.
| Prop | Type | Default | Description |
|---|---|---|---|
className | "string" | "undefined" | Additional classes, merged last. |
CheckboxGroup.Item
A labelled Checkbox. children is the label. Other props go to Checkbox.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
value | "string" | "-" | Item value. Required. |
description | "ReactNode" | "undefined" | Secondary text under the label. |
disabled | "boolean" | "false" | Disables this item. |
id | "string" | "generated" | Id of the checkbox. |
className | "string" | "undefined" | Classes for the item row. |
CheckboxGroup.Message
Error text, shown while the group is invalid or a required check failed.
| Prop | Type | Default | Description |
|---|---|---|---|
className | "string" | "undefined" | Additional classes, merged last. |