Disclosure
A mostly unstyled vertical set of interactive elements that each reveal a section of content when clicked
Dependencies
Source Code
"use client";
import { AnimatePresence, HTMLMotionProps, motion } from "motion/react";
import { createContext, use, useId, useState } from "react";
import { Slot } from "@/components/slot";
import { cn } from "@/lib/utils";
import { CaretDown } from "@phosphor-icons/react";
interface DisclosureGroupContext {
open: string | null;
setOpen: (id: string | null) => void;
}
// let's keep an eye on calc-size
// https://developer.mozilla.org/en-US/docs/Web/CSS/calc-size
// when it's generally available, we can avoid the use of `motion` in this component
const DisclosureGroupContext = createContext<DisclosureGroupContext | null>(
null
);
const useDisclosureGroupContext = () => use(DisclosureGroupContext);
const DisclosureGroup = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = useState<string | null>(null);
return (
<DisclosureGroupContext value={{ open, setOpen }}>
{children}
</DisclosureGroupContext>
);
};
interface DisclosureContext {
id: string;
open: boolean;
setOpen: (open: boolean) => void;
}
const DisclosureContext = createContext<DisclosureContext | null>(null);
const useDisclosureContext = () => {
const context = use(DisclosureContext);
if (!context)
throw new Error("Disclosure components must be used within an Disclosure");
return context;
};
interface DisclosureProps extends React.ComponentPropsWithRef<"div"> {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
const Disclosure = ({
defaultOpen,
open: propsOpen,
onOpenChange,
children,
...props
}: DisclosureProps) => {
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
const generatedId = useId();
const id = props.id ?? generatedId;
const group = useDisclosureGroupContext();
let open = propsOpen ?? internalOpen;
let setOpen = (open: boolean) => {
setInternalOpen(open);
onOpenChange?.(open);
};
if (group) {
open = group.open === id;
setOpen = (open: boolean) => {
group.setOpen(open ? id : null);
};
}
return (
<DisclosureContext value={{ open, setOpen, id }}>
<div {...props}>{children}</div>
</DisclosureContext>
);
};
interface DisclosureTriggerProps extends React.ComponentPropsWithRef<"button"> {
asChild?: boolean;
}
const DisclosureTrigger = ({
children,
onClick,
asChild,
className,
...props
}: DisclosureTriggerProps) => {
const { open, setOpen, id } = useDisclosureContext();
const Comp = asChild ? Slot : "button";
return (
<Comp
type="button"
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) setOpen(!open);
}}
aria-expanded={open}
aria-controls={open ? getContentId(id) : undefined}
data-open={open}
className={cn(
"ring-ring flex w-full items-center justify-between text-left outline-none focus-visible:ring-4",
className
)}
{...props}
>
{children}
</Comp>
);
};
const getContentId = (id: string) => `${id}-Disclosure-content`;
const DisclosureContent = ({
children,
className,
...props
}: Omit<HTMLMotionProps<"div">, "id" | "children"> & {
className?: string;
children: React.ReactNode;
}) => {
const { open, id } = useDisclosureContext();
return (
<AnimatePresence initial={false}>
{open && (
<motion.div
id={getContentId(id)}
className={cn("overflow-hidden", className)}
data-open={open}
transition={{ type: "spring", bounce: 0, visualDuration: 0.15 }}
initial={{ height: 0 }}
animate={{ height: "auto" }}
exit={{ height: 0 }}
{...props}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
const DisclosureChevron = ({
className,
...props
}: React.ComponentPropsWithRef<"span">) => {
const { open } = useDisclosureContext();
return (
<span
aria-hidden="true"
className={cn(
"ease-out-cubic p-1 transition-transform duration-100",
open && "rotate-180",
className
)}
{...props}
>
<CaretDown />
</span>
);
};
export {
Disclosure,
DisclosureTrigger,
DisclosureContent,
DisclosureGroup,
DisclosureChevron,
};
Features
- Unstyled Core: Minimal styling for maximum flexibility
- Smooth Animations: Height transitions using Motion
- Group Support: Optional grouping to create exclusive sections
- Controlled & Uncontrolled: Supports both modes of operation
- Customizable Triggers: Flexible trigger elements with optional chevron
Anatomy
<DisclosureGroup>
<Disclosure>
<DisclosureTrigger>
<DisclosureChevron />
</DisclosureTrigger>
<DisclosureContent />
</Disclosure>
</DisclosureGroup>
API Reference
Disclosure
Extends the div
element.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Whether the element is open by default. Useful when used as an uncontrolled component. |
| - |
| Controls the open state directly in controlled mode. |
| - |
| Callback fired when the open state changes. |
DisclosureTrigger
Extends the button
element.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Whether to merge props onto the child element. |
DisclosureContent
Extends the div
element.
The content that will be shown or hidden. Uses Motion for smooth height transitions.
DisclosureChevron
Extends the span
element.
Will render a caret that rotates when the disclosure is open.
DisclosureGroup
If you want to only allow one disclosure to be open at a time, you can wrap your disclosures in a DisclosureGroup
.
Examples
Simple
Basic usage with default styling.
Exclusive
Wraps your disclosures in a DisclosureGroup
to ensure only one disclosure is open at a time.
With chevron
Using the built-in chevron component.
Styled
A more styled example showing how to build an Accordion.
Building an Accordion
component
The Disclosure
component serves as a low-level building block that enables progressive disclosure functionality in any UI element that requires it.
You can easily use it to build a more opinionated Accordion
component, like the example above.
import {
Disclosure,
DisclosureContent,
DisclosureTrigger,
DisclosureChevron,
DisclosureGroup,
} from "../disclosure";
const AccordionGroup = ({
children,
className,
}: React.ComponentProps<"div">) => {
return (
<DisclosureGroup>
<div className={cn("rounded-lg border", className)}>{children}</div>
</DisclosureGroup>
);
};
const Accordion = ({
children,
className,
}: React.ComponentProps<typeof Disclosure>) => {
return (
<Disclosure className={cn("border-b last:border-b-0", className)}>
{children}
</Disclosure>
);
};
const AccordionTrigger = ({
children,
className,
}: React.ComponentProps<typeof DisclosureTrigger>) => {
return (
<DisclosureTrigger
className={cn(
"hover:bg-foreground/5 flex cursor-pointer items-center justify-between gap-4 px-3 py-2 transition-colors",
className
)}
>
{children}
<DisclosureChevron />
</DisclosureTrigger>
);
};
const AccordionContent = ({
children,
className,
}: React.ComponentProps<typeof DisclosureContent>) => {
return (
<DisclosureContent>
<div className={cn("border-t px-3 py-2", className)}>{children}</div>
</DisclosureContent>
);
};
About the use of motion
The usage of Motion in this component is temporary as it's not worth it to develop a custom solution when calc-size is almost available.
As most projects we do already include Motion, using it here is pretty much inconsequential.
If you need a leaner approach, feel free to remove it.
Best Practices
-
Content Organization:
- Use clear, descriptive trigger labels
- Keep content concise and focused
- Consider the order of disclosures
-
Mobile Considerations:
- Ensure touch targets are large enough
- Test animations on lower-end devices
- Consider using DisclosureGroup on mobile
-
Performance:
- Lazy load content if needed
- Consider disabling animations on slower devices
- Clean up resources when unmounting