<bpdm/ui />
Panel

Accordion

An accessible accordion — single or multiple open, three looks, icons, smooth height animation, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Accordion collapses content into expandable sections — an FAQ, settings groups, filters. It's accessible by default (full keyboard + ARIA), with a smooth height animation and a rotating chevron. It's data-driven via items; open one at a time (single) or several (multiple), in three looks. In Angular it's <bpdm-accordion> with the same inputs; each item's content is an <ng-template>.

Usage

Pass items — each with a value, title, and content. type="single" (default) opens one at a time; collapsible (default) lets you close it.

Every push to main runs CI; a green build promotes to staging automatically.
faq.tsx
import { Accordion, type AccordionItemData } from '@bpdm/ui/accordion';

const items: AccordionItemData[] = [
  { value: 'deploys', title: 'How are deploys triggered?', content: 'Every push to main runs CI; a green build promotes to staging automatically.' },
  { value: 'rollback', title: 'Can I roll back a release?', content: 'Yes — pick any previous build in the deploys panel and click Promote.' },
  { value: 'logs', title: 'Where are build logs stored?', content: 'Logs are kept for 30 days and are downloadable from each run.' },
];

export function Faq() {
  return <Accordion type="single" collapsible defaultValue="deploys" items={items} />;
}

Each item's content is a <ng-template> referenced from the items array.

faq.ts
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmAccordion, type AccordionItemData } from '@bpdm/ng';

@Component({
  selector: 'faq',
  imports: [BpdmAccordion],
  templateUrl: './faq.html',
})
export class Faq {
  readonly deploys = viewChild.required<TemplateRef<unknown>>('deploys');
  readonly rollback = viewChild.required<TemplateRef<unknown>>('rollback');
  readonly logs = viewChild.required<TemplateRef<unknown>>('logs');

  readonly items = computed<AccordionItemData[]>(() => [
    { value: 'deploys', title: 'How are deploys triggered?', content: this.deploys() },
    { value: 'rollback', title: 'Can I roll back a release?', content: this.rollback() },
    { value: 'logs', title: 'Where are build logs stored?', content: this.logs() },
  ]);
}
faq.html
<bpdm-accordion [items]="items()" type="single" [collapsible]="true" defaultValue="deploys" />

<ng-template #deploys>Every push to main runs CI; a green build promotes to staging automatically.</ng-template>
<ng-template #rollback>Yes — pick any previous build in the deploys panel and click Promote.</ng-template>
<ng-template #logs>Logs are kept for 30 days and are downloadable from each run.</ng-template>

Variants

variant gives three looks: default (a bordered list), separated (each item a card), and borderless (dividers only). The preview toggles them with our Tabs.

Every push to main runs CI; a green build promotes to staging automatically.
variants.tsx
import { Accordion, type AccordionItemData } from '@bpdm/ui/accordion';

const items: AccordionItemData[] = [
  { value: 'deploys', title: 'How are deploys triggered?', content: 'Every push to main runs CI; a green build promotes to staging automatically.' },
  { value: 'rollback', title: 'Can I roll back a release?', content: 'Yes — pick any previous build in the deploys panel and click Promote.' },
  { value: 'logs', title: 'Where are build logs stored?', content: 'Logs are kept for 30 days and are downloadable from each run.' },
];

// variant: "default" | "separated" | "borderless"
export function SeparatedFaq() {
  return <Accordion type="single" collapsible defaultValue="deploys" variant="separated" items={items} />;
}
variants.ts
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmAccordion, type AccordionItemData } from '@bpdm/ng';

@Component({
  selector: 'separated-faq',
  imports: [BpdmAccordion],
  templateUrl: './variants.html',
})
export class SeparatedFaq {
  readonly deploys = viewChild.required<TemplateRef<unknown>>('deploys');
  readonly rollback = viewChild.required<TemplateRef<unknown>>('rollback');
  readonly logs = viewChild.required<TemplateRef<unknown>>('logs');

