Collapsible
A panel that shows and hides with height motion you can reverse mid-way, without jumping the layout.
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/collapsible.jsonInstall manually
Install the dependencies:
npm install @base-ui/react @tabler/icons-react cnCopy this into
components/ui/collapsible.tsx:collapsible.tsx"use client" import * as React from "react" import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconChevronDown } from "@tabler/icons-react" import { cn } from "cn" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) { return (node: T) => { const cleanups = refs.map((ref) => { if (typeof ref === "function") { const cleanup = ref(node) return typeof cleanup === "function" ? cleanup : () => ref(null) } if (ref) { ref.current = node return () => { ref.current = null } } return undefined }) return () => { for (const cleanup of cleanups) { cleanup?.() } } } } function getLayoutItem(panel: HTMLElement) { let item: HTMLElement = panel let parent = panel.parentElement while (parent && getComputedStyle(parent).display === "contents") { item = parent parent = parent.parentElement } return { item, parent } } function getCollapsedGap(panel: HTMLElement) { const { item, parent } = getLayoutItem(panel) if (!parent) { return { start: 0, end: 0 } } const style = getComputedStyle(parent) const isFlexColumn = style.display.endsWith("flex") && style.flexDirection.startsWith("column") const isSingleColumnGrid = style.display.endsWith("grid") && style.gridTemplateColumns.trim().split(/\s+/).length === 1 const gap = isFlexColumn || isSingleColumnGrid ? parseFloat(style.rowGap) || 0 : 0 if (!gap) { return { start: 0, end: 0 } } const reversed = isFlexColumn && style.flexDirection === "column-reverse" if (item.previousElementSibling) { return reversed ? { start: 0, end: gap } : { start: gap, end: 0 } } if (item.nextElementSibling) { return reversed ? { start: gap, end: 0 } : { start: 0, end: gap } } return { start: 0, end: 0 } } function syncCollapsedGap(panel: HTMLElement) { const { start, end } = getCollapsedGap(panel) const { style } = panel if ( style.getPropertyValue("--collapsible-gap-start") === `${start}px` && style.getPropertyValue("--collapsible-gap-end") === `${end}px` ) { return } const transition = style.getPropertyValue("transition") const priority = style.getPropertyPriority("transition") style.setProperty("transition", "none", "important") style.setProperty("--collapsible-gap-start", `${start}px`) style.setProperty("--collapsible-gap-end", `${end}px`) void getComputedStyle(panel).margin if (transition) { style.setProperty("transition", transition, priority) } else { style.removeProperty("transition") } } function observeSettled(panel: HTMLDivElement | null) { if (!panel) { return } const sync = () => { const open = panel.hasAttribute("data-open") && !panel.hasAttribute("data-starting-style") && !panel.hasAttribute("data-ending-style") const animating = typeof panel.getAnimations === "function" && panel .getAnimations() .some( (animation) => "transitionProperty" in animation && animation.transitionProperty === "height" ) panel.toggleAttribute("data-settled", open && !animating) } sync() const observer = new MutationObserver(sync) observer.observe(panel, { attributes: true, attributeFilter: [ "data-open", "data-starting-style", "data-ending-style", "style", ], }) panel.addEventListener("transitionend", sync) panel.addEventListener("transitioncancel", sync) return () => { observer.disconnect() panel.removeEventListener("transitionend", sync) panel.removeEventListener("transitioncancel", sync) } } function Collapsible(props: CollapsiblePrimitive.Root.Props) { return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> } function CollapsibleTrigger({ className, ...props }: CollapsiblePrimitive.Trigger.Props) { return ( <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" className={mergeClassName( "group/collapsible-trigger [-webkit-tap-highlight-color:transparent]", className )} {...props} /> ) } function CollapsibleTriggerIcon({ className, children, render, ...props }: useRender.ComponentProps<"span">) { return useRender({ defaultTagName: "span", render, props: mergeProps<"span">( { "aria-hidden": true, className: cn( "inline-flex size-4 shrink-0 items-center justify-center transition-[translate] duration-150 group-active/collapsible-trigger:translate-y-px motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", className ), children: children ?? ( <IconChevronDown className="transition-[rotate] duration-200 ease-spring group-data-panel-open/collapsible-trigger:rotate-180 motion-reduce:transition-none" /> ), }, props, { "data-slot": "collapsible-trigger-icon" } as Record<string, string> ), }) } function CollapsibleContent({ className, children, hiddenUntilFound = true, ref, ...props }: Omit<CollapsiblePrimitive.Panel.Props, "className"> & { className?: string }) { const observeGap = React.useCallback((panel: HTMLDivElement | null) => { const parent = panel ? getLayoutItem(panel).parent : null if (!panel || !parent) { return } syncCollapsedGap(panel) const sync = () => syncCollapsedGap(panel) const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync) const mutationObserver = new MutationObserver(sync) resizeObserver?.observe(parent) mutationObserver.observe(parent, { childList: true }) return () => { resizeObserver?.disconnect() mutationObserver.disconnect() } }, []) const setRef = React.useMemo( () => mergeRefs(observeGap, observeSettled, ref), [observeGap, ref] ) return ( <CollapsiblePrimitive.Panel data-slot="collapsible-content" ref={setRef} hiddenUntilFound={hiddenUntilFound} className="group/collapsible-content h-(--collapsible-panel-height) overflow-clip transition-[height,margin] duration-300 ease-spring data-ending-style:h-0 data-ending-style:duration-150 data-settled:overflow-visible data-starting-style:-mt-(--collapsible-gap-start) data-starting-style:-mb-(--collapsible-gap-end) data-starting-style:h-0 motion-reduce:transition-none data-closed:-mt-(--collapsible-gap-start) data-closed:-mb-(--collapsible-gap-end)" {...props} > <div data-slot="collapsible-content-clip" className="overflow-clip group-data-settled/collapsible-content:overflow-visible" > <div data-slot="collapsible-content-inner" className={cn( "transition-[opacity,translate] duration-200 ease-out-quint group-data-ending-style/collapsible-content:-translate-y-1 group-data-ending-style/collapsible-content:opacity-0 group-data-ending-style/collapsible-content:duration-150 group-data-starting-style/collapsible-content:-translate-y-1 group-data-starting-style/collapsible-content:opacity-0 motion-reduce:transition-none", className )} > {children} </div> </div> </CollapsiblePrimitive.Panel> ) } export { Collapsible, CollapsibleTrigger, CollapsibleTriggerIcon, CollapsibleContent, }
Examples
Inside a card
Controlled
Custom icon
Disabled
File tree
Long content
Open by default
Right to left
Show more
Unmounted when closed
Feedback
Questions, bugs or ideas for Collapsible? Post them here, or reply to someone else. Sign in with GitHub to comment.