Octocat

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&apos;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&apos;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&apos;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&apos;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&apos;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

  1. Content Organization:

    • Use clear, descriptive trigger labels
    • Keep content concise and focused
    • Consider the order of disclosures
  2. Mobile Considerations:

    • Ensure touch targets are large enough
    • Test animations on lower-end devices
    • Consider using DisclosureGroup on mobile
  3. Performance:

    • Lazy load content if needed
    • Consider disabling animations on slower devices
    • Clean up resources when unmounting

Previous

Dialog

Next

Divider