Collapsible
An interactive component which expands/collapses a panel.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
import styles from '../snippets/collapsible.css?inline';
import SVG from './svg';
export default component$(() => {
useStyles$(styles);
return (
<Collapsible class="collapsible">
<CollapsibleTrigger class="collapsible-trigger">
<span>Trigger</span>
<SVG />
</CollapsibleTrigger>
<CollapsibleContent class="collapsible-content collapsible-content-outline ">
Content
</CollapsibleContent>
</Collapsible>
);
});
✨ 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, CollapsibleContent, CollapsibleTrigger } from '@qwik-ui/headless';
export default component$(() => (
<Collapsible>
<CollapsibleTrigger>Button</CollapsibleTrigger>
<CollapsibleContent>Content</CollapsibleContent>
</Collapsible>
));
🎨 Anatomy
Component | Description |
Collapsible | The root container for the Collapsible component. |
CollapsibleTrigger | A button that opens the Collapsible content when interacted with. |
CollapsibleContent | 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 />
component.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
import styles from '../snippets/collapsible.css?inline';
import SVG from './svg';
export default component$(() => {
useStyles$(styles);
return (
<Collapsible class="collapsible" open>
<CollapsibleTrigger class="collapsible-trigger">
<span>Trigger</span>
<SVG />
</CollapsibleTrigger>
<CollapsibleContent class="collapsible-content collapsible-content-outline ">
Content
</CollapsibleContent>
</Collapsible>
);
});
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 />
component.
is open: false
Content
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } 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 class="collapsible" bind:open={isOpen}>
<CollapsibleTrigger class="collapsible-trigger">Trigger</CollapsibleTrigger>
<CollapsibleContent class="collapsible-content">
<p class="collapsible-content-outline">Content</p>
</CollapsibleContent>
</Collapsible>
</>
);
});
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, CollapsibleTrigger, CollapsibleContent } 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 class="collapsible" bind:open={isOpen}>
<CollapsibleTrigger class="collapsible-trigger">Trigger</CollapsibleTrigger>
<CollapsibleContent class="collapsible-content">
<p class="collapsible-content-outline">Content</p>
</CollapsibleContent>
</Collapsible>
</>
);
});
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, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
import styles from '../snippets/collapsible.css?inline';
import SVG from './svg';
export default component$(() => {
useStyles$(styles);
const count = useSignal<number>(0);
const isOpen = useSignal<boolean>(false);
const handleOpenChange$ = $((open: boolean) => {
isOpen.value = open;
count.value++;
});
return (
<>
<p>
count: <strong> {count.value}</strong>
</p>
<Collapsible class="collapsible" onOpenChange$={handleOpenChange$}>
<CollapsibleTrigger class="collapsible-trigger">
<span>Trigger</span>
<SVG />
</CollapsibleTrigger>
<CollapsibleContent class="collapsible-content collapsible-content-outline ">
Content
</CollapsibleContent>
</Collapsible>
</>
);
});
To do that, we can use the onOpenChange$ 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, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
import styles from '../snippets/collapsible.css?inline';
import SVG from './svg';
export default component$(() => {
useStyles$(styles);
return (
<Collapsible class="collapsible" disabled>
<CollapsibleTrigger class="collapsible-trigger">
<span>Trigger</span>
<SVG />
</CollapsibleTrigger>
<CollapsibleContent class="collapsible-content collapsible-content-outline ">
Content
</CollapsibleContent>
</Collapsible>
);
});
The collapsible can be disabled by adding the disabled
prop to the <Collapsible />
component.
Animating the content
To animate the height of the content, we can use a keyframe animation on the height property.
Content
import { component$, useStyles$ } from '@builder.io/qwik';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
import styles from '../snippets/collapsible.css?inline';
import SVG from './svg';
export default component$(() => {
useStyles$(styles);
return (
<Collapsible class="collapsible">
<CollapsibleTrigger class="collapsible-trigger">
<span>Trigger</span>
<SVG class="collapsible-transition" />
</CollapsibleTrigger>
<CollapsibleContent class="collapsible-animation collapsible-content">
<p class="collapsible-content-outline">Content</p>
</CollapsibleContent>
</Collapsible>
);
});
Height animation
By default, the --qwikui-collapsible-content-height
CSS variable will automatically be set to the height of the content.
.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 CollapsibleContent
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 styles from '../snippets/collapsible.css?inline';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@qwik-ui/headless';
import SVG from './svg';
export default component$(() => {
useStyles$(styles);
const isCollapsibleRendered = useSignal(false);
return (
<>
<button onClick$={() => (isCollapsibleRendered.value = true)}>
Render Collapsible
</button>
{isCollapsibleRendered.value && (
<Collapsible class="collapsible">
<CollapsibleTrigger class="collapsible-trigger">
<span>Trigger</span>
<SVG />
</CollapsibleTrigger>
<CollapsibleContent class="collapsible-content collapsible-content-outline ">
Content
</CollapsibleContent>
</Collapsible>
)}
</>
);
});
The main difference, is there is no server to client handoff. This can be useful if you're navigating via SPA.
Example CSS
.collapsible {
width: 10rem;
}
.collapsible-trigger {
width: 100%;
border: 2px dotted hsla(var(--primary) / 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[data-open] svg {
transform: rotate(180deg);
}
.collapsible-content {
width: 100%;
background-color: hsl(var(--background));
border-radius: calc(var(--border-radius) / 2);
max-width: var(--select-width);
color: hsl(var(--foreground));
overflow: hidden;
}
.collapsible-content-outline {
padding: 0.5rem;
border: 2px dotted hsla(var(--foreground) / 0.6);
}
/* animations only */
.collapsible-transition {
transition: transform 500ms ease;
}
@keyframes collapsible-open {
0% {
height: 0;
}
100% {
height: var(--qwikui-collapsible-content-height);
}
}
@keyframes collapsible-closed {
0% {
height: var(--qwikui-collapsible-content-height);
}
100% {
height: 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
, CollapsibleTrigger
, and CollapsibleContent
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. |