Accordion für React
Aufklappzeilen auf nativem details: ohne JavaScript lesbar, eine oder mehrere offen, weich, wo der Browser es kann.
Eine von 6 Components in Navigation
Aufklappzeilen, die überall funktionieren
Ein Accordion faltet lange Inhalte zu kurzen Zeilen, die sich bei Bedarf öffnen. Dieses baut auf den nativen Elementen details und summary auf, der Inhalt steht also für Suchmaschinen im HTML, öffnet sich mit Enter und Leertaste und funktioniert auch, wenn JavaScript nicht lädt.
„Eine zur Zeit“ schließt die offene Zeile, sobald eine andere aufgeht, gut für FAQ, bei denen man eine Antwort liest. Karten geben jeder Zeile eine eigene Fläche, für Preisdetails oder technische Daten. „Mit Icons“ ergänzt ein Icon je Zeile für Einstellungen und Hilfethemen. Alle drei erlauben auch mehrere offene Zeilen, und die Browsersuche findet Text in geschlossenen Zeilen.
Formulier jede Zeile als ganze Frage oder klares Thema, Menschen entscheiden allein nach dem Label. Halte den geöffneten Inhalt kurz, ein Accordion im Accordion zeigt, dass die Seite eine andere Struktur braucht. Öffne die wichtigste Zeile von Anfang an, wenn es hilft.
Details
Was Akkordeon mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Navigation
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/accordion.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AccordionProps aus components/ui/accordion.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
itemsPflichtAccordionItem[]typeoptional'single' | 'multiple'single: opening one closes the others (native <details name>). multiple: each on its own.
defaultOpenoptionalstring[]Values open on first render
variantoptional'divided' | 'cards' | 'contained'divided: hairlines between rows. cards: separate surfaces. contained: one bordered box.
markeroptional'chevron' | 'plus'chevron or plus, the plus turns into a minus
headingLeveloptional2 | 3 | 4Heading level of the titles, for a correct outline
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 191 Zeilen
'use client';
import { useEffect, useId, useRef } from 'react';
import { cn, prefersReducedMotion, useInView } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
export interface AccordionItem {
/** Stable id, used for the anchor and defaultOpen */
value: string;
title: React.ReactNode;
content: React.ReactNode;
icon?: IconName;
/** Quiet text under the title */
subtitle?: React.ReactNode;
}
export interface AccordionProps {
items: AccordionItem[];
/** single: opening one closes the others (native <details name>). multiple: each on its own. */
type?: 'single' | 'multiple';
/** Values open on first render */
defaultOpen?: string[];
/** divided: hairlines between rows. cards: separate surfaces. contained: one bordered box. */
variant?: 'divided' | 'cards' | 'contained';
/** chevron or plus, the plus turns into a minus */
marker?: 'chevron' | 'plus';
/** Heading level of the titles, for a correct outline */
headingLevel?: 2 | 3 | 4;
className?: string;
}
/*
* Open and closed follow [open], except while a row closes: then data-closing turns
* the marker back at once, while the height still runs. Rows rise one after another
* when the list scrolls into view.
*/
const STYLES =
'.kitfx-acc-chev,.kitfx-acc-plus{transition:transform .35s var(--ease-kit),translate .2s var(--ease-kit)}' +
'.kitfx-acc[open]:not([data-closing]) .kitfx-acc-chev{transform:rotate(180deg)}' +
'.kitfx-acc[open]:not([data-closing]) .kitfx-acc-plus{transform:scaleY(0)}' +
'.kitfx-acc-card{transition:border-color .3s,box-shadow .3s,translate .3s var(--ease-kit)}' +
'.kitfx-acc-card[open]:not([data-closing]){border-color:var(--border-strong);box-shadow:var(--highlight),var(--shadow-md)}' +
'.kitfx-acc-in>.kitfx-acc{animation:kitfx-acc-in .6s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 70ms)}' +
'@keyframes kitfx-acc-in{from{opacity:0;transform:translateY(12px)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-acc-chev,.kitfx-acc-plus,.kitfx-acc-card{transition:none}.kitfx-acc-in>.kitfx-acc{animation:none}}';
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
/**
* Disclosure rows on native <details>: work without JavaScript, open with Enter and Space, searchable with Ctrl F
* in browsers that support it. With JavaScript the height opens and closes softly (grid rows 0fr to 1fr),
* the text fades in and the marker turns.
* <Accordion type="single" items={[{ value: 'refund', title: 'Can I get a refund?', content: '…' }]} />
*/
export function Accordion({ items, type = 'single', defaultOpen = [], variant = 'divided', marker = 'chevron', headingLevel = 3, className }: AccordionProps) {
const group = useId();
const H = `h${headingLevel}` as 'h3';
const [ref, inView] = useInView<HTMLDivElement>();
const hidden = useRef(false);
if (!inView) hidden.current = true;
const entering = inView && hidden.current;
const timers = useRef(new Map<HTMLDetailsElement, number>());
// The script takes over "one at a time", so the others can close softly instead of snapping shut
useEffect(() => {
if (prefersReducedMotion()) return;
ref.current?.querySelectorAll('details[name]').forEach((d) => d.removeAttribute('name'));
}, [ref]);
useEffect(() => {
const map = timers.current;
return () => map.forEach((t) => window.clearTimeout(t));
}, []);
const body = (d: HTMLDetailsElement) => d.querySelector<HTMLElement>(':scope > [data-acc-body]');
const shut = (d: HTMLDetailsElement) => {
const b = body(d);
if (!b || !d.open || d.dataset.closing !== undefined) return;
window.clearTimeout(timers.current.get(d));
d.dataset.closing = '';
(b.firstElementChild as HTMLElement | null)?.style.setProperty('overflow', 'hidden');
b.dataset.shut = '';
timers.current.set(
d,
window.setTimeout(() => {
d.open = false;
delete d.dataset.closing;
delete b.dataset.shut;
(b.firstElementChild as HTMLElement | null)?.style.removeProperty('overflow');
}, 380),
);
};
const show = (d: HTMLDetailsElement) => {
const b = body(d);
if (!b) return;
window.clearTimeout(timers.current.get(d));
const inner = b.firstElementChild as HTMLElement | null;
inner?.style.setProperty('overflow', 'hidden');
if (!d.open) {
b.dataset.shut = '';
d.open = true;
void b.offsetHeight;
}
delete d.dataset.closing;
delete b.dataset.shut;
inner?.firstElementChild?.animate([{ opacity: 0, transform: 'translateY(-6px)' }, { opacity: 1, transform: 'none' }], { duration: 380, delay: 60, easing: EASE, fill: 'backwards' });
timers.current.set(
d,
window.setTimeout(() => inner?.style.removeProperty('overflow'), 420),
);
};
const onToggle = (e: React.MouseEvent<HTMLElement>) => {
if (prefersReducedMotion()) return;
const d = e.currentTarget.parentElement as HTMLDetailsElement;
e.preventDefault();
if (d.open && d.dataset.closing === undefined) {
shut(d);
return;
}
if (type === 'single') ref.current?.querySelectorAll<HTMLDetailsElement>(':scope > details[open]').forEach((o) => o !== d && shut(o));
show(d);
};
return (
<div
ref={ref}
className={cn(
'w-full',
variant === 'divided' && 'divide-y divide-border border-y border-border',
variant === 'cards' && 'grid gap-3',
variant === 'contained' && 'divide-y divide-border overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]',
!inView && '[&>details]:opacity-0',
entering && 'kitfx-acc-in',
className,
)}
>
<style href="kitfx-acc" precedence="default">
{STYLES}
</style>
{items.map((it, i) => (
<details
key={it.value}
id={it.value}
name={type === 'single' ? group : undefined}
open={defaultOpen.includes(it.value) || undefined}
style={{ '--i': i } as React.CSSProperties}
className={cn('kitfx-acc group/acc', variant === 'cards' && 'kitfx-acc-card rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] hover:-translate-y-px motion-reduce:hover:translate-y-0')}
>
<summary
onClick={onToggle}
className={cn(
'flex min-h-14 cursor-pointer list-none items-center gap-4 py-4 text-left outline-none [&::-webkit-details-marker]:hidden',
'rounded-lg focus-visible:ring-2 focus-visible:ring-ring',
variant !== 'divided' && 'px-5',
variant === 'divided' && 'hover:text-foreground',
)}
>
{it.icon && (
<span className="grid size-9 shrink-0 place-items-center rounded-lg border border-border bg-surface text-muted-foreground transition-[color,scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-open/acc:text-foreground group-hover/acc:scale-105 motion-reduce:transition-none">
<Icon name={it.icon} size={17} />
</span>
)}
<span className="min-w-0 flex-1">
<H className="text-pretty text-[15px] font-medium leading-snug text-foreground">{it.title}</H>
{it.subtitle && <span className="mt-0.5 block text-[13px] text-muted-foreground">{it.subtitle}</span>}
</span>
{marker === 'chevron' ? (
<span className="grid shrink-0 place-items-center text-muted-foreground transition-[translate] duration-200 group-hover/acc:translate-y-0.5 motion-reduce:transition-none">
<Icon name="chevron-down" size={18} className="kitfx-acc-chev" />
</span>
) : (
<span aria-hidden className="relative grid size-7 shrink-0 place-items-center rounded-full border border-border text-muted-foreground transition-[color,border-color,rotate] duration-300 ease-[var(--ease-kit)] group-open/acc:border-border-strong group-open/acc:text-foreground group-hover/acc:rotate-90 group-open/acc:group-hover/acc:rotate-0 motion-reduce:transition-none">
<span className="absolute h-[1.5px] w-3 rounded-full bg-current" />
<span className="kitfx-acc-plus absolute h-3 w-[1.5px] rounded-full bg-current" />
</span>
)}
</summary>
<div data-acc-body className="grid grid-rows-[1fr] transition-[grid-template-rows] duration-[380ms] ease-[var(--ease-kit)] data-[shut]:grid-rows-[0fr] motion-reduce:transition-none">
<div className="min-h-0">
<div className={cn('pb-5 text-pretty text-sm leading-relaxed text-muted-foreground', variant !== 'divided' && 'px-5', it.icon && (variant === 'divided' ? 'ps-[52px]' : 'ps-[72px]'), marker === 'plus' ? 'pe-12' : 'pe-10')}>
{it.content}
</div>
</div>
</div>
</details>
))}
</div>
);
}Häufige Fragen
Wie installiere ich Akkordeon?
Mit aiw add @ai-website/accordion. Der Befehl legt die Datei components/ui/accordion.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Akkordeon?
3 Varianten, Eine zur Zeit, Karten und Mit Icons. Du wählst sie mit der Prop variant, zum Beispiel variant="contained".
Kann ein KI-Assistent Akkordeon einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Akkordeon mit aiw_components und installieren sie mit aiw_add @ai-website/accordion. Nenn im Prompt die Variante, etwa variant="cards", dann schreibt der Assistent deine Inhalte direkt in die Props.