Accordion
A set of interactive sections that show or hide connected information.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const items = [1, 2, 3];
return (
<Accordion.Root>
{items.map((item) => (
<Accordion.Item class="collapsible" key={item}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {item}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {item}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
✨ Features
- Follows the WAI-Aria design pattern
- Full keyboard navigation
- Can open one or multiple items at a time
- Supports initial and reactive values
Building blocks
import { component$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
export default component$(() => {
return (
<Accordion.Root>
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger>Title</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>Content</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
);
});
🎨 Anatomy
Component | Description |
Accordion.Root | The primary container for the accordion. |
Accordion.Item | A single disclosure widget. |
Accordion.Header | The heading element of an accordion item. |
Accordion.Trigger | Activates to show or hide the accordion content. |
Accordion.Content | Displays the content when its connected trigger is actived. |
Why use a headless accordion?
While you can create a native disclosure with HTML elements like details
and summary
, there are some limitations and issues with them.
Native disclosure pain points
- Cannot control multiple disclosures at once
- Inconsistent accessible name computation
- Unintuitive role announcements
- Connected information is hard to find with AT
Qwik UI includes a headless Accordion component that uses ARIA and JavaScript to enhance accessibility and usability for managing multiple sections smoothly.
Component State
Initial value
To set a default or initial value on page load, use the value
prop on the <Accordion.Root />
component.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const nums = [1, 2, 3];
return (
<Accordion.Root value="item-2">
{nums.map((num) => (
<Accordion.Item value={`item-${num}`} class="collapsible" key={num}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {num}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {num}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
The value
prop on the <Accordion.Root>
was set to item-2
, which is the value of the second item. As a result, the second item is selected by default.
Reactive value
Pass reactive state by using the bind:value
prop on the <Accordion.Root />
component.
Current open item: Not selected
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const nums = [1, 2, 3];
const currOpenItem = useSignal<string | null>(null);
return (
<>
<Accordion.Root bind:value={currOpenItem}>
{nums.map((num) => (
<Accordion.Item value={`item-${num}`} class="collapsible" key={num}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {num}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {num}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
<p style={{ marginTop: '1rem' }}>
Current open item:{' '}
{currOpenItem.value === null ? 'Not selected' : currOpenItem.value}
</p>
</>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
Programmatic changes
You can also change the current expanded item values programmatically by updating the signal's value.
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const nums = [1, 2, 3];
const currOpenItem = useSignal<string | null>(null);
return (
<>
<Accordion.Root bind:value={currOpenItem}>
{nums.map((num) => (
<Accordion.Item value={`item-${num}`} class="collapsible" key={num}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {num}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {num}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
<button
style={{ marginTop: '1rem' }}
onClick$={() => {
// toggle the first item
if (currOpenItem.value === 'item-1') {
currOpenItem.value = null;
} else {
currOpenItem.value = 'item-1';
}
}}
>
Toggle first item
</button>
</>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
Handling selection changes
Listen to when a new item is selected by passing a callback function to the onChange$
prop.
Called change count: 0
Changed to: nothing
import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const items = [1, 2, 3];
const count = useSignal(0);
const currItem = useSignal<string | null>(null);
return (
<>
<Accordion.Root
onChange$={(value: string) => {
count.value++;
currItem.value = value;
}}
>
{items.map((item) => (
<Accordion.Item value={`item-${item}`} class="collapsible" key={item}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {item}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {item}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
<div style={{ marginTop: '1rem' }}>
<p>Called change count: {count.value}</p>
<p>Changed to: {currItem.value ?? 'nothing'}</p>
</div>
</>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
Multiple items
To allow multiple items to be open at the same time, set the multiple
prop to true
.
import { component$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
const items = [1, 2, 3];
return (
<Accordion.Root multiple>
{items.map((item) => (
<Accordion.Item class="collapsible" key={item}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {item}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content-outline collapsible-content">
Inside Content {item}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
});
Non-collapsible
To disable collapsible behavior, set the collapsible
prop to false
.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const items = [1, 2, 3];
return (
<Accordion.Root collapsible={false}>
{items.map((item) => (
<Accordion.Item class="collapsible" key={item}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {item}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {item}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
This will prevent the accordion from collapsing when the user clicks on the trigger.
Disabled items
Items can be disabled by setting the disabled
prop to true on the <Accordion.Item />
component.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const items = [1, 2, 3, 4];
return (
<Accordion.Root>
{items.map((item, index) => (
<Accordion.Item
disabled={index === 1 || index === 3 ? true : false}
class="collapsible"
key={item}
>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {item}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {item}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
Disabled Component
The component itself can also be disabled by setting the disabled
prop to true on the <Accordion.Root />
component.
import { component$, useStyles$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
export default component$(() => {
useStyles$(styles);
const items = [1, 2, 3, 4];
return (
<Accordion.Root disabled={true}>
{items.map((item) => (
<Accordion.Item class="collapsible" key={item}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {item}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {item}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
import { LuChevronDown } from '@qwikest/icons/lucide';
Advanced
Height Animation
To animate the Accordion content, the --qwikui-collapsible-content-height
CSS variable in your keyframes.
Inside Content 1
Inside Content 2
Inside Content 3
import { component$ } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
const items = [1, 2, 3];
return (
<Accordion.Root>
{items.map((item) => (
<Accordion.Item class="collapsible" key={item}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {item}</span>
<LuChevronDown class="collapsible-transition" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-animation collapsible-content">
<p class="collapsible-content-outline">Inside Content {item}</p>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
});
@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;
}
Why does padding or border break the animation?
Padding or border applied to Accordion.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 Accordion automatically renders based on its environment. This means that it works for both server-side and client-side rendering.
import { component$, useStyles$, useSignal } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
import { LuChevronDown } from '@qwikest/icons/lucide';
export default component$(() => {
useStyles$(styles);
const items = [1, 2, 3];
const isRendered = useSignal(false);
return (
<>
<button style={{ marginBottom: '1rem' }} onClick$={() => (isRendered.value = true)}>
Render Accordion
</button>
{isRendered.value && (
<Accordion.Root>
{items.map((item) => (
<Accordion.Item class="collapsible" key={item}>
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">
<span>Trigger {item}</span>
<LuChevronDown />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
Inside Content {item}
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
)}
</>
);
});
// interal
import styles from '../snippets/accordion.css?inline';
Dynamic
You have custom control over how to render items in the Accordion, allowing for dynamic rendering of items.
import { component$, useSignal, useStore } from '@builder.io/qwik';
import { Accordion } from '@qwik-ui/headless';
interface DynamicAccordionProps {
itemIndexToDelete?: number;
itemIndexToAdd?: number;
itemsLength: number;
}
export default component$(({ itemsLength = 3 }: DynamicAccordionProps) => {
const itemIndexToAdd = useSignal<string>('0');
const itemIndexToDelete = useSignal<string>('0');
// start off with some items
const items = [];
const newItem = { label: 'New Item', id: Math.random() };
for (let i = 0; i < itemsLength; i++) {
items.push({
label: `Original Item ${i + 1}`,
id: Math.random(),
});
}
const itemStore = useStore<{ label: string; id: number }[]>(items);
return (
<>
<div class="dynamic-input">
<label class="add">
<input bind:value={itemIndexToAdd} />
<span>Index to Add</span>
</label>
<label class="delete">
<input bind:value={itemIndexToDelete} />
<span>Index to Delete</span>
</label>
</div>
<Accordion.Root>
{itemStore.map(({ label, id }, index) => {
return (
<Accordion.Item id={`${id}`} key={id} class="collapsible">
<Accordion.Header>
<Accordion.Trigger class="collapsible-trigger">{label}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="collapsible-content collapsible-content-outline">
index: {index}
</Accordion.Content>
</Accordion.Item>
);
})}
</Accordion.Root>
<div class="dynamic-buttons">
<button
onClick$={() => {
if (itemStore.length < 6) {
itemStore.splice(parseInt(itemIndexToAdd.value), 0, newItem);
}
}}
>
<strong>Add Item</strong>
</button>
<button
onClick$={() => {
if (itemStore.length > 2) {
itemStore.splice(parseInt(itemIndexToDelete.value), 1);
}
}}
>
<strong>Remove Item</strong>
</button>
</div>
</>
);
});
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;
margin-top: -2px;
}
.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:not(:first-child) .collapsible-trigger {
border-top: none;
}
.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;
/* offset the dotted border */
}
.collapsible-content-outline {
padding: 0.5rem;
border: 2px dotted hsla(var(--primary) / 1);
}
/* chevron transition */
.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;
}
.dynamic-input {
display: flex;
gap: 1rem;
justify-content: center;
}
.dynamic-input label {
display: flex;
flex-direction: column;
text-align: center;
margin-block: 0.5rem;
align-items: center;
}
.dynamic-input input {
margin-bottom: 0.5rem;
width: 5rem;
background: hsl(var(--accent));
border: 2px dotted hsl(var(--foreground));
padding-left: 0.5rem;
}
.dynamic-input .add input {
background: hsla(var(--primary) / 0.2);
}
.dynamic-input .delete input {
background: hsla(var(--accent) / 1);
}
.dynamic-buttons {
display: flex;
gap: 1rem;
margin-block: 0.5rem;
}
Keyboard Interaction
Key | Description |
---|---|
Space | |
Enter | |
Tab | |
Shift + Tab | |
ArrowDown | |
ArrowUp | |
Home | |
End |
API
Accordion.Root
Prop | Type | Description |
---|---|---|
value | string | The initial selectedd item of the accordion. |
bind:value | Signal | Reactive signal that controls the selected item. |
onChange$ | function | Called when the state changes. |
disabled | boolean | Disables the entire accordion. |
collapsible | boolean | Allows items to be collapsible. |
multiple | boolean | Allows multiple items to be expanded. |
Accordion.Item
Prop | Type | Description |
---|---|---|
value | string | The value associated with the accordion item. |
disabled | boolean | When true, the accordion item is disabled. |
open | boolean | Opens the accordion item in multiple mode. |