Collapsible
A single expand/collapse panel with a default or custom trigger, the low-level primitive behind Accordion.
Playground
Design tokens are named values — colors, spacing, radii — that form the single source of truth for your design system. They bridge the gap between design tools and code.
<Collapsible>
<p class="text-text-secondary text-sm">
Design tokens are named values — colors, spacing, radii — that form the single source of
truth for your design system. They bridge the gap between design tools and code.
</p>
</Collapsible>01 Examples
A Collapsible is a single panel: title sets its trigger
text and the default slot is the content it reveals. Leave it uncontrolled with defaultOpen, or drive it from your own state with bind:open. For a set of panels where only one stays open
at a time, reach for Accordion instead.
FAQ item
Named values (colors, spacing, radii) that form the single source of truth for your design system. They bridge the gap between design tools and code.
No. Every component ships with sensible defaults. Tailwind helps when you want to
override styles via slotClasses, but it's optional.
Yes. The components are SSR-safe and hydrate without layout shift. See the SvelteKit adapter docs for setup details.
<div class="flex w-full max-w-lg flex-col gap-3">
<Collapsible variant="card" title="What are design tokens?" defaultOpen>
<p class="text-text-secondary text-sm leading-relaxed">
Named values (colors, spacing, radii) that form the single source of truth for your
design system. They bridge the gap between design tools and code.
</p>
</Collapsible>
<Collapsible variant="card" title="Do I need to learn Tailwind?">
<p class="text-text-secondary text-sm leading-relaxed">
No. Every component ships with sensible defaults. Tailwind helps when you want to
override styles via <code class="bg-surface-base rounded px-1.5 py-0.5 text-xs"
>slotClasses</code
>, but it's optional.
</p>
</Collapsible>
<Collapsible variant="card" title="Can I use this with SvelteKit?">
<p class="text-text-secondary text-sm leading-relaxed">
Yes. The components are SSR-safe and hydrate without layout shift. See the SvelteKit
adapter docs for setup details.
</p>
</Collapsible>
</div>Controlled section toggle
This panel is controlled via bind:open. Toggle it with the button above or by clicking the trigger.
<div class="flex w-full max-w-lg flex-col gap-4">
<div class="flex items-center gap-3">
<Button size="sm" variant="outlined" onclick={() => (controlledOpen = !controlledOpen)}>
{controlledOpen ? 'Hide filters' : 'Show filters'}
</Button>
<Badge size="xs" intent={controlledOpen ? 'success' : 'neutral'} variant="soft">
{controlledOpen ? 'open' : 'closed'}
</Badge>
</div>
<Collapsible variant="card" bind:open={controlledOpen} title="Advanced filters">
<p class="text-text-secondary text-sm">
This panel is controlled via <code class="bg-surface-base rounded px-1.5 py-0.5 text-xs"
>bind:open</code
>. Toggle it with the button above or by clicking the trigger.
</p>
</Collapsible>
</div>Release-notes item with custom trigger
Features: New Collapsible component, improved Accordion internals, Stepper navigation.
Fixes: Dialog focus trap on Safari, Tooltip positioning near edges.
<div class="w-full max-w-lg">
<Collapsible variant="card" defaultOpen>
{#snippet trigger({
open,
toggle,
triggerId,
contentId
}: {
open: boolean;
toggle: () => void;
triggerId: string;
contentId: string;
})}
<!-- The custom trigger fills the card's own top edge, so its hover
fill has to carry the frame's radius — otherwise it squares off
the rounded corners it sits in (invisible at the 2px default,
obvious in a theme that rounds containers). Open: only the top
two, the body continues the fill below. -->
<button
id={triggerId}
type="button"
onclick={toggle}
aria-expanded={open}
aria-controls={contentId}
class="hover:bg-surface-hover rounded-t-contain focus-visible:ring-primary/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset"
>
<div
class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg"
>
<ClipboardListIcon size={16} />
</div>
<div class="flex-1">
<p class="text-text-primary text-sm font-semibold">Release Notes v3.2</p>
<p class="text-text-tertiary text-xs">3 new features, 2 bug fixes</p>
</div>
<Badge size="xs" intent={open ? 'primary' : 'neutral'} variant="soft">
{open ? 'Expanded' : 'Collapsed'}
</Badge>
</button>
{/snippet}
<div class="space-y-2 px-4 pb-4">
<p class="text-text-secondary text-sm">
<strong>Features:</strong> New Collapsible component, improved Accordion internals, Stepper
navigation.
</p>
<p class="text-text-secondary text-sm">
<strong>Fixes:</strong> Dialog focus trap on Safari, Tooltip positioning near edges.
</p>
</div>
</Collapsible>
</div>02 Without the layout
Collapsible wraps trigger and content in one element. When the revealed content has to be a
sibling instead — the next row of the same grid, a panel in another column — useDisclosure gives you the same wiring on its own: the open
state, one toggle, and the two attribute records to spread. Everything else, including the layout
and the animation, is yours.
Detail row on the same grid
Three stages, two of them cacheable. Because the detail is its own row, the trigger cell keeps its size and the table columns stay aligned down the whole list.
<script lang="ts">
import { ChevronDownIcon, useDisclosure } from '@urbicon-ui/blocks';
let open = $state(false);
const propsId = $props.id();
const detail = useDisclosure(() => ({
open,
triggerId: `entry-${propsId}-trigger`,
contentId: `entry-${propsId}-detail`,
onOpenChange: (next) => (open = next)
}));
</script>
<div class="border-border-subtle grid w-full max-w-md grid-cols-[1fr_auto] gap-x-3 border-b py-2">
<span class="text-text-primary self-center text-sm">Rebuild the deployment pipeline</span>
<button
{...detail.triggerProps}
type="button"
onclick={detail.toggle}
class="text-text-tertiary hover:text-text-primary focus-visible:ring-primary/50 rounded-sm p-1 focus-visible:ring-2 focus-visible:outline-none"
>
<ChevronDownIcon size={16} class={detail.open ? 'rotate-180' : ''} />
<span class="sr-only">{detail.open ? 'Hide' : 'Show'} details</span>
</button>
<!-- The revealed region is a SIBLING row spanning both columns, not a child
of the trigger's cell. It stays mounted so the height can animate, which
is what makes contentProps.inert load-bearing. -->
<div
{...detail.contentProps}
class="col-span-2 grid overflow-hidden transition-[grid-template-rows] duration-[var(--blocks-collapse-duration)]"
style:grid-template-rows={detail.open ? '1fr' : '0fr'}
>
<p class="text-text-secondary overflow-hidden text-sm">
Three stages, two of them cacheable. Because the detail is its own row, the trigger cell keeps
its size and the table columns stay aligned down the whole list.
</p>
</div>
</div>
03 Customization
Frosted glass
class tints the panel and slotClasses recolours the trigger, chevron and content. It keeps the card radius tier, padding and the expand animation. Only the fill, border, blur and text change, in raw colours because glass has no token equivalent.<div class="w-full max-w-sm">
<Collapsible
variant="card"
defaultOpen
title="Frosted Glass"
class="border-white/20 bg-white/10 shadow-[var(--blocks-shadow-lg)] backdrop-blur-xl"
slotClasses={{
trigger: 'text-white hover:text-white',
chevron: 'text-white/60',
contentInner: 'text-sm text-white/80'
}}
>
Frosted surfaces read best over a photograph or gradient, where the blur lifts the panel
off the busy background behind it.
</Collapsible>
</div>This is one of five ways to restyle a block. See Customization for class, slotClasses, unstyled, preset and provider-level overrides.
04 Accessibility
Built-in ARIA
The default trigger uses aria-expanded and aria-controls to link to the content panel. The content panel has role="region" with aria-labelledby pointing back to the trigger. The data-state attribute exposes open / closed for CSS-only styling.
Keyboard Navigation
Tab moves focus to the trigger. Enter / Space toggle the content. Focus rings use focus-visible: so they only appear on keyboard navigation.
Custom Triggers
When using the trigger snippet, the component passes triggerId and contentId so you can wire up aria-expanded and aria-controls yourself. The content region always
gets the correct aria-labelledby.
Reduced Motion
The expand/collapse is tied to the --blocks-collapse-duration token. Under prefers-reduced-motion that token collapses
to 1 ms, so the panel opens and closes without a visible slide.
05 API Reference
18 propsProp | Type | Default | Description | |
|---|---|---|---|---|
children required | Snippet | — | Collapsible content | |
class | string | — | Custom CSS class | |
defaultOpen | boolean | false | Initial open state for uncontrolled usage | |
disabled | boolean | false | Disable the trigger | |
name | string | — | Base name for generating ARIA IDs. Defaults to auto-generated. | |
onOpenChange | (open: boolean) => void | — | Callback fired once per trigger-driven open transition, after the state is
applied. Not fired for consumer writes via bind:open. | |
open | boolean | — | Whether the content is visible. Supports bind:open. Trigger-driven transitions
are applied optimistically: open is updated first, then onOpenChange reports
the change. When passing open without bind:, mirror every onOpenChange
back into your state — an ignored change leaves the component and your source
of truth diverged. To conditionally reject transitions, drive open from your
own state and toggle it from a custom trigger snippet instead of calling the
provided toggle. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ Collapsible: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
size variant | lgmdsm | md | Controls the dimensions, padding, and text size of the Collapsible. Affects the component's physical footprint. Available options: lg, md, sm. | |
slotClasses | Partial<Record<CollapsibleSlots, string>> | — | Per-slot class overrides. Slots: base | trigger | chevron | content | contentInner | |
title | string | — | Trigger label text (used by the default trigger) | |
transitionDuration | number | — | Override the expand/collapse animation duration in milliseconds. Defaults to the
--blocks-collapse-duration token (the normal 250ms). Set globally via that CSS
custom property or per-instance here. Respects prefers-reduced-motion (near-instant). | |
transitionEasing | string | — | Override the expand/collapse easing as a CSS <easing-function> — e.g. 'ease-in-out',
'cubic-bezier(0.4,0,0.2,1)', or a token such as 'var(--blocks-ease-springy)'. Defaults
to the --blocks-collapse-easing token.
Note: unlike the overlay components (Dialog/Drawer), whose Svelte transitions take an easing
**function** (t: number) => number, Collapsible animates via CSS — so its easing is a CSS
string. Same intent, representation follows the transition mechanism. | |
trigger | Snippet<[
{
open: boolean;
toggle: () => void;
disabled: boolean;
triggerId: string;
contentId: string;
}
]> | — | Custom trigger snippet — receives open state, toggle fn, disabled flag, and ARIA IDs | |
unstyled | boolean | — | Remove default styles | |
variant variant | carddefaultghost | default | Controls the visual style and presentation of the Collapsible. Determines the component's visual treatment. Available options: card, default, ghost. | |
...CollapsibleVariants variant | VariantProps | — | Styling variants from CollapsibleVariants | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') |
06 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
CollapsibleProps | interface | props | 0 | Props interface for the Collapsible component | |
CollapsibleVariants | type | variant | 1 | — | |
CollapsibleSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. |
07 Installation
Import
import { Collapsible } from '@urbicon-ui/blocks';