Skip to content

Collapsible

One trigger that shows and hides one region: "show more" lists, advanced settings, details under a summary. The trigger and the content can sit anywhere inside the root.

4 repositories

ck-ui/core

Installation

Add Collapsible 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 collapsible
Adds
Collapsible.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@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

Create or update your Collapsible component

Copy this code to create your Collapsible component

/components/ui/Collapsible.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";

/*
 * One trigger that shows and hides one region.
 *   Collapsible.Trigger → <button aria-expanded aria-controls>
 *   Collapsible.Content → stays in the DOM and is `hidden` while closed, so aria-controls always resolves
 * Built on React + Radix Slot only. For a list of sections where only some are open, use Accordion.
 */

const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

type CollapsibleContextValue = {
    open: boolean;
    disabled: boolean;
    contentId: string;
    setContentId: (id: string | undefined) => void;
    toggle: () => void;
};

const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null);

function useCollapsible(part: string) {
    const context = React.useContext(CollapsibleContext);
    if (!context) throw new Error(`<Collapsible.${part}> must be rendered inside <Collapsible.Root>.`);
    return context;
}

const stateAttribute = (open: boolean) => (open ? "open" : "closed");

export interface CollapsibleRootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Open state (controlled). */
    open?: boolean;
    /** Initial open state (uncontrolled). */
    defaultOpen?: boolean;
    onOpenChange?: (open: boolean) => void;
    /** Prevents the trigger from toggling. */
    disabled?: boolean;
    asChild?: boolean;
}

const Root = React.forwardRef<HTMLDivElement, CollapsibleRootProps>(
    ({ open: openProp, defaultOpen = false, onOpenChange, disabled = false, asChild = false, children, ...props }, ref) => {
        const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
        const isControlled = openProp !== undefined;
        const open = isControlled ? openProp : uncontrolledOpen;

        const generatedId = `collapsible-${React.useId()}`;
        const [customContentId, setContentId] = React.useState<string | undefined>(undefined);

        const toggle = React.useCallback(() => {
            if (!isControlled) setUncontrolledOpen(!open);
            onOpenChange?.(!open);
        }, [isControlled, open, onOpenChange]);

        const context = React.useMemo(
            () => ({ open, disabled, contentId: customContentId ?? generatedId, setContentId, toggle }),
            [open, disabled, customContentId, generatedId, toggle]
        );
        const Comp = asChild ? Slot : "div";

        return (
            <CollapsibleContext.Provider value={context}>
                <Comp ref={ref} data-state={stateAttribute(open)} data-disabled={disabled ? "" : undefined} {...props}>
                    {children}
                </Comp>
            </CollapsibleContext.Provider>
        );
    }
);
Root.displayName = "Collapsible.Root";

export interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
    /** Merge the trigger behaviour onto the single child element (e.g. a Button). */
    asChild?: boolean;
}

const Trigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>(({ asChild = false, onClick, disabled, type, ...props }, ref) => {
    const context = useCollapsible("Trigger");
    const isDisabled = Boolean(disabled ?? context.disabled);
    const Comp = asChild ? Slot : "button";

    return (
        <Comp
            ref={ref}
            type={asChild ? type : (type ?? "button")}
            aria-controls={context.contentId}
            aria-expanded={context.open}
            data-state={stateAttribute(context.open)}
            data-disabled={isDisabled ? "" : undefined}
            disabled={isDisabled || undefined}
            onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
                onClick?.(event);
                if (!event.defaultPrevented && !isDisabled) context.toggle();
            }}
            {...props}
        />
    );
});
Trigger.displayName = "Collapsible.Trigger";

export interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Keep the content visible while closed so you can animate it yourself with `data-state`. */
    forceMount?: boolean;
}

const Content = React.forwardRef<HTMLDivElement, CollapsibleContentProps>(({ id, forceMount = false, className, ...props }, ref) => {
    const context = useCollapsible("Content");
    const { setContentId } = context;

    // A consumer id replaces the generated one, and the trigger's aria-controls follows it.
    useIsomorphicLayoutEffect(() => {
        if (!id) return;
        setContentId(id);
        return () => setContentId(undefined);
    }, [id, setContentId]);

    return (
        <div
            ref={ref}
            id={id ?? context.contentId}
            data-state={stateAttribute(context.open)}
            data-disabled={context.disabled ? "" : undefined}
            hidden={!context.open && !forceMount}
            className={cn(
                // [hidden] must win over display utilities (e.g. `flex`) a consumer adds.
                "[&[hidden]]:hidden data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1 data-[state=open]:duration-base",
                className
            )}
            {...props}
        />
    );
});
Content.displayName = "Collapsible.Content";

export { Root, Trigger, Content };

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

Controlled

Pass open and onOpenChange to own the state; use defaultOpen otherwise.

State lives in the parent: closed.

Disabled

disabled on the root disables the trigger.

Accordion vs Collapsible

  • Collapsible: one independent region, often a "show more" or an optional settings block. Free layout: the trigger can be any element (via asChild) and does not need a heading.
  • Accordion: a set of related sections with headings, where you may want only one open at a time (type="single") and arrow-key navigation between headers.
  • If you are repeating several Collapsibles in a list with headings, use an Accordion instead.

Accessibility

  • The trigger is a button with aria-expanded and aria-controls. Enter and Space toggle it.
  • The content stays in the DOM with the hidden attribute while closed, so aria-controls always points at an element and closed content is not focusable or read.
  • With asChild, the child must be a focusable, operable element such as a button.
  • The opening animation (fade and short slide) is removed under reduced motion.
  • Built with React and Radix Slot only. The native <details> element was considered, but it cannot place the trigger outside its container or render it through asChild.

API Reference

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

Collapsible.Root

Holds the open state. Renders a div with data-state.

PropTypeDefaultDescription
open"boolean""undefined"

Controlled open state.

defaultOpen"boolean""false"

Initial state when uncontrolled.

onOpenChange"(open: boolean) => void""undefined"

Called when the trigger toggles.

disabled"boolean""false"

Disables the trigger.

asChild"boolean""false"

Render the single child element instead, merging this part's props and behaviour onto it.

className"string""undefined"

Additional classes, merged last.

Collapsible.Trigger

A button that toggles the content. Exposes data-state="open" | "closed" for styling.

PropTypeDefaultDescription
asChild"boolean""false"

Render your own button (e.g. Button.Root).

className"string""undefined"

Additional classes, merged last.

Collapsible.Content

The region. Hidden while closed.

PropTypeDefaultDescription
forceMount"boolean""false"

Never set hidden; style data-state yourself (e.g. for a height animation).

id"string""generated"

Content id; the trigger's aria-controls follows it.

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.

Marketing