Collapsible
An interactive section that shows or hides its connected information.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
return (
<Collapsible.Root class="collapsible">
<Collapsible.Trigger class="collapsible-trigger">
<span>Trigger</span>
<LuChevronDown />
</Collapsible.Trigger>
<Collapsible.Content class="collapsible-content collapsible-content-outline ">
Content
</Collapsible.Content>
</Collapsible.Root>
);
});
// internal
import styles from '../snippets/collapsible.css?inline';
✨ Features
- Accessible as a button that shows content, following web a11y standards.
- Full keyboard navigation
- Controlled or uncontrolled
- Initial open state does not wake up the component
- Automatic entry/exit animation detection
- Executes on interaction or programmatically
Building blocks
import { component$ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
export default component$(() => (
<Collapsible.Root>
<Collapsible.Trigger>Button</Collapsible.Trigger>
<Collapsible.Content>Content</Collapsible.Content>
</Collapsible.Root>
));
🎨 Anatomy
Component | Description |
Collapsible.Root | The root container for the Collapsible component. |
Collapsible.Trigger | A button that opens the Collapsible content when interacted with. |
Collapsible.Content | Contains the content associated with a Collapsible. |
Why use a headless collapsible?
One of the most common questions: "why not use the native details
and summary
HTML elements?".
As much as we love the native elements, they come with a couple of problems:
Native element pain points
- Inconsistent accessibility and browser support
- Inconsistent screen reader support
- Hierarchy and DOM structure restrictions
- Lack of full programmatic control
Component State
Uncontrolled / Initial value
We can select an initial uncontrolled value by passing the open
prop to the <Collapsible.Root />
component.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
return (
<Collapsible.Root class="collapsible" open>
<Collapsible.Trigger class="collapsible-trigger">
<span>Trigger</span>
<LuChevronDown />
</Collapsible.Trigger>
<Collapsible.Content class="collapsible-content collapsible-content-outline ">
Content
</Collapsible.Content>
</Collapsible.Root>
);
});
// internal
import styles from '../snippets/collapsible.css?inline';
The above example expands the collapsible by default. Something to notice, is there isn't any layout shift when refreshing the page.
This is because the content is rendered on the server. Animations applied to data-open
take effect after the initial render to prevent layout shift.
Controlled / Reactive value
We can pass reactive state by using the bind:open prop to the <Collapsible.Root />
component.
is open: false
Content
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import styles from '../snippets/collapsible.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal<boolean>(false);
return (
<>
<p>
is open: <strong>{isOpen.value ? 'true' : 'false'}</strong>
</p>
<Collapsible.Root class="collapsible" bind:open={isOpen}>
<Collapsible.Trigger class="collapsible-trigger">Trigger</Collapsible.Trigger>
<Collapsible.Content class="collapsible-content">
<p class="collapsible-content-outline">Content</p>
</Collapsible.Content>
</Collapsible.Root>
</>
);
});
bind:open is a signal prop, and allows us to pass in our own signal to control the expanded state of the collapsible.
Programmatic changes
Now that we have a controlled state, we can programmatically change the state of the collapsible by changing the value of the signal.
is open: false
Content
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import styles from '../snippets/collapsible.css?inline';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal<boolean>(false);
return (
<>
<input
style={{ width: '20px', height: '20px', accentColor: 'hsl(var(--primary))' }}
type="checkbox"
bind:checked={isOpen}
/>
<p>
is open: <strong>{isOpen.value ? 'true' : 'false'}</strong>
</p>
<Collapsible.Root class="collapsible" bind:open={isOpen}>
<Collapsible.Trigger class="collapsible-trigger">Trigger</Collapsible.Trigger>
<Collapsible.Content class="collapsible-content">
<p class="collapsible-content-outline">Content</p>
</Collapsible.Content>
</Collapsible.Root>
</>
);
});
The example above toggles the expanded state of the collapsible by changing the value of the isOpen
signal when the checkbox is clicked.
Handling open / close
We may want to handle the open / close of the collapsible. For example, we may want execute some code when the collapsible is opened or closed.
count: 0
import { component$, useStyles$, useSignal, $ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const count = useSignal<number>(0);
const isOpen = useSignal<boolean>(false);
const handleChange$ = $((open: boolean) => {
isOpen.value = open;
count.value++;
});
return (
<>
<p>
count: <strong> {count.value}</strong>
</p>
<Collapsible.Root class="collapsible" onChange$={handleChange$}>
<Collapsible.Trigger class="collapsible-trigger">
<span>Trigger</span>
<LuChevronDown />
</Collapsible.Trigger>
<Collapsible.Content class="collapsible-content collapsible-content-outline ">
Content
</Collapsible.Content>
</Collapsible.Root>
</>
);
});
// internal
import styles from '../snippets/collapsible.css?inline';
To do that, we can use the onChange$
prop. A parameter is passed to the handler, which is a boolean indicating whether the collapsible is open or closed.
Disabled collapsible
import { component$, useStyles$ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
return (
<Collapsible.Root class="collapsible" disabled>
<Collapsible.Trigger class="collapsible-trigger">
<span>Trigger</span>
<LuChevronDown />
</Collapsible.Trigger>
<Collapsible.Content class="collapsible-content collapsible-content-outline ">
Content
</Collapsible.Content>
</Collapsible.Root>
);
});
// internal
import styles from '../snippets/collapsible.css?inline';
The collapsible can be disabled by adding the disabled
prop to the <Collapsible.Root />
component.
Animating the content
To animate the height of the content, we can use a keyframe animation on the height property.
Content
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(true);
return (
<div>
<Collapsible.Root class="collapsible" bind:open={isOpen}>
<Collapsible.Trigger class="collapsible-trigger">
<span>Trigger</span>
<LuChevronDown class="collapsible-transition" />
</Collapsible.Trigger>
<Collapsible.Content class="collapsible-animation collapsible-content">
<p class="collapsible-content-outline">Content</p>
</Collapsible.Content>
</Collapsible.Root>
<button onClick$={() => (isOpen.value = !isOpen.value)}>Toggle Animation</button>
</div>
);
});
// internal
import styles from '../snippets/collapsible.css?inline';
Height Animation
To animate the Accordion content, the --qwikui-collapsible-content-height
CSS variable in your keyframes.
Content
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const isOpen = useSignal(true);
return (
<div>
<Collapsible.Root class="collapsible" bind:open={isOpen}>
<Collapsible.Trigger class="collapsible-trigger">
<span>Trigger</span>
<LuChevronDown class="collapsible-transition" />
</Collapsible.Trigger>
<Collapsible.Content class="collapsible-animation collapsible-content">
<p class="collapsible-content-outline">Content</p>
</Collapsible.Content>
</Collapsible.Root>
<button onClick$={() => (isOpen.value = !isOpen.value)}>Toggle Animation</button>
</div>
);
});
// internal
import styles from '../snippets/collapsible.css?inline';
.collapsible-content {
overflow: hidden;
}
.collapsible-content[data-open] {
animation: 550ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards collapsible-open;
}
.collapsible-content[data-closed] {
animation: 350ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards collapsible-closed;
}
@keyframes collapsible-open {
from {
height: 0;
}
to {
height: var(--qwikui-collapsible-content-height);
}
}
@keyframes collapsible-closed {
from {
height: var(--qwikui-collapsible-content-height);
}
to {
height: 0;
}
}
Why does padding or border break the animation?
Padding or border applied to Collapsible.Content
breaks our keyframe animation above. This is because the content height has changed.
To fix this, add a child element to the content, and set the padding or border on that element.
CSR
The collapsible can be rendered both server-side or client-side, same with the rest of the components.
import { component$, useStyles$, useSignal } from '@builder.io/qwik';
import { Collapsible } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const renderCollapsible = useSignal(false);
return (
<>
<button onClick$={() => (renderCollapsible.value = true)}>
Render Collapsible
</button>
{renderCollapsible.value && (
<Collapsible.Root class="collapsible">
<Collapsible.Trigger class="collapsible-trigger">
<span>Trigger</span>
<LuChevronDown />
</Collapsible.Trigger>
<Collapsible.Content class="collapsible-content collapsible-content-outline ">
Content
</Collapsible.Content>
</Collapsible.Root>
)}
</>
);
});
// internal
import styles from '../snippets/collapsible.css?inline';
The main difference, is there is no server to client handoff. This can be useful if you're navigating via SPA.
Example CSS
.collapsible {
min-width: 14rem;
}
.collapsible-trigger {
width: 100%;
border: 2px dotted hsla(var(--foreground) / 1);
border-radius: calc(var(--border-radius) / 2);
padding: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.collapsible-trigger[data-disabled] {
opacity: 0.3;
}
.collapsible-trigger:hover {
background-color: hsla(var(--primary) / 0.08);
}
.collapsible-trigger svg {
width: 1.25rem;
height: 1.25rem;
}
.collapsible-trigger[data-open] {
border-bottom: none;
}
.collapsible-trigger[data-open] svg {
transform: rotate(180deg);
}
.collapsible-content {
width: 100%;
font-weight: 500;
background: hsla(var(--primary) / 0.2);
border-radius: calc(var(--border-radius) / 2);
max-width: var(--select-width);
color: hsl(var(--foreground));
overflow: hidden;
margin-top: -2px;
}
.collapsible:has(.collapsible-content:not([hidden])) .collapsible-trigger {
border-bottom: 2px dotted;
}
.collapsible-content-outline {
padding: 0.5rem;
border: 2px dotted hsla(var(--primary) / 1);
}
/* animations only */
.collapsible-transition {
transition: transform 500ms ease;
}
@keyframes collapsible-open {
0% {
height: 0;
margin-top: 0;
}
100% {
height: var(--qwikui-collapsible-content-height);
margin-top: -2px;
}
}
@keyframes collapsible-closed {
0% {
height: var(--qwikui-collapsible-content-height);
margin-top: -2px;
}
100% {
height: 0;
margin-top: 0;
}
}
.collapsible-animation[data-open] {
animation: 550ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards collapsible-open;
}
.collapsible-animation[data-closed] {
animation: 350ms cubic-bezier(0.87, 0, 0.13, 1) 0s 1 normal forwards collapsible-closed;
}
Every code example uses the following CSS:
Some CSS variables are specific to the docs, feel free to plug in your own values or variables!
Keyboard Interaction
Key | Description |
---|---|
Space | |
Enter |
API
Data Attributes
Collapsible.Root
, Collapsible.Trigger
, and Collapsible.Content
all have the following data attributes that are used to track state:
Attribute | Description |
data-open | If the collapsible is open (Boolean). |
data-closed | If the collapsible is closed (Boolean). |
data-disabled | If the collapsible is disabled (Boolean). |
Collapsible.Root
Prop | Type | Description |
---|---|---|
open | boolean | Uncontrolled initial expanded value. |
bind:open | signal boolean | Controlled expanded value, manages the collapsible content. |
onOpenChange$ | QRL QRL<(open: boolean) => void> | Function called when the collapsible opens or closes. |
disabled | boolean | Disables the collapsible when true. |