An accordion organizes related content into expandable and collapsible sections, reducing page scrolling and helping users focus on relevant information. Each section has a trigger button and a content panel. Clicking a trigger toggles the visibility of its associated panel.
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="basic-accordion" [multiExpandable]="false">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger" [expanded]="true">
Which attribute tells assistive tech whether the panel is open or closed?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger1.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<p>
Use <code>aria-expanded</code> on the button element. Set it to "true" when the content
panel is visible and "false" when the content is hidden. This is a crucial state indicator
for screen reader users.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger">
How do you link the button to the content it controls?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger2.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<p>
Use the <code>aria-controls</code> attribute on the button, and set its value to match the
id of the related content panel. This establishes a programmatic relationship, allowing
assistive technologies to jump directly to the relevant content.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger">
What role should the heading element containing the accordion button have?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger3.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<p>
The element containing the button should typically have <code>role="heading"</code> with an
appropriate <code>aria-level</code> to define the structure. This ensures the accordion
section is recognized as a section header, making the page structure navigable for users.
</p>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--secondary-contrast);
box-sizing: border-box;
position: relative;
}
h3:focus-within::before,
h3:hover::before {
content: '';
position: absolute;
height: 100%;
width: 2px;
background-color: var(--vivid-pink);
top: 0;
left: 0;
}
h3:not(:first-of-type) {
border-block-start: 1px solid var(--senary-contrast);
}
p {
margin: 0;
padding: 0 1.5rem 1.5rem 1.5rem;
color: var(--tertiary-contrast);
font-size: 0.875rem;
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionTrigger][aria-expanded='true'] {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
[ngAccordionTrigger][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
.expand-icon {
position: relative;
width: 1rem;
height: 1rem;
flex-shrink: 0;
margin-left: 1rem;
}
.expand-icon::before,
.expand-icon::after {
content: '';
position: absolute;
width: 100%;
height: 2px;
top: 50%;
background-color: var(--quaternary-contrast);
transition: .3s ease-out;
}
.expand-icon::after {
transform: rotate(90deg);
}
.expand-icon__expanded::before {
transform: translateY(-50%) rotate(-90deg);
opacity: 0;
}
.expand-icon__expanded::after {
transform: translateY(-50%) rotate(0);
background-color: var(--electric-violet);
}
Accordions work well for organizing content into logical groups where users typically need to view one section at a time.
Use accordions when:
Avoid accordions when:
Set [multiExpandable]="false" to allow only one panel to be open at a time. Opening a new panel automatically closes any previously open panel.
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="basic-accordion" [multiExpandable]="false">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger" [expanded]="true">
Which attribute tells assistive tech whether the panel is open or closed?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger1.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<p>
Use <code>aria-expanded</code> on the button element. Set it to "true" when the content
panel is visible and "false" when the content is hidden. This is a crucial state indicator
for screen reader users.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger">
How do you link the button to the content it controls?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger2.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<p>
Use the <code>aria-controls</code> attribute on the button, and set its value to match the
id of the related content panel. This establishes a programmatic relationship, allowing
assistive technologies to jump directly to the relevant content.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger">
What role should the heading element containing the accordion button have?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger3.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<p>
The element containing the button should typically have <code>role="heading"</code> with an
appropriate <code>aria-level</code> to define the structure. This ensures the accordion
section is recognized as a section header, making the page structure navigable for users.
</p>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--secondary-contrast);
box-sizing: border-box;
position: relative;
}
h3:focus-within::before,
h3:hover::before {
content: '';
position: absolute;
height: 100%;
width: 2px;
background-color: var(--vivid-pink);
top: 0;
left: 0;
}
h3:not(:first-of-type) {
border-block-start: 1px solid var(--senary-contrast);
}
p {
margin: 0;
padding: 0 1.5rem 1.5rem 1.5rem;
color: var(--tertiary-contrast);
font-size: 0.875rem;
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionTrigger][aria-expanded='true'] {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
[ngAccordionTrigger][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
.expand-icon {
position: relative;
width: 1rem;
height: 1rem;
flex-shrink: 0;
margin-left: 1rem;
}
.expand-icon::before,
.expand-icon::after {
content: '';
position: absolute;
width: 100%;
height: 2px;
top: 50%;
background-color: var(--quaternary-contrast);
transition: .3s ease-out;
}
.expand-icon::after {
transform: rotate(90deg);
}
.expand-icon__expanded::before {
transform: translateY(-50%) rotate(-90deg);
opacity: 0;
}
.expand-icon__expanded::after {
transform: translateY(-50%) rotate(0);
background-color: var(--electric-violet);
}
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="material-accordion" [multiExpandable]="false">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger">
Which attribute tells assistive tech whether the panel is open or closed?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger1.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<p>
Use <code>aria-expanded</code> on the button element. Set it to "true" when the content
panel is visible and "false" when the content is hidden. This is a crucial state indicator
for screen reader users.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger" [expanded]="true">
How do you link the button to the content it controls?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger2.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<p>
Use the <code>aria-controls</code> attribute on the button, and set its value to match the
id of the related content panel. This establishes a programmatic relationship, allowing
assistive technologies to jump directly to the relevant content.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger">
What role should the heading element containing the accordion button have?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger3.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<p>
The element containing the button should typically have <code>role="heading"</code> with an
appropriate <code>aria-level</code> to define the structure. This ensures the accordion
section is recognized as a section header, making the page structure navigable for users.
</p>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--secondary-contrast);
box-sizing: border-box;
border-block-start: 1px solid var(--quinary-contrast);
border-inline: 1px solid var(--quinary-contrast);
}
h3:focus-within,
h3:hover {
background-color: var(--septenary-contrast);
}
h3:first-of-type {
border-radius: 1rem 1rem 0 0;
}
h3:last-of-type {
border-block-end: 1px solid var(--quinary-contrast);
border-radius: 0 0 1rem 1rem;
}
h3:last-of-type:has([aria-expanded='true']) {
border-block-end: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
p {
margin: 0;
padding: 0 1.5rem 1.5rem 1.5rem;
color: var(--tertiary-contrast);
font-size: 0.875rem;
border-inline: 1px solid var(--quinary-contrast);
border-radius: 0 0 1rem 1rem;
border-block-end: 1px solid var(--quinary-contrast);
margin-block-end: 1rem;
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionTrigger][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
.expand-icon {
font-size: 1.5rem;
color: var(--quaternary-contrast);
transition: transform .3s ease-out;
}
.expand-icon__expanded {
transform: rotate(180deg);
}
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="retro-accordion" [multiExpandable]="false">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger">
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger1.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<div class="treasure-box">👻</div>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger">
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger2.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<div class="treasure-box">💎💎💎</div>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger">
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger3.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<div class="treasure-box">🐸 ribbit...ribbit...</div>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--symbolic-yellow) 90%, var(--page-background));
--retro-button-text-color: color-mix(in srgb, var(--symbolic-yellow) 10%, white);
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--retro-button-text-color);
background-color: var(--retro-button-color);
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
h3:focus-within,
h3:hover {
box-shadow: var(--retro-pressed-shadow);
transform: translate(1px, 1px);
outline-offset: 6px;
outline: 4px dashed color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
h3:has([aria-disabled='true']) {
opacity: 0.5;
cursor: default;
}
.treasure-box {
margin: 0;
padding: 1rem;
display: flex;
justify-content: center;
align-items: center;
height: 5rem;
background-color: var(--septenary-contrast);
box-shadow: var(--retro-flat-shadow);
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionPanel] {
padding: 1rem;
}
This mode works well for FAQs or situations where you want users to focus on one answer at a time.
Set [multiExpandable]="true" to allow multiple panels to be open simultaneously. Users can expand as many panels as needed without closing others.
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="basic-accordion">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger" [expanded]="true">
Which attribute tells assistive tech whether the panel is open or closed?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger1.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<p>
Use <code>aria-expanded</code> on the button element. Set it to "true" when the content
panel is visible and "false" when the content is hidden. This is a crucial state indicator
for screen reader users.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger">
How do you link the button to the content it controls?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger2.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<p>
Use the <code>aria-controls</code> attribute on the button, and set its value to match the
id of the related content panel. This establishes a programmatic relationship, allowing
assistive technologies to jump directly to the relevant content.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger" [expanded]="true">
What role should the heading element containing the accordion button have?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger3.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<p>
The element containing the button should typically have <code>role="heading"</code> with an
appropriate <code>aria-level</code> to define the structure. This ensures the accordion
section is recognized as a section header, making the page structure navigable for users.
</p>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--secondary-contrast);
box-sizing: border-box;
position: relative;
}
h3:focus-within::before,
h3:hover::before {
content: '';
position: absolute;
height: 100%;
width: 2px;
background-color: var(--vivid-pink);
top: 0;
left: 0;
}
h3:not(:first-of-type) {
border-block-start: 1px solid var(--senary-contrast);
}
p {
margin: 0;
padding: 0 1.5rem 1.5rem 1.5rem;
color: var(--tertiary-contrast);
font-size: 0.875rem;
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionTrigger][aria-expanded='true'] {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
[ngAccordionTrigger][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
.expand-icon {
position: relative;
width: 1rem;
height: 1rem;
flex-shrink: 0;
margin-left: 1rem;
}
.expand-icon::before,
.expand-icon::after {
content: '';
position: absolute;
width: 100%;
height: 2px;
top: 50%;
background-color: var(--quaternary-contrast);
transition: .3s ease-out;
}
.expand-icon::after {
transform: rotate(90deg);
}
.expand-icon__expanded::before {
transform: translateY(-50%) rotate(-90deg);
opacity: 0;
}
.expand-icon__expanded::after {
transform: translateY(-50%) rotate(0);
background-color: var(--electric-violet);
}
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="material-accordion">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger" [expanded]="true">
Which attribute tells assistive tech whether the panel is open or closed?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger1.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<p>
Use <code>aria-expanded</code> on the button element. Set it to "true" when the content
panel is visible and "false" when the content is hidden. This is a crucial state indicator
for screen reader users.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger">
How do you link the button to the content it controls?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger2.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<p>
Use the <code>aria-controls</code> attribute on the button, and set its value to match the
id of the related content panel. This establishes a programmatic relationship, allowing
assistive technologies to jump directly to the relevant content.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger" [expanded]="true">
What role should the heading element containing the accordion button have?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger3.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<p>
The element containing the button should typically have <code>role="heading"</code> with an
appropriate <code>aria-level</code> to define the structure. This ensures the accordion
section is recognized as a section header, making the page structure navigable for users.
</p>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--secondary-contrast);
box-sizing: border-box;
border-block-start: 1px solid var(--quinary-contrast);
border-inline: 1px solid var(--quinary-contrast);
}
h3:focus-within,
h3:hover {
background-color: var(--septenary-contrast);
}
h3:first-of-type {
border-radius: 1rem 1rem 0 0;
}
h3:last-of-type {
border-block-end: 1px solid var(--quinary-contrast);
border-radius: 0 0 1rem 1rem;
}
h3:last-of-type:has([aria-expanded='true']) {
border-block-end: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
p {
margin: 0;
padding: 0 1.5rem 1.5rem 1.5rem;
color: var(--tertiary-contrast);
font-size: 0.875rem;
border-inline: 1px solid var(--quinary-contrast);
border-radius: 0 0 1rem 1rem;
border-block-end: 1px solid var(--quinary-contrast);
margin-block-end: 1rem;
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionTrigger][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
.expand-icon {
font-size: 1.5rem;
color: var(--quaternary-contrast);
transition: transform .3s ease-out;
}
.expand-icon__expanded {
transform: rotate(180deg);
}
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="retro-accordion">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger">
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger1.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<div class="treasure-box">👻</div>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger">
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger2.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<div class="treasure-box">💎💎💎</div>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger">
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger3.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<div class="treasure-box">🐸 ribbit...ribbit...</div>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--symbolic-yellow) 90%, var(--page-background));
--retro-button-text-color: color-mix(in srgb, var(--symbolic-yellow) 10%, white);
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--retro-button-text-color);
background-color: var(--retro-button-color);
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
h3:focus-within,
h3:hover {
box-shadow: var(--retro-pressed-shadow);
transform: translate(1px, 1px);
outline-offset: 6px;
outline: 4px dashed color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
h3:has([aria-disabled='true']) {
opacity: 0.5;
cursor: default;
}
.treasure-box {
margin: 0;
padding: 1rem;
display: flex;
justify-content: center;
align-items: center;
height: 5rem;
background-color: var(--septenary-contrast);
box-shadow: var(--retro-flat-shadow);
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionPanel] {
padding: 1rem;
}
This mode is useful for form sections or when users need to compare content across multiple panels.
NOTE: The multiExpandable input defaults to true. Set it to false explicitly if you want single expansion behavior.
Disable specific triggers using the disabled input. Control how disabled items behave during keyboard navigation using the softDisabled input on the accordion group.
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="basic-accordion">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger" [expanded]="true">
Which attribute tells assistive tech whether the panel is open or closed?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger1.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<p>
Use <code>aria-expanded</code> on the button element. Set it to "true" when the content
panel is visible and "false" when the content is hidden. This is a crucial state indicator
for screen reader users.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger" disabled>
How do you link the button to the content it controls?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger2.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<p>
Use the <code>aria-controls</code> attribute on the button, and set its value to match the
id of the related content panel. This establishes a programmatic relationship, allowing
assistive technologies to jump directly to the relevant content.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger">
What role should the heading element containing the accordion button have?
<span
aria-hidden="true"
class="expand-icon"
[class.expand-icon__expanded]="trigger3.expanded()"
></span>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<p>
The element containing the button should typically have <code>role="heading"</code> with an
appropriate <code>aria-level</code> to define the structure. This ensures the accordion
section is recognized as a section header, making the page structure navigable for users.
</p>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--secondary-contrast);
box-sizing: border-box;
position: relative;
}
h3:focus-within::before,
h3:hover::before {
content: '';
position: absolute;
height: 100%;
width: 2px;
background-color: var(--vivid-pink);
top: 0;
left: 0;
}
h3:not(:first-of-type) {
border-block-start: 1px solid var(--senary-contrast);
}
p {
margin: 0;
padding: 0 1.5rem 1.5rem 1.5rem;
color: var(--tertiary-contrast);
font-size: 0.875rem;
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionTrigger][aria-expanded='true'] {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
[ngAccordionTrigger][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
.expand-icon {
position: relative;
width: 1rem;
height: 1rem;
flex-shrink: 0;
margin-left: 1rem;
}
.expand-icon::before,
.expand-icon::after {
content: '';
position: absolute;
width: 100%;
height: 2px;
top: 50%;
background-color: var(--quaternary-contrast);
transition: .3s ease-out;
}
.expand-icon::after {
transform: rotate(90deg);
}
.expand-icon__expanded::before {
transform: translateY(-50%) rotate(-90deg);
opacity: 0;
}
.expand-icon__expanded::after {
transform: translateY(-50%) rotate(0);
background-color: var(--electric-violet);
}
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="material-accordion">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger" [expanded]="true">
Which attribute tells assistive tech whether the panel is open or closed?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger1.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<p>
Use <code>aria-expanded</code> on the button element. Set it to "true" when the content
panel is visible and "false" when the content is hidden. This is a crucial state indicator
for screen reader users.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger" disabled>
How do you link the button to the content it controls?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger2.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<p>
Use the <code>aria-controls</code> attribute on the button, and set its value to match the
id of the related content panel. This establishes a programmatic relationship, allowing
assistive technologies to jump directly to the relevant content.
</p>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger">
What role should the heading element containing the accordion button have?
<span
aria-hidden="true"
class="material-symbols-outlined expand-icon"
[class.expand-icon__expanded]="trigger3.expanded()"
translate="no"
>keyboard_arrow_up</span
>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<p>
The element containing the button should typically have <code>role="heading"</code> with an
appropriate <code>aria-level</code> to define the structure. This ensures the accordion
section is recognized as a section header, making the page structure navigable for users.
</p>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--secondary-contrast);
box-sizing: border-box;
border-block-start: 1px solid var(--quinary-contrast);
border-inline: 1px solid var(--quinary-contrast);
}
h3:focus-within,
h3:hover {
background-color: var(--septenary-contrast);
}
h3:first-of-type {
border-radius: 1rem 1rem 0 0;
}
h3:last-of-type {
border-block-end: 1px solid var(--quinary-contrast);
border-radius: 0 0 1rem 1rem;
}
h3:last-of-type:has([aria-expanded='true']) {
border-block-end: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
p {
margin: 0;
padding: 0 1.5rem 1.5rem 1.5rem;
color: var(--tertiary-contrast);
font-size: 0.875rem;
border-inline: 1px solid var(--quinary-contrast);
border-radius: 0 0 1rem 1rem;
border-block-end: 1px solid var(--quinary-contrast);
margin-block-end: 1rem;
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionTrigger][aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
.expand-icon {
font-size: 1.5rem;
color: var(--quaternary-contrast);
transition: transform .3s ease-out;
}
.expand-icon__expanded {
transform: rotate(180deg);
}
import {Component} from '@angular/core';
import {
AccordionGroup,
AccordionTrigger,
AccordionPanel,
AccordionContent,
} from '@angular/aria/accordion';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent],
})
export class App {}
<div ngAccordionGroup class="retro-accordion">
<h3>
<span ngAccordionTrigger panelId="q1" #trigger1="ngAccordionTrigger">
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger1.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q1">
<ng-template ngAccordionContent>
<div class="treasure-box">👻</div>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q2" #trigger2="ngAccordionTrigger" disabled>
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger2.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q2">
<ng-template ngAccordionContent>
<div class="treasure-box">💎💎💎</div>
</ng-template>
</div>
<h3>
<span ngAccordionTrigger panelId="q3" #trigger3="ngAccordionTrigger" [expanded]="true">
Unlock Treasure Box
<span aria-hidden="true" class="material-symbols-outlined" translate="no">{{
trigger3.expanded() ? 'lock_open_right' : 'lock'
}}</span>
</span>
</h3>
<div ngAccordionPanel panelId="q3">
<ng-template ngAccordionContent>
<div class="treasure-box">🐸 ribbit...ribbit...</div>
</ng-template>
</div>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--symbolic-yellow) 90%, var(--page-background));
--retro-button-text-color: color-mix(in srgb, var(--symbolic-yellow) 10%, white);
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--quaternary-contrast),
0px 4px 0px 0px var(--quaternary-contrast), -4px 0px 0px 0px var(--quaternary-contrast),
0px -4px 0px 0px var(--quaternary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--quaternary-contrast), 0px 4px 0px 0px var(--quaternary-contrast),
-4px 0px 0px 0px var(--quaternary-contrast), 0px -4px 0px 0px var(--quaternary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--quaternary-contrast),
0px 4px 0px 0px var(--quaternary-contrast), -4px 0px 0px 0px var(--quaternary-contrast),
0px -4px 0px 0px var(--quaternary-contrast), 8px 8px 0px 0px var(--quaternary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--quaternary-contrast),
0px 4px 0px 0px var(--quaternary-contrast), -4px 0px 0px 0px var(--quaternary-contrast),
0px -4px 0px 0px var(--quaternary-contrast), 0px 0px 0px 0px var(--quaternary-contrast);
}
[ngAccordionGroup] {
width: 500px;
}
h3 {
font-size: 1rem;
margin: 0;
color: var(--retro-button-text-color);
background-color: var(--retro-button-color);
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
h3:focus-within,
h3:hover {
box-shadow: var(--retro-pressed-shadow);
transform: translate(1px, 1px);
outline-offset: 6px;
outline: 4px dashed color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
h3:has([aria-disabled='true']) {
opacity: 0.5;
cursor: default;
}
.treasure-box {
margin: 0;
padding: 1rem;
display: flex;
justify-content: center;
align-items: center;
height: 5rem;
background-color: var(--septenary-contrast);
box-shadow: var(--retro-flat-shadow);
}
[ngAccordionTrigger] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
cursor: pointer;
padding: 1.5rem;
}
[ngAccordionPanel] {
padding: 1rem;
}
When [softDisabled]="true" (the default), disabled items can receive focus but cannot be activated. When [softDisabled]="false", disabled items are skipped entirely during keyboard navigation.
Use the ngAccordionContent directive on an ng-template to defer rendering content until the panel first expands. This improves performance for accordions with heavy content like images, charts, or complex components.
<div ngAccordionGroup>
<div>
<button ngAccordionTrigger panelId="item-1">Trigger Text</button>
<div ngAccordionPanel panelId="item-1">
<ng-template ngAccordionContent>
<!-- This content only renders when the panel first opens -->
<img src="large-image.jpg" alt="Description" />
<app-expensive-component />
</ng-template>
</div>
</div>
</div>
By default, content remains in the DOM after the panel collapses. Set [preserveContent]="false" to remove the content from the DOM when the panel closes.
The container directive that manages keyboard navigation and expansion behavior for a group of accordion items.
| Property | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Disables all triggers in the group |
multiExpandable | boolean | true | Whether multiple panels can be expanded simultaneously |
softDisabled | boolean | true | When true, disabled items are focusable. When false, they are skipped |
wrap | boolean | false | Whether keyboard navigation wraps from last to first item and vice versa |
| Method | Parameters | Description |
|---|---|---|
expandAll | none | Expands all panels (only works when multiExpandable is true) |
collapseAll | none | Collapses all panels |
The directive applied to the button element that toggles panel visibility.
| Property | Type | Default | Description |
|---|---|---|---|
id | string | auto | Unique identifier for the trigger |
panelId | string | — |
Required. Must match the panelId of the associated panel |
disabled | boolean | false | Disables this trigger |
expanded | boolean | false | Whether the panel is expanded (supports two-way binding) |
| Property | Type | Description |
|---|---|---|
active | Signal<boolean> | Whether the trigger currently has focus |
| Method | Parameters | Description |
|---|---|---|
expand | none | Expands the associated panel |
collapse | none | Collapses the associated panel |
toggle | none | Toggles the panel expansion state |
The directive applied to the element containing the collapsible content.
| Property | Type | Default | Description |
|---|---|---|---|
id | string | auto | Unique identifier for the panel |
panelId | string | — |
Required. Must match the panelId of the associated trigger |
preserveContent | boolean | true | Whether to keep content in DOM after panel collapses |
| Property | Type | Description |
|---|---|---|
visible | Signal<boolean> | Whether the panel is currently expanded |
| Method | Parameters | Description |
|---|---|---|
expand | none | Expands this panel |
collapse | none | Collapses this panel |
toggle | none | Toggles the expansion state |
The structural directive applied to an ng-template inside an accordion panel to enable lazy rendering.
This directive has no inputs, outputs, or methods. Apply it to an ng-template element:
<div ngAccordionPanel panelId="item-1">
<ng-template ngAccordionContent>
<!-- Content here is lazily rendered -->
</ng-template>
</div>
Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/aria/accordion