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.

PropDefaultTypeDescription

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.

PropDefaultTypeDescription

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.

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

  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