Skip to content

Fieldset

Groups related controls under a real <fieldset> and <legend>: a profile section, a set of preferences, an address. Each control keeps its own Field.

Profile

This information appears on your public page.

Include https://

Installation

Add Fieldset 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 fieldset
Adds
Fieldset.tsxField.tsxLabel.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@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-slot @radix-ui/react-label

Add the Field component

Fieldset 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 Label component

Fieldset 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 Fieldset component

Copy this code to create your Fieldset component

/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 };

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

Notification Preferences

A bordered group of switches. Style the fieldset with className.

Email notifications

Choose what we email you about.

When someone replies to you.

When someone mentions you.

A summary every Monday.

Disabled

disabled uses the native fieldset attribute, so every control inside is disabled by the browser, and Field.Root children reflect it in their labels.

Billing address

Managed by your organization's owner.

Invalid

For an error about the group as a whole, set invalid and add Fieldset.Message. Mark the individual fields that are wrong with their own invalid.

Date of birth

February has 28 or 29 days.

Accessibility

  • A native fieldset is a group; its legend is the group name, read when focus enters any control inside.
  • Fieldset.Description and, while invalid, Fieldset.Message are referenced with aria-describedby on the fieldset.
  • The disabled attribute is native: controls inside cannot be focused or submitted.
  • required adds an asterisk and visually hidden "(required)" text to the legend. It does not validate; put required on the fields.
  • Use a Fieldset for groups of related controls, not around a single control.

API Reference

Import with import * as Fieldset from "@/components/ui/Fieldset".

Fieldset.Root

The fieldset element. Other fieldset attributes (name, form) are passed through.

PropTypeDefaultDescription
disabled"boolean""false"

Native disabled; Field.Root children follow it.

invalid"boolean""false"

Shows Fieldset.Message and sets data-invalid.

required"boolean""false"

Required marker on the legend.

className"string""undefined"

Additional classes, merged last.

Fieldset.Legend

The group name.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

Fieldset.Description

Help text for the group.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

Fieldset.Content

A column of controls with consistent spacing.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

Fieldset.Message

Error text for the group, rendered while invalid.

PropTypeDefaultDescription
showWhenValid"boolean""false"

Also render while valid.

className"string""undefined"

Additional classes, merged last.

Used in these blocks

Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.