@data-slot/collapsible
Headless collapsible (show/hide) component for vanilla JavaScript. Accessible, unstyled, tiny.
Installation
npm install @data-slot/collapsible
Quick Start
<div data-slot="collapsible">
<button data-slot="collapsible-trigger">Toggle Content</button>
<div data-slot="collapsible-content" hidden>
Hidden content revealed on click.
</div>
</div>
<script type="module">
import { create } from "@data-slot/collapsible";
const controllers = create();
</script>
API
Initialization
create(scope?)
Auto-discover and bind all collapsible instances in a scope (defaults to document).
import { create } from "@data-slot/collapsible";
const controllers = create(); // Returns CollapsibleController[]
createCollapsible(root, options?)
Create a controller for a specific element.
import { createCollapsible } from "@data-slot/collapsible";
const collapsible = createCollapsible(element, {
defaultOpen: false,
hiddenUntilFound: false,
closeOnEscape: false,
closeOnClickOutside: false,
onOpenChange: (open) => console.log(open),
});
Slots
Runtime Slots
collapsible- Root element that manages the open state.collapsible-trigger- Required button that toggles the content and receivesaria-expanded.collapsible-content- Required panel that is shown or hidden with the open state.
Markup
<div data-slot="collapsible">
<button data-slot="collapsible-trigger">Toggle</button>
<div data-slot="collapsible-content">Content</div>
</div>
Data Attributes
Options can also be set via data attributes on the root element. JS options take precedence.
| Attribute | Type | Default | Description |
|---|---|---|---|
data-default-open | boolean | false | Initial open state |
data-hidden-until-found | boolean | false | Use hidden="until-found" when closed |
data-close-on-escape | boolean | false | Close when pressing Escape |
data-close-on-click-outside | boolean | false | Close on a pointer press outside the collapsible |
Boolean attributes: present or "true" = true, "false" = false, absent = default.
<!-- Start expanded -->
<div data-slot="collapsible" data-default-open>
...
</div>
Options
| Option | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Initial open state |
hiddenUntilFound | boolean | false | Use hidden="until-found" when closed |
closeOnEscape | boolean | false | Close when pressing Escape |
closeOnClickOutside | boolean | false | Close on a pointer press outside the collapsible |
onOpenChange | (open: boolean) => void | undefined | Callback when open state changes (not called on init) |
Controller
| Method/Property | Description |
|---|---|
open() | Open the collapsible |
close() | Close the collapsible |
toggle() | Toggle the collapsible |
isOpen | Current open state (readonly boolean) |
destroy() | Cleanup all event listeners |
Events
Outbound Events
Listen for changes via custom events:
element.addEventListener("collapsible:change", (e) => {
console.log("Collapsible open:", e.detail.open);
});
Inbound Events
Control the collapsible via events:
| Event | Detail | Description |
|---|---|---|
collapsible:set | { open: boolean } | Set open state programmatically |
// Open the collapsible
element.dispatchEvent(
new CustomEvent("collapsible:set", { detail: { open: true } })
);
// Close the collapsible
element.dispatchEvent(
new CustomEvent("collapsible:set", { detail: { open: false } })
);
Note: Blocked when trigger is disabled (has disabled attribute or aria-disabled="true").
Deprecated Shapes
The following shape is deprecated and will be removed in v1.0:
// Deprecated: { value: boolean }
element.dispatchEvent(
new CustomEvent("collapsible:set", { detail: { value: true } })
);
Use { open: boolean } instead.
Styling
Use data-state attributes for CSS styling (available on both root and content):
/* Hidden state */
[data-slot="collapsible-content"][hidden] {
display: none;
}
/* Or use data-state */
[data-slot="collapsible"][data-state="closed"] [data-slot="collapsible-content"] {
display: none;
}
/* Animate using data-state on content */
[data-slot="collapsible-content"] {
overflow: hidden;
transition: max-height 0.3s;
max-height: 0;
}
[data-slot="collapsible-content"][data-state="open"] {
max-height: 500px;
}
/* Presence lifecycle markers */
[data-slot="collapsible-content"][data-starting-style] {
opacity: 0;
}
[data-slot="collapsible-content"][data-ending-style] {
opacity: 0;
}
CSS Variables
The content element exposes size variables you can use for dimension animations:
| Variable | Description |
|---|---|
--collapsible-panel-height | Panel height (auto at open rest, measured px during transitions, 0px when closed) |
--collapsible-panel-width | Panel width (auto at open rest, measured px during transitions, 0px when closed) |
Example:
[data-slot="collapsible-content"] {
overflow: hidden;
height: var(--collapsible-panel-height);
width: var(--collapsible-panel-width);
transition: height 0.25s ease, width 0.25s ease, opacity 0.2s ease;
}
[data-slot="collapsible-content"][data-starting-style],
[data-slot="collapsible-content"][data-ending-style] {
height: 0;
opacity: 0;
}
Accessibility
The component automatically handles:
aria-expandedstate on triggeraria-controlslinking trigger to contentrole="region"on contentaria-labelledbylinking content back to trigger- Unique ID generation for trigger and content
- Disabled trigger support (respects
disabledattribute andaria-disabled="true") - Focus returns to the trigger when Escape closes the collapsible while focus is inside it
Behavior
Dismissal
Collapsibles stay open until toggled by default. Enable closeOnEscape and
closeOnClickOutside (or data-close-on-escape and data-close-on-click-outside)
for disclosures that behave like a light-dismiss layer, such as a mobile navigation panel.
<div data-slot="collapsible" data-close-on-escape data-close-on-click-outside>
<button data-slot="collapsible-trigger">Menu</button>
<nav data-slot="collapsible-content" aria-label="Main">...</nav>
</div>
Dismissal uses the same layer stack as popovers, dialogs and menus:
- Escape closes only the most recently opened layer, so a popover inside the panel closes first.
- Presses inside portaled descendants, such as a popover opened from the panel, count as inside.
- On touch devices, an outside press closes the panel on
click, so scrolling the page does not dismiss it. - Dismissal is blocked while the trigger is disabled.
Find-in-Page Support
Enable hiddenUntilFound (or data-hidden-until-found) to close panels with
hidden="until-found". This allows browser find-in-page to reveal matching text.
With Tailwind:
<div data-slot="collapsible" class="group">
<button data-slot="collapsible-trigger" class="flex items-center gap-2">
<span>Show more</span>
<svg class="group-data-[state=open]:rotate-180 transition-transform">...</svg>
</button>
<div data-slot="collapsible-content" class="hidden group-data-[state=open]:block">
Content here
</div>
</div>
License
MIT