Headless Disclosure

PreviousNext

A simple, accessible foundation for building custom UIs that show and hide content, like togglable accordion panels.

Docs
animate-uiui

Preview

Loading preview…
registry/primitives/headless/disclosure/index.tsx
'use client';

import * as React from 'react';
import {
  Disclosure as DisclosurePrimitive,
  DisclosureButton as DisclosureButtonPrimitive,
  DisclosurePanel as DisclosurePanelPrimitive,
  type DisclosureProps as DisclosurePrimitiveProps,
  type DisclosureButtonProps as DisclosureButtonPrimitiveProps,
  type DisclosurePanelProps as DisclosurePanelPrimitiveProps,
} from '@headlessui/react';
import { motion, AnimatePresence, type HTMLMotionProps } from 'motion/react';

import { getStrictContext } from '@/lib/get-strict-context';

type DisclosureContextType = {
  isOpen: boolean;
};

const [DisclosureProvider, useDisclosure] =
  getStrictContext<DisclosureContextType>('DisclosureContext');

type DisclosureProps<TTag extends React.ElementType = 'div'> =
  DisclosurePrimitiveProps<TTag> & {
    as?: TTag;
    className?: string;
  };

function Disclosure<TTag extends React.ElementType = 'div'>({
  children,
  ...props
}: DisclosureProps<TTag>) {
  return (
    <DisclosurePrimitive data-slot="disclosure" {...props}>
      {(bag) => (
        <DisclosureProvider value={{ isOpen: bag.open }}>
          {typeof children === 'function' ? children(bag) : children}
        </DisclosureProvider>
      )}
    </DisclosurePrimitive>
  );
}

type DisclosureButtonProps<TTag extends React.ElementType = 'button'> =
  DisclosureButtonPrimitiveProps<TTag> & {
    as?: TTag;
  };

function DisclosureButton<TTag extends React.ElementType = 'button'>(
  props: DisclosureButtonProps<TTag>,
) {
  return <DisclosureButtonPrimitive data-slot="disclosure-button" {...props} />;
}

type DisclosurePanelProps<TTag extends React.ElementType = typeof motion.div> =
  Pick<DisclosurePanelPrimitiveProps<TTag>, 'static' | 'unmount' | 'children'> &
    Omit<HTMLMotionProps<'div'>, 'children'> & {
      as?: TTag;
      keepRendered?: boolean;
    };

function DisclosurePanel<TTag extends React.ElementType = typeof motion.div>(
  props: DisclosurePanelProps<TTag>,
) {
  const {
    children,
    transition = { duration: 0.35, ease: 'easeInOut' },
    as = motion.div,
    unmount,
    keepRendered = false,
    ...rest
  } = props;
  const { isOpen } = useDisclosure();

  return (
    <AnimatePresence>
      {keepRendered ? (
        <DisclosurePanelPrimitive
          static
          as={as as React.ElementType}
          unmount={unmount}
        >
          {(bag) => (
            <motion.div
              key="disclosure-panel"
              data-slot="disclosure-panel"
              initial={{ height: 0, opacity: 0, '--mask-stop': '0%', y: 20 }}
              animate={
                isOpen
                  ? { height: 'auto', opacity: 1, '--mask-stop': '100%', y: 0 }
                  : { height: 0, opacity: 0, '--mask-stop': '0%', y: 20 }
              }
              transition={transition}
              style={{
                maskImage:
                  'linear-gradient(black var(--mask-stop), transparent var(--mask-stop))',
                WebkitMaskImage:
                  'linear-gradient(black var(--mask-stop), transparent var(--mask-stop))',
                overflow: 'hidden',
              }}
              {...rest}
            >
              {typeof children === 'function' ? children(bag) : children}
            </motion.div>
          )}
        </DisclosurePanelPrimitive>
      ) : (
        isOpen && (
          <DisclosurePanelPrimitive
            static
            as={as as React.ElementType}
            unmount={unmount}
          >
            {(bag) => (
              <motion.div
                key="disclosure-panel"
                data-slot="disclosure-panel"
                initial={{ height: 0, opacity: 0, '--mask-stop': '0%', y: 20 }}
                animate={{
                  height: 'auto',
                  opacity: 1,
                  '--mask-stop': '100%',
                  y: 0,
                }}
                exit={{ height: 0, opacity: 0, '--mask-stop': '0%', y: 20 }}
                transition={transition}
                style={{
                  maskImage:
                    'linear-gradient(black var(--mask-stop), transparent var(--mask-stop))',
                  WebkitMaskImage:
                    'linear-gradient(black var(--mask-stop), transparent var(--mask-stop))',
                  overflow: 'hidden',
                }}
                {...rest}
              >
                {typeof children === 'function' ? children(bag) : children}
              </motion.div>
            )}
          </DisclosurePanelPrimitive>
        )
      )}
    </AnimatePresence>
  );
}

export {
  Disclosure,
  DisclosureButton,
  DisclosurePanel,
  useDisclosure,
  type DisclosureProps,
  type DisclosureButtonProps,
  type DisclosurePanelProps,
};

Installation

npx shadcn@latest add @animate-ui/primitives-headless-disclosure

Usage

import { PrimitivesHeadlessDisclosure } from "@/components/ui/primitives-headless-disclosure"
<PrimitivesHeadlessDisclosure />