New: Activity Terrain +12 more

Collapsible

A panel that shows and hides with height motion you can reverse mid-way, without jumping the layout.

11 examplesDepends on@base-ui/react@tabler/icons-reactcnFrom HextaUI (MIT)

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.json
Install manually
  1. Install the dependencies:

    npm install @base-ui/react @tabler/icons-react cn
  2. Copy 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.