  readonly items = computed<AccordionItemData[]>(() => [
    { value: 'deploys', title: 'How are deploys triggered?', content: this.deploys() },
    { value: 'rollback', title: 'Can I roll back a release?', content: this.rollback() },
    { value: 'logs', title: 'Where are build logs stored?', content: this.logs() },
  ]);
}
variants.html
<bpdm-accordion [items]="items()" type="single" [collapsible]="true" defaultValue="deploys" variant="separated" />

<ng-template #deploys>Every push to main runs CI; a green build promotes to staging automatically.</ng-template>
<ng-template #rollback>Yes — pick any previous build in the deploys panel and click Promote.</ng-template>
<ng-template #logs>Logs are kept for 30 days and are downloadable from each run.</ng-template>

Single vs multiple

type="single" (the default) keeps one section open at a time; collapsible (default true) lets you close the open one so none are showing. type="multiple" lets several sections stay open at once — here defaultValue is an array.

Every push to main runs CI; a green build promotes to staging automatically.
Logs are kept for 30 days and are downloadable from each run.
multiple.tsx
import { Accordion, type AccordionItemData } from '@bpdm/ui/accordion';

const items: AccordionItemData[] = [
  { value: 'deploys', title: 'How are deploys triggered?', content: 'Every push to main runs CI; a green build promotes to staging automatically.' },
  { value: 'rollback', title: 'Can I roll back a release?', content: 'Yes — pick any previous build in the deploys panel and click Promote.' },
  { value: 'logs', title: 'Where are build logs stored?', content: 'Logs are kept for 30 days and are downloadable from each run.' },
];

export function MultiFaq() {
  return <Accordion type="multiple" defaultValue={['deploys', 'logs']} items={items} />;
}
multiple.ts
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmAccordion, type AccordionItemData } from '@bpdm/ng';

@Component({
  selector: 'multi-faq',
  imports: [BpdmAccordion],
  templateUrl: './multiple.html',
})
export class MultiFaq {
  readonly deploys = viewChild.required<TemplateRef<unknown>>('deploys');
  readonly rollback = viewChild.required<TemplateRef<unknown>>('rollback');
  readonly logs = viewChild.required<TemplateRef<unknown>>('logs');

  readonly items = computed<AccordionItemData[]>(() => [
    { value: 'deploys', title: 'How are deploys triggered?', content: this.deploys() },
    { value: 'rollback', title: 'Can I roll back a release?', content: this.rollback() },
    { value: 'logs', title: 'Where are build logs stored?', content: this.logs() },
  ]);
}
multiple.html
<bpdm-accordion [items]="items()" type="multiple" [defaultValue]="['deploys', 'logs']" />

<ng-template #deploys>Every push to main runs CI; a green build promotes to staging automatically.</ng-template>
<ng-template #rollback>Yes — pick any previous build in the deploys panel and click Promote.</ng-template>
<ng-template #logs>Logs are kept for 30 days and are downloadable from each run.</ng-template>

With icons

Give an item an icon for a leading glyph.

Every push to main runs CI; a green build promotes to staging automatically.

In React the icon is any node — here a lucide-react glyph.

icons.tsx
import { Accordion, type AccordionItemData } from '@bpdm/ui/accordion';
import { Rocket, RotateCcw, FileText } from 'lucide-react';

const items: AccordionItemData[] = [
  { value: 'deploys', title: 'How are deploys triggered?', content: 'Every push to main runs CI; a green build promotes to staging automatically.', icon: <Rocket /> },
  { value: 'rollback', title: 'Can I roll back a release?', content: 'Yes — pick any previous build in the deploys panel and click Promote.', icon: <RotateCcw /> },
  { value: 'logs', title: 'Where are build logs stored?', content: 'Logs are kept for 30 days and are downloadable from each run.', icon: <FileText /> },
];

export function IconFaq() {
  return <Accordion type="single" collapsible defaultValue="deploys" items={items} />;
}

In Angular the icon is a <ng-template> too — reference it from the items array.

icons.ts
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmAccordion, type AccordionItemData } from '@bpdm/ng';

@Component({
  selector: 'icon-faq',
  imports: [BpdmAccordion],
  templateUrl: './icons.html',
})
export class IconFaq {
  readonly deploys = viewChild.required<TemplateRef<unknown>>('deploys');
  readonly rollback = viewChild.required<TemplateRef<unknown>>('rollback');
  readonly logs = viewChild.required<TemplateRef<unknown>>('logs');
  readonly rocket = viewChild.required<TemplateRef<unknown>>('rocket');

