motion-primitives
components
ui
animation
motion

disclosure

The Disclosure component allows users to toggle the visibility of content, either collapsed or expanded.

animated
button
effect
motion
text
transition
View Docs

Source Code

Files
disclosure.tsx
1'use client';
2import * as React from 'react';
3import {
4  AnimatePresence,
5  motion,
6  MotionConfig,
7  Transition,
8  Variant,
9  Variants,
10} from 'motion/react';
11import { createContext, useContext, useState, useId, useEffect } from 'react';
12import { cn } from '@/lib/utils';
13
14export type DisclosureContextType = {
15  open: boolean;
16  toggle: () => void;
17  variants?: { expanded: Variant; collapsed: Variant };
18};
19
20const DisclosureContext = createContext<DisclosureContextType | undefined>(
21  undefined
22);
23
24export type DisclosureProviderProps = {
25  children: React.ReactNode;
26  open: boolean;
27  onOpenChange?: (open: boolean) => void;
28  variants?: { expanded: Variant; collapsed: Variant };
29};
30
31function DisclosureProvider({
32  children,
33  open: openProp,
34  onOpenChange,
35  variants,
36}: DisclosureProviderProps) {
37  const [internalOpenValue, setInternalOpenValue] = useState<boolean>(openProp);
38
39  useEffect(() => {
40    setInternalOpenValue(openProp);
41  }, [openProp]);
42
43  const toggle = () => {
44    const newOpen = !internalOpenValue;
45    setInternalOpenValue(newOpen);
46    if (onOpenChange) {
47      onOpenChange(newOpen);
48    }
49  };
50
51  return (
52    <DisclosureContext.Provider
53      value={{
54        open: internalOpenValue,
55        toggle,
56        variants,
57      }}
58    >
59      {children}
60    </DisclosureContext.Provider>
61  );
62}
63
64function useDisclosure() {
65  const context = useContext(DisclosureContext);
66  if (!context) {
67    throw new Error('useDisclosure must be used within a DisclosureProvider');
68  }
69  return context;
70}
71
72export type DisclosureProps = {
73  open?: boolean;
74  onOpenChange?: (open: boolean) => void;
75  children: React.ReactNode;
76  className?: string;
77  variants?: { expanded: Variant; collapsed: Variant };
78  transition?: Transition;
79};
80
81export function Disclosure({
82  open: openProp = false,
83  onOpenChange,
84  children,
85  className,
86  transition,
87  variants,
88}: DisclosureProps) {
89  return (
90    <MotionConfig transition={transition}>
91      <div className={className}>
92        <DisclosureProvider
93          open={openProp}
94          onOpenChange={onOpenChange}
95          variants={variants}
96        >
97          {React.Children.toArray(children)[0]}
98          {React.Children.toArray(children)[1]}
99        </DisclosureProvider>
100      </div>
101    </MotionConfig>
102  );
103}
104
105export function DisclosureTrigger({
106  children,
107  className,
108}: {
109  children: React.ReactNode;
110  className?: string;
111}) {
112  const { toggle, open } = useDisclosure();
113
114  return (
115    <>
116      {React.Children.map(children, (child) => {
117        return React.isValidElement(child)
118          ? React.cloneElement(child, {
119              onClick: toggle,
120              role: 'button',
121              'aria-expanded': open,
122              tabIndex: 0,
123              onKeyDown: (e: { key: string; preventDefault: () => void }) => {
124                if (e.key === 'Enter' || e.key === ' ') {
125                  e.preventDefault();
126                  toggle();
127                }
128              },
129              className: cn(
130                className,
131                (child as React.ReactElement).props.className
132              ),
133              ...(child as React.ReactElement).props,
134            })
135          : child;
136      })}
137    </>
138  );
139}
140
141export function DisclosureContent({
142  children,
143  className,
144}: {
145  children: React.ReactNode;
146  className?: string;
147}) {
148  const { open, variants } = useDisclosure();
149  const uniqueId = useId();
150
151  const BASE_VARIANTS: Variants = {
152    expanded: {
153      height: 'auto',
154      opacity: 1,
155    },
156    collapsed: {
157      height: 0,
158      opacity: 0,
159    },
160  };
161
162  const combinedVariants = {
163    expanded: { ...BASE_VARIANTS.expanded, ...variants?.expanded },
164    collapsed: { ...BASE_VARIANTS.collapsed, ...variants?.collapsed },
165  };
166
167  return (
168    <div className={cn('overflow-hidden', className)}>
169      <AnimatePresence initial={false}>
170        {open && (
171          <motion.div
172            id={uniqueId}
173            initial='collapsed'
174            animate='expanded'
175            exit='collapsed'
176            variants={combinedVariants}
177          >
178            {children}
179          </motion.div>
180        )}
181      </AnimatePresence>
182    </div>
183  );
184}
185
186export default {
187  Disclosure,
188  DisclosureProvider,
189  DisclosureTrigger,
190  DisclosureContent,
191};
192