Skip to content
  1. Page structure
  2. Collapsible section

Collapsible section

A section that folds under its own header, with room for a copy button and other actions next to the title.

{
  "id": "pay_8f2a",
  "amount": 12500,
  "currency": "XOF",
  "status": "succeeded"
}
import { CollapsibleSection } from '@/components/collapsible-section'

export default function Example({ body }: { body: string }) {
  return (
    <CollapsibleSection title="Request body">
      <pre className="overflow-x-auto p-3 font-mono text-sm/6 text-zinc-950 dark:text-white">{body}</pre>
    </CollapsibleSection>
  )
}
<script setup lang="ts">
import CollapsibleSection from '@/components/collapsible-section.vue'

defineProps<{ body: string }>()
</script>

<template>
  <CollapsibleSection title="Request body">
    <pre class="overflow-x-auto p-3 font-mono text-sm/6 text-zinc-950 dark:text-white">{{ body }}</pre>
  </CollapsibleSection>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import { ChevronRightIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { useId, useState } from 'react'
import { CopyButton } from './copy-button'

export function CollapsibleSection({
  title,
  copy,
  copyLabel,
  defaultOpen = true,
  actions,
  className,
  children,
  ...props
}: {
  title: React.ReactNode
  copy?: string
  copyLabel?: string
  defaultOpen?: boolean
  actions?: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'title'>) {
  let [open, setOpen] = useState(defaultOpen)
  let panelId = useId()
  let label = copyLabel ?? (typeof title === 'string' ? `Copy ${title.toLowerCase()}` : 'Copy')

  return (
    <div
      {...props}
      data-open={open ? '' : undefined}
      className={clsx(className, 'overflow-hidden rounded-lg border border-zinc-950/10 dark:border-white/10')}
    >
      <div className="flex items-center gap-2 bg-zinc-50 dark:bg-white/2.5">
        <button
          type="button"
          aria-expanded={open}
          aria-controls={panelId}
          onClick={() => setOpen(!open)}
          className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pl-3 text-left select-none last:pr-3"
        >
          <ChevronRightIcon
            className={clsx('size-4 shrink-0 text-zinc-400 transition-transform dark:text-zinc-500', open && 'rotate-90')}
          />
          <span className="min-w-0 flex-1 truncate text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">
            {title}
          </span>
        </button>
        {actions || copy ? (
          <div className="flex items-center gap-2 pr-3">
            {actions}
            {copy ? <CopyButton text={copy} label={label} /> : null}
          </div>
        ) : null}
      </div>
      <div
        id={panelId}
        hidden={!open}
        className="border-t border-zinc-950/5 dark:border-white/5 dark:bg-black/20"
      >
        {children}
      </div>
    </div>
  )
}

Also copy Copy button.

<script setup lang="ts">
import { ChevronRightIcon } from '@heroicons/vue/16/solid'
import { ref, useId } from 'vue'
import CopyButton from './copy-button.vue'

const props = withDefaults(
  defineProps<{
    title: string
    copy?: string
    copyLabel?: string
    defaultOpen?: boolean
  }>(),
  { copy: undefined, copyLabel: undefined, defaultOpen: true }
)

const open = ref(props.defaultOpen)
const panelId = useId()
</script>

<template>
  <div
    :data-open="open ? '' : undefined"
    class="overflow-hidden rounded-lg border border-zinc-950/10 dark:border-white/10"
  >
    <div class="flex items-center gap-2 bg-zinc-50 dark:bg-white/2.5">
      <button
        type="button"
        :aria-expanded="open"
        :aria-controls="panelId"
        class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pl-3 text-left select-none last:pr-3"
        @click="open = !open"
      >
        <ChevronRightIcon
          :class="['size-4 shrink-0 text-zinc-400 transition-transform dark:text-zinc-500', open && 'rotate-90']"
        />
        <span class="min-w-0 flex-1 truncate text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">
          {{ title }}
        </span>
      </button>
      <div v-if="$slots.actions || copy" class="flex items-center gap-2 pr-3">
        <slot name="actions" />
        <CopyButton v-if="copy" :text="copy" :label="copyLabel ?? `Copy ${title.toLowerCase()}`" />
      </div>
    </div>
    <div
      :id="panelId"
      :hidden="!open"
      class="border-t border-zinc-950/5 dark:border-white/5 dark:bg-black/20"
    >
      <slot />
    </div>
  </div>
</template>

Also copy Copy button.

Component API

PropDefaultDescription
CollapsibleSectionrenders a <div> element
title-Text of the header.
defaultOpentrueWhether the section starts open. The section keeps its own state from there.
copy-Text placed on the clipboard by a copy button in the header.
copyLabelCopy <title>Accessible label of that button.
actions-Extra controls at the end of the header, next to the toggle: a count, a badge, a link. They sit outside the toggle button, so they never fold the section.
PropDefaultDescription
CollapsibleSectionrenders a <div> element
title-Text of the header.
defaultOpentrueWhether the section starts open. The section keeps its own state from there.
copy-Text placed on the clipboard by a copy button in the header.
copyLabelCopy <title>Accessible label of that button.
#actions-Extra controls at the end of the header, next to the toggle: a count, a badge, a link. They sit outside the toggle button, so they never fold the section.

Examples

Basic example

Give it a title and a body. The body has no padding of its own: a table sits flush against the edges, a block of code brings its p-3:

{
  "id": "pay_8f2a",
  "amount": 12500,
  "currency": "XOF",
  "status": "succeeded"
}
import { CollapsibleSection } from '@/components/collapsible-section'

export default function Example({ body }: { body: string }) {
  return (
    <CollapsibleSection title="Request body">
      <pre className="overflow-x-auto p-3 font-mono text-sm/6 text-zinc-950 dark:text-white">{body}</pre>
    </CollapsibleSection>
  )
}
<script setup lang="ts">
import CollapsibleSection from '@/components/collapsible-section.vue'

defineProps<{ body: string }>()
</script>

<template>
  <CollapsibleSection title="Request body">
    <pre class="overflow-x-auto p-3 font-mono text-sm/6 text-zinc-950 dark:text-white">{{ body }}</pre>
  </CollapsibleSection>
</template>

Closed by default

Start a secondary section folded with defaultOpen={false}. Once rendered, the section keeps its own state:

<CollapsibleSection title="Response headers" defaultOpen={false}>
  <HeadersTable rows={responseHeaders} />
</CollapsibleSection>
<CollapsibleSection title="Response headers" :default-open="false">
  <HeadersTable :rows="responseHeaders" />
</CollapsibleSection>

With a copy button

Pass the text to copy and a copy button appears in the header, labeled after the title. Clicking it does not fold the section:

{
  "id": "pay_8f2a",
  "amount": 12500,
  "currency": "XOF",
  "status": "succeeded"
}
<CollapsibleSection title="Request body" copy={body}>
  <pre className="overflow-x-auto p-3 font-mono text-sm/6 text-zinc-950 dark:text-white">{body}</pre>
</CollapsibleSection>
<CollapsibleSection title="Request body" :copy="body">
  <pre class="overflow-x-auto p-3 font-mono text-sm/6 text-zinc-950 dark:text-white">{{ body }}</pre>
</CollapsibleSection>

With actions

Anything else that belongs in the header goes in actions: a count, a badge, a link. Like the copy button, it sits outside the toggle, so a click there never folds the section:

4/12Sandbox only
  • payment.created
  • payment.processing
  • payment.succeeded
  • payment.failed
<CollapsibleSection
  title="Payment events"
  actions={
    <>
      <span className="text-sm/6 text-zinc-500 tabular-nums sm:text-xs/6 dark:text-zinc-400">4/12</span>
      <Badge color="amber">Sandbox only</Badge>
    </>
  }
>
  <ul className="divide-y divide-zinc-950/5 font-mono text-sm/6 dark:divide-white/5">
    {events.map((event) => (
      <li key={event} className="px-3 py-1.5">{event}</li>
    ))}
  </ul>
</CollapsibleSection>
<CollapsibleSection title="Payment events">
  <template #actions>
    <span class="text-sm/6 text-zinc-500 tabular-nums sm:text-xs/6 dark:text-zinc-400">4/12</span>
    <Badge color="amber">Sandbox only</Badge>
  </template>
  <ul class="divide-y divide-zinc-950/5 font-mono text-sm/6 dark:divide-white/5">
    <li v-for="event in events" :key="event" class="px-3 py-1.5">{{ event }}</li>
  </ul>
</CollapsibleSection>

Stacked

Sections carry no outer margin. Stack them in a flex column with gap-3:

content-typeapplication/json
x-request-idreq_01J9K2M4
x-signaturev1=4f8a…c21e
content-typeapplication/json
x-ratelimit-remaining98
<div className="flex flex-col gap-3">
  <CollapsibleSection title="Request headers">
    <HeadersTable rows={requestHeaders} />
  </CollapsibleSection>
  <CollapsibleSection title="Response headers">
    <HeadersTable rows={responseHeaders} />
  </CollapsibleSection>
</div>
<div class="flex flex-col gap-3">
  <CollapsibleSection title="Request headers">
    <HeadersTable :rows="requestHeaders" />
  </CollapsibleSection>
  <CollapsibleSection title="Response headers">
    <HeadersTable :rows="responseHeaders" />
  </CollapsibleSection>
</div>