Skip to content

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.

Notification channels

Where we send product updates.

Sent to your primary address.

Standard rates apply.

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.

Terminal
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

Terminal
npm install @radix-ui/react-checkbox @radix-ui/react-slot @radix-ui/react-label

Add the Checkbox component

CheckboxGroup imports ./Checkbox, so add this file next to it

/components/ui/Checkbox.tsx
"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

/components/ui/Field.tsx
"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

/components/ui/Fieldset.tsx
"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

/components/ui/Label.tsx
"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

/components/ui/CheckboxGroup.tsx
"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.

Delivery days

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.

Topics you follow (required)

Pick at least one.

Controlled

Own the value with value and onValueChange, for example to limit the number of choices.

Toppings

Choose up to two. Selected: mushrooms.

Disabled

Disable the whole group, or single items with their own disabled.

Permissions

Only owners can change permissions.

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.

PropTypeDefaultDescription
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).

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

CheckboxGroup.Description

Help text for the group.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

CheckboxGroup.Content

Lays out the items.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

CheckboxGroup.Item

A labelled Checkbox. children is the label. Other props go to Checkbox.Root.

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

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.