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
| Prop | Default | Description |
|---|---|---|
CollapsibleSectionrenders a <div> element | ||
title | - | Text of the header. |
defaultOpen | true | Whether 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. |
copyLabel | Copy <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. |
| Prop | Default | Description |
|---|---|---|
CollapsibleSectionrenders a <div> element | ||
title | - | Text of the header. |
defaultOpen | true | Whether 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. |
copyLabel | Copy <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:
| content-type | application/json |
| x-ratelimit-remaining | 98 |
<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:
- 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-type | application/json |
| x-request-id | req_01J9K2M4 |
| x-signature | v1=4f8a…c21e |
| content-type | application/json |
| x-ratelimit-remaining | 98 |
<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>