  readonly items = computed<AccordionItemData[]>(() => [
    { value: 'deploys', title: 'How are deploys triggered?', content: this.deploys(), icon: this.rocket() },
    { value: 'rollback', title: 'Can I roll back a release?', content: this.rollback() },
    { value: 'logs', title: 'Where are build logs stored?', content: this.logs() },
  ]);
}
icons.html
<bpdm-accordion [items]="items()" type="single" [collapsible]="true" defaultValue="deploys" />

<ng-template #deploys>Every push to main runs CI; a green build promotes to staging automatically.</ng-template>
<ng-template #rollback>Yes — pick any previous build in the deploys panel and click Promote.</ng-template>
<ng-template #logs>Logs are kept for 30 days and are downloadable from each run.</ng-template>

<ng-template #rocket><!-- your icon svg --></ng-template>

Disabled item

Set disabled on an item to make it non-interactive.

disabled.tsx
import { Accordion, type AccordionItemData } from '@bpdm/ui/accordion';

const items: AccordionItemData[] = [
  { value: 'deploys', title: 'How are deploys triggered?', content: 'Every push to main runs CI; a green build promotes to staging automatically.' },
  { value: 'rollback', title: 'Can I roll back a release?', content: 'Yes — pick any previous build in the deploys panel and click Promote.' },
  { value: 'logs', title: 'Where are build logs stored?', content: 'Logs are kept for 30 days and are downloadable from each run.', disabled: true },
];

export function DisabledFaq() {
  return <Accordion type="single" collapsible items={items} />;
}
disabled.ts
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmAccordion, type AccordionItemData } from '@bpdm/ng';

@Component({
  selector: 'disabled-faq',
  imports: [BpdmAccordion],
  templateUrl: './disabled.html',
})
export class DisabledFaq {
  readonly deploys = viewChild.required<TemplateRef<unknown>>('deploys');
  readonly rollback = viewChild.required<TemplateRef<unknown>>('rollback');
  readonly logs = viewChild.required<TemplateRef<unknown>>('logs');

  readonly items = computed<AccordionItemData[]>(() => [
    { value: 'deploys', title: 'How are deploys triggered?', content: this.deploys() },
    { value: 'rollback', title: 'Can I roll back a release?', content: this.rollback() },
    { value: 'logs', title: 'Where are build logs stored?', content: this.logs(), disabled: true },
  ]);
}
disabled.html
<bpdm-accordion [items]="items()" type="single" [collapsible]="true" />

<ng-template #deploys>Every push to main runs CI; a green build promotes to staging automatically.</ng-template>
<ng-template #rollback>Yes — pick any previous build in the deploys panel and click Promote.</ng-template>
<ng-template #logs>Logs are kept for 30 days and are downloadable from each run.</ng-template>

Internationalization

  • The accordion renders no copy of its own — every string (an item's title, its content, an optional icon) comes from the items you pass. So you localize at the data level: translate your items and the accordion renders them as-is. There is nothing to translate on the component itself and no messages prop to set.
  • RTL-safe by construction: layout uses logical properties (text-start, inline-start padding), so it mirrors automatically under dir="rtl" — the chevron moves to the inline-start edge and the text aligns to the start, with no prop required. The German example below is left-to-right; for a right-to-left locale, render it inside a dir="rtl" container (or inherit dir from the page). See the Internationalization guide.
faq-de.tsx
import { Accordion, type AccordionItemData } from '@bpdm/ui/accordion';

// German (de-DE) FAQ — localize the items you pass in
const items: AccordionItemData[] = [
  {
    value: 'projekt',
    title: 'Wie erstelle ich ein Projekt?',
    content: 'Öffnen Sie das Dashboard, klicken Sie auf „Neues Projekt" und geben Sie einen Namen ein.',
  },
  {
    value: 'mitglieder',
    title: 'Wie lade ich Teammitglieder ein?',
    content: 'Gehen Sie zu „Mitglieder", geben Sie eine E-Mail-Adresse ein und wählen Sie eine Rolle aus.',
  },
  {
    value: 'berechtigungen',
    title: 'Wie verwalte ich Berechtigungen?',
    content: 'Berechtigungen werden pro Rolle vergeben; passen Sie sie jederzeit unter „Einstellungen" an.',
  },
];

export function GermanFaq() {
  return <Accordion type="single" collapsible defaultValue="projekt" items={items} />;
}
faq-de.ts
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmAccordion, type AccordionItemData } from '@bpdm/ng';

@Component({
  selector: 'german-faq',
  imports: [BpdmAccordion],
  templateUrl: './faq-de.html',
})
export class GermanFaq {
  readonly projekt = viewChild.required<TemplateRef<unknown>>('projekt');
  readonly mitglieder = viewChild.required<TemplateRef<unknown>>('mitglieder');
  readonly berechtigungen = viewChild.required<TemplateRef<unknown>>('berechtigungen');

  // German (de-DE) FAQ — localize the items you pass in
  readonly items = computed<AccordionItemData[]>(() => [
    { value: 'projekt', title: 'Wie erstelle ich ein Projekt?', content: this.projekt() },
    { value: 'mitglieder', title: 'Wie lade ich Teammitglieder ein?', content: this.mitglieder() },
    { value: 'berechtigungen', title: 'Wie verwalte ich Berechtigungen?', content: this.berechtigungen() },
  ]);
}
faq-de.html
<bpdm-accordion [items]="items()" type="single" [collapsible]="true" defaultValue="projekt" />

<ng-template #projekt>Öffnen Sie das Dashboard, klicken Sie auf „Neues Projekt" und geben Sie einen Namen ein.</ng-template>
<ng-template #mitglieder>Gehen Sie zu „Mitglieder", geben Sie eine E-Mail-Adresse ein und wählen Sie eine Rolle aus.</ng-template>
<ng-template #berechtigungen>Berechtigungen werden pro Rolle vergeben; passen Sie sie jederzeit unter „Einstellungen" an.</ng-template>

API

Accordion / <bpdm-accordion>

PropTypeDefaultDescription
itemsAccordionItemData[]Required. The sections.
typesingle | multiplesingleOpen one at a time, or several.
collapsiblebooleantrueIn single mode, allow closing the open item.
variantdefault | separated | borderlessdefaultThe look.
headingLevel2 | 3 | 4 | 5 | 63Heading level for each item's header, for correct document outline.
defaultValuestring (or string[] for multiple)Which section(s) start open — uncontrolled.
value / onValueChangestring (or string[] for multiple)Controlled open section(s) (React only).
classNamestring (Angular class)Extra classes.

AccordionItemData

FieldTypeDescription
valuestringUnique id for the section.
titleReactNode (Angular string)The trigger label.
contentReactNode (Angular TemplateRef)The collapsible body.
iconReactNode (Angular TemplateRef)Optional leading icon.
disabledbooleanMake the section non-interactive.

Prefer composition? Import AccordionRoot / AccordionItem / AccordionTrigger / AccordionContent and build the tree yourself instead of passing items.

Accessibility

Both frameworks implement the same WAI-ARIA accordion pattern:

  • Real heading per item. Each item's header is a heading at the level you set via headingLevel (default 3), so the accordion sits correctly in the document outline — use 4 when it lives under an <h3> section, and so on.
  • Trigger is a <button> carrying aria-expanded (open/closed) and aria-controls pointing at the panel it toggles.
  • Panel is a role="region" labelled by its trigger via aria-labelledby, so a screen reader announces which section the content belongs to.
  • A collapsed panel is removed from the tab order and hidden from assistive tech, so focus and the reading order skip content that isn't visible.
  • Keyboard: Enter / Space toggle the focused header; Arrow Down / Arrow Up move between headers; Home / End jump to the first / last header. Focused triggers show a visible focus-visible ring.
  • Disabled items are not focusable and cannot be toggled.
  • State is not colour-only — the rotating chevron reflects open/closed alongside aria-expanded, and the open/close height animation is CSS-driven and respects prefers-reduced-motion (the transition is dropped when reduced motion is requested).

On this page