Disclosure
A mostly unstyled vertical set of interactive elements that each reveal a section of content when clicked
import {
Disclosure,
DisclosureContent,
DisclosureTrigger,
} from '@/components/disclosure';
export default function DisclosurePreview() {
return (
<div className="w-90 text-sm">
<Disclosure>
<DisclosureTrigger>
Did you know that octopuses have three hearts?
</DisclosureTrigger>
<DisclosureContent className="text-foreground-secondary">
Two hearts pump blood to the gills, while the third one circulates it
to the rest of the body. When they swim, their third heart actually
stops beating - which is why they tend to crawl more than swim!
</DisclosureContent>
</Disclosure>
<Disclosure>
<DisclosureTrigger>
Want to hear about immortal jellyfish?
</DisclosureTrigger>
<DisclosureContent className="text-foreground-secondary">
The Turritopsis dohrnii jellyfish can technically live forever! When
stressed, it can transform back into a juvenile form by turning its
existing cells into different cell types. It's like having a
reset button for aging!
</DisclosureContent>
</Disclosure>
</div>
);
} Dependencies
Source Code
'use client';
import { CaretDownIcon } from '@phosphor-icons/react';
import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react';
import { createContext, use, useId, useState } from 'react';
import { Slot } from '@/components/slot';
import { cn } from '@/lib/utils/classnames';
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(
'flex w-full items-center justify-between text-left outline-none ring-ring 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(
'p-1 transition-transform duration-100 ease-out-cubic',
open && 'rotate-180',
className
)}
{...props}
>
<CaretDownIcon />
</span>
);
};
export {
Disclosure,
DisclosureChevron,
DisclosureContent,
DisclosureGroup,
DisclosureTrigger,
}; 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 |
|---|---|---|---|
defaultOpen | - | boolean | Whether the element is open by default. Useful when used as an uncontrolled component. |
open | - | boolean | Controls the open state directly in controlled mode. |
onOpenChange | - | (open: boolean) => void | Callback fired when the open state changes. |
DisclosureTrigger
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | 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.
import {
Disclosure,
DisclosureContent,
DisclosureTrigger,
} from '@/components/disclosure';
export default function DisclosurePreview() {
return (
<div className="w-90 text-sm">
<Disclosure>
<DisclosureTrigger>
Did you know that octopuses have three hearts?
</DisclosureTrigger>
<DisclosureContent className="text-foreground-secondary">
Two hearts pump blood to the gills, while the third one circulates it
to the rest of the body. When they swim, their third heart actually
stops beating - which is why they tend to crawl more than swim!
</DisclosureContent>
</Disclosure>
<Disclosure>
<DisclosureTrigger>
Want to hear about immortal jellyfish?
</DisclosureTrigger>
<DisclosureContent className="text-foreground-secondary">
The Turritopsis dohrnii jellyfish can technically live forever! When
stressed, it can transform back into a juvenile form by turning its
existing cells into different cell types. It's like having a
reset button for aging!
</DisclosureContent>
</Disclosure>
</div>
);
} Exclusive
Wraps your disclosures in a DisclosureGroup to ensure only one disclosure is open at a time.
import {
Disclosure,
DisclosureContent,
DisclosureGroup,
DisclosureTrigger,
} from '@/components/disclosure';
export default function DisclosurePreview() {
return (
<div className="w-90 text-sm">
<DisclosureGroup>
<Disclosure>
<DisclosureTrigger>
Did you know that honey never spoils?
</DisclosureTrigger>
<DisclosureContent className="text-foreground-secondary">
Archaeologists have found pots of honey in ancient Egyptian tombs
that are over 3,000 years old and still perfectly edible! The unique
chemical composition and low moisture content make it impossible for
bacteria to grow in honey.
</DisclosureContent>
</Disclosure>
<Disclosure>
<DisclosureTrigger>
Want to learn about hummingbird metabolism?
</DisclosureTrigger>
<DisclosureContent className="text-foreground-secondary">
A hummingbird's heart beats up to 1,260 times per minute during
flight! They have such a fast metabolism that they need to eat every
10-15 minutes and visit up to 2,000 flowers per day. At night, they
enter a state called torpor where their metabolism slows down by 95%
to survive.
</DisclosureContent>
</Disclosure>
</DisclosureGroup>
</div>
);
} With chevron
Using the built-in chevron component.
import {
Disclosure,
DisclosureChevron,
DisclosureContent,
DisclosureTrigger,
} from '@/components/disclosure';
export default function DisclosureChevronPreview() {
return (
<div className="w-90 text-sm">
<Disclosure>
<DisclosureTrigger>
Sloths can hold their breath for 40 minutes?
<DisclosureChevron />
</DisclosureTrigger>
<DisclosureContent className="text-foreground-secondary">
While most mammals can only hold their breath for a few minutes,
sloths can slow their heart rates to one-third of its normal rate,
allowing them to stay underwater for up to 40 minutes! This helps them
escape predators and swim between islands.
</DisclosureContent>
</Disclosure>
<Disclosure>
<DisclosureTrigger>
Want to learn about platypus superpowers?
<DisclosureChevron />
</DisclosureTrigger>
<DisclosureContent className="text-foreground-secondary">
Platypuses have electroreceptors in their bills that detect electrical
signals from prey! They can sense the electrical fields produced by
the muscular contractions of small aquatic animals. They're also
one of the few mammals that produce venom.
</DisclosureContent>
</Disclosure>
</div>
);
} Styled
A more styled example showing how to build an Accordion.
import {
Disclosure,
DisclosureChevron,
DisclosureContent,
DisclosureGroup,
DisclosureTrigger,
} from '@/components/disclosure';
import { cn } from '@/lib/utils/classnames';
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(
'flex cursor-pointer items-center justify-between gap-4 px-3 py-2 transition-colors hover:bg-foreground/5',
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>
);
};
export default function DisclosureStyledPreview() {
return (
<AccordionGroup>
<Accordion>
<AccordionTrigger>
Did you know that butterflies taste with their feet?
</AccordionTrigger>
<AccordionContent>
Butterflies have taste receptors on their feet that help them identify
which plants to lay their eggs on. When they land on a plant, they can
taste it to determine if it's suitable food for their
caterpillars.
</AccordionContent>
</Accordion>
<Accordion>
<AccordionTrigger>
Want to learn about tardigrade superpowers?
</AccordionTrigger>
<AccordionContent>
Tardigrades, also known as water bears, can survive in space! They can
withstand extreme temperatures, pressure, radiation, and can even
survive being completely dehydrated for years by entering a state of
cryptobiosis.
</AccordionContent>
</Accordion>
<Accordion>
<AccordionTrigger>
Have you heard about the immortal jellyfish?
</AccordionTrigger>
<AccordionContent>
The Turritopsis dohrnii jellyfish can technically live forever! When
stressed or injured, it can transform back into its juvenile stage
instead of dying, making it the only known animal capable of
biological immortality.
</AccordionContent>
</Accordion>
</AccordionGroup>
);
} 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
Previous
Dialog
Next
Divider