Accordion
Build an accessible accordion component with smooth animations and keyboard support
Learn to build a simple yet powerful accordion component for collapsible content panels. Perfect for FAQs, settings panels, and content organization.
What We're Building
An accordion that supports:
Expand/collapse panels
Single or multiple panels open
Smooth animations
Keyboard navigation
Accessible with ARIA attributes
Customizable styling
Component Code
accordion.ts
import { bindable } from 'aurelia';
export class Accordion {
@bindable allowMultiple = false;
@bindable openPanels: number[] = [];
togglePanel(index: number) {
if (this.allowMultiple) {
// Multiple panels can be open
const panelIndex = this.openPanels.indexOf(index);
if (panelIndex > -1) {
this.openPanels.splice(panelIndex, 1);
} else {
this.openPanels.push(index);
}
} else {
// Only one panel can be open
if (this.isPanelOpen(index)) {
this.openPanels = [];
} else {
this.openPanels = [index];
}
}
}
isPanelOpen(index: number): boolean {
return this.openPanels.includes(index);
}
}accordion.html
<div class="accordion">
<au-slot></au-slot>
</div>accordion-panel.ts
import { bindable, resolve } from 'aurelia';
import { Accordion } from './accordion';
export class AccordionPanel {
@bindable title = '';
@bindable index = 0;
private accordion = resolve(Accordion);
get isOpen(): boolean {
return this.accordion.isPanelOpen(this.index);
}
toggle() {
this.accordion.togglePanel(this.index);
}
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggle();
}
}
}accordion-panel.html
<div class="accordion-panel \${isOpen ? 'accordion-panel--open' : ''}">
<button
type="button"
class="accordion-panel__header"
click.trigger="toggle()"
keydown.trigger="handleKeyDown($event)"
aria-expanded.bind="isOpen">
<span class="accordion-panel__title">\${title}</span>
<svg class="accordion-panel__icon" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 12L2 6h12z" fill="currentColor"/>
</svg>
</button>
<div
class="accordion-panel__content"
aria-hidden.bind="!isOpen">
<div class="accordion-panel__body">
<au-slot></au-slot>
</div>
</div>
</div>accordion.css
.accordion {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.accordion-panel {
border-bottom: 1px solid #e5e7eb;
}
.accordion-panel:last-child {
border-bottom: none;
}
.accordion-panel__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border: none;
cursor: pointer;
transition: background 0.15s;
text-align: left;
font-size: 16px;
font-weight: 500;
}
.accordion-panel__header:hover {
background: #f9fafb;
}
.accordion-panel__header:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
z-index: 1;
}
.accordion-panel__title {
color: #111827;
}
.accordion-panel__icon {
color: #6b7280;
transition: transform 0.2s;
flex-shrink: 0;
}
.accordion-panel--open .accordion-panel__icon {
transform: rotate(180deg);
}
.accordion-panel__content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.accordion-panel--open .accordion-panel__content {
max-height: 1000px; /* Adjust based on your content */
}
.accordion-panel__body {
padding: 0 20px 16px;
color: #374151;
line-height: 1.6;
}Usage Examples
Basic Accordion (Single Panel Open)
<accordion>
<accordion-panel index="0" title="What is Aurelia?">
Aurelia is a modern JavaScript framework for building web applications.
</accordion-panel>
<accordion-panel index="1" title="How do I install Aurelia?">
You can install Aurelia using npm: <code>npm install aurelia</code>
</accordion-panel>
<accordion-panel index="2" title="Where can I learn more?">
Check out the official documentation at docs.aurelia.io
</accordion-panel>
</accordion>Multiple Panels Open
<accordion allow-multiple.bind="true">
<accordion-panel index="0" title="Account Settings">
<p>Manage your account settings here.</p>
</accordion-panel>
<accordion-panel index="1" title="Privacy Settings">
<p>Control your privacy preferences.</p>
</accordion-panel>
<accordion-panel index="2" title="Notification Settings">
<p>Configure your notification preferences.</p>
</accordion-panel>
</accordion>Controlled Open Panels
// your-component.ts
export class FAQPage {
openPanels = [0]; // First panel open by default
openAll() {
this.openPanels = [0, 1, 2, 3];
}
closeAll() {
this.openPanels = [];
}
}<!-- your-component.html -->
<div>
<button click.trigger="openAll()">Expand All</button>
<button click.trigger="closeAll()">Collapse All</button>
</div>
<accordion allow-multiple.bind="true" open-panels.bind="openPanels">
<accordion-panel index="0" title="Question 1">Answer 1</accordion-panel>
<accordion-panel index="1" title="Question 2">Answer 2</accordion-panel>
<accordion-panel index="2" title="Question 3">Answer 3</accordion-panel>
<accordion-panel index="3" title="Question 4">Answer 4</accordion-panel>
</accordion>With Rich Content
<accordion>
<accordion-panel index="0" title="Product Features">
<ul>
<li>Feature 1: Fast performance</li>
<li>Feature 2: Easy to use</li>
<li>Feature 3: Highly customizable</li>
</ul>
</accordion-panel>
<accordion-panel index="1" title="Pricing">
<div class="pricing-grid">
<div class="plan">
<h3>Basic</h3>
<p>$9/month</p>
</div>
<div class="plan">
<h3>Pro</h3>
<p>$29/month</p>
</div>
</div>
</accordion-panel>
</accordion>Testing
import { createFixture } from '@aurelia/testing';
import { Accordion } from './accordion';
import { AccordionPanel } from './accordion-panel';
describe('Accordion', () => {
it('toggles panel open/closed', async () => {
const { getAllBy, trigger, stop } = await createFixture
.html`
<accordion>
<accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
</accordion>
`
.deps(Accordion, AccordionPanel)
.build()
.started;
const panel = getAllBy('.accordion-panel')[0];
const button = getAllBy('.accordion-panel__header')[0];
// Initially closed
expect(panel.classList.contains('accordion-panel--open')).toBe(false);
// Click to open
trigger.click(button);
expect(panel.classList.contains('accordion-panel--open')).toBe(true);
// Click to close
trigger.click(button);
expect(panel.classList.contains('accordion-panel--open')).toBe(false);
await stop(true);
});
it('allows only one panel open when allowMultiple=false', async () => {
const { component, getAllBy, trigger, stop } = await createFixture
.html`
<accordion allow-multiple.bind="false">
<accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
<accordion-panel index="1" title="Panel 2">Content 2</accordion-panel>
</accordion>
`
.deps(Accordion, AccordionPanel)
.build()
.started;
const buttons = getAllBy('.accordion-panel__header');
// Open first panel
trigger.click(buttons[0]);
expect(component.openPanels).toEqual([0]);
// Open second panel
trigger.click(buttons[1]);
expect(component.openPanels).toEqual([1]); // First closed, second open
await stop(true);
});
it('allows multiple panels open when allowMultiple=true', async () => {
const { component, getAllBy, trigger, stop } = await createFixture
.html`
<accordion allow-multiple.bind="true">
<accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
<accordion-panel index="1" title="Panel 2">Content 2</accordion-panel>
</accordion>
`
.deps(Accordion, AccordionPanel)
.build()
.started;
const buttons = getAllBy('.accordion-panel__header');
// Open first panel
trigger.click(buttons[0]);
expect(component.openPanels).toEqual([0]);
// Open second panel
trigger.click(buttons[1]);
expect(component.openPanels).toEqual([0, 1]); // Both open
await stop(true);
});
it('supports keyboard navigation', async () => {
const { getAllBy, trigger, stop } = await createFixture
.html`
<accordion>
<accordion-panel index="0" title="Panel 1">Content 1</accordion-panel>
</accordion>
`
.deps(Accordion, AccordionPanel)
.build()
.started;
const button = getAllBy('.accordion-panel__header')[0];
const panel = getAllBy('.accordion-panel')[0];
// Press Enter to open
trigger.keydown(button, { key: 'Enter' });
expect(panel.classList.contains('accordion-panel--open')).toBe(true);
// Press Space to close
trigger.keydown(button, { key: ' ' });
expect(panel.classList.contains('accordion-panel--open')).toBe(false);
await stop(true);
});
});Accessibility Features
This accordion implements WCAG 2.1 guidelines:
✅ ARIA Attributes:
aria-expandedindicates panel state✅ Keyboard Support: Enter and Space keys toggle panels
✅ Focus Management: Buttons are focusable with visible focus indicators
✅ Semantic HTML: Uses
<button>for interactive headers
Enhancements
1. Add Animation Callbacks
export class AnimatedAccordion extends Accordion {
@bindable onBeforeOpen?: (index: number) => void;
@bindable onAfterOpen?: (index: number) => void;
togglePanel(index: number) {
const wasOpen = this.isPanelOpen(index);
if (!wasOpen && this.onBeforeOpen) {
this.onBeforeOpen(index);
}
super.togglePanel(index);
if (!wasOpen && this.onAfterOpen) {
setTimeout(() => this.onAfterOpen!(index), 300); // After animation
}
}
}2. Add Icons
<accordion-panel index="0" title="Custom Icon">
<svg au-slot="icon" width="20" height="20">
<!-- Custom icon -->
</svg>
Panel content here
</accordion-panel>3. Add Lazy Loading
export class LazyAccordionPanel extends AccordionPanel {
@bindable loadContent?: () => Promise<any>;
content: any = null;
loaded = false;
async toggle() {
super.toggle();
if (this.isOpen && !this.loaded && this.loadContent) {
this.content = await this.loadContent();
this.loaded = true;
}
}
}Best Practices
Animation Performance: Use
max-heightinstead ofheight: autofor smooth transitionsContent Height: Set reasonable
max-heightvalues or calculate dynamicallyAccessibility: Always include
aria-expandedfor screen readersFocus Visible: Ensure keyboard focus is clearly visible
Mobile: Test touch interactions and ensure adequate tap targets
Summary
You've built a fully-functional accordion with:
✅ Single and multiple panel modes
✅ Smooth animations
✅ Keyboard support
✅ Accessible markup
✅ Easy customization
This accordion is ready for FAQs, settings panels, and any collapsible content!
Last updated
Was this helpful?