Skip to main content
Zurück zum Blog
CSSHTMLAccordionFrontendPerformance

Ein flüssiges Akkordeon mit null JavaScript

Alle greifen zu React-State und einem höhenmessenden Hook, um ein Akkordeon zu bauen. Der Browser liefert seit Jahren ein natives — und seit 2024 animiert es das Öffnen und Schließen mit reinem CSS. Hier die vollständige Technik: details[name], ::details-content, interpolate-size und die eine Eigenschaft, die auch das Schließen animiert.

Veröffentlicht 28. Juli 20269 Min. Lesezeit

Ein Akkordeon ist eines der am häufigsten neu implementierten Widgets im Web. Das übliche Rezept: ein Stück React-State für den geöffneten Zustand, ein onClick zum Umschalten und etwas CSS (oder ein höhenmessender Hook), um das Aufklappen zu animieren. Es funktioniert, aber es verwandelt statischen Inhalt in eine Client-Komponente, die JavaScript ausliefert und hydratisiert — nur zum Öffnen und Schließen.

Der Browser hat seit Jahren ein natives Akkordeon-Element: <details>. Seine einzige historische Schwäche — man konnte es nicht animieren — ist seit 2024 Geschichte. Dieser Artikel baut ein vollständig animiertes, exklusives Akkordeon mit rotierendem Chevron und null JavaScript: kein State, keine Hooks, keine Hydratation.

Das Akkordeon, das alle ausliefern

Hier das Muster in seiner häufigsten Form — React-State für das geöffnete Element, ein Click-Handler zum Umschalten und der Trick grid-template-rows: 0fr → 1fr, um die Höhe zu animieren, ohne irgendetwas zu messen:

Accordion.tsx
"use client"; // ← the whole component is now a client bundle

import { useState } from "react";

export function Accordion({ items }: { items: Item[] }) {
  const [openId, setOpenId] = useState<string | null>(null);

  return (
    <div>
      {items.map((item) => {
        const isOpen = openId === item.id;
        return (
          <div key={item.id}>
            <button onClick={() => setOpenId(isOpen ? null : item.id)}>
              {item.title}
            </button>
            {/* grid 0fr → 1fr is the CSS trick that animates the height */}
            <div className="body" data-open={isOpen}>
              <div>{item.content}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}
.body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms;
}
.body[data-open="true"] { grid-template-rows: 1fr; }
.body > div { overflow: hidden; }

Das ist in Ordnung, und der Grid-Trick ist wirklich clever. Aber beachte, was er kostet: das "use client" ganz oben. Diese Komponente wird jetzt auf jeder Seite, auf der sie erscheint, heruntergeladen, geparst und hydratisiert — und das alles, um einen einzigen Boolean zu verwalten. Für eine rein darstellende FAQ, die im React-Sinne nie interaktiv sein musste, ist das reiner Overhead.

Das native Element, das du vergessen hast

<details> und <summary> sind ein eingebautes Disclosure-Widget. Der Browser übernimmt den Öffnen/Schließen-Zustand, die Klick- und Tastaturinteraktion sowie die Barrierefreiheit — kostenlos:

<details>
  <summary>What does it cost?</summary>
  <p>Nothing — it's a native browser element.</p>
</details>

Das ist ein funktionierendes Akkordeon ohne JavaScript und ohne CSS. Klicke auf die Summary (oder drücke Enter darauf) und sie schaltet um. Der Haken war jahrelang, dass sie sofort umschaltete — es gab keine Möglichkeit, das Aufklappen zu animieren, und genau deshalb griffen Teams stattdessen zur JavaScript-Version.

Warum es sich nicht animieren ließ — bis vor Kurzem

Der Grund ist subtil. Wenn ein <details> geschlossen ist, verbirgt der Browser seinen Inhalt nicht nur visuell — er nimmt den Inhalt vollständig aus dem Rendering (ein Verbergen im Stil von content-visibility). Man kann nicht zu oder von "nicht gerendert" überblenden, und lange Zeit konnte man auch height nicht zu oder von auto überblenden. Es gab also nichts, zwischen dem man hätte animieren können.

Man probierte denselben Grid-Trick, den die JavaScript-Version verwendet, und verlagerte ihn auf das Attribut [open]:

/* Looks like it should work — but only animates OPENING. */
details > .body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms;
}
details[open] > .body { grid-template-rows: 1fr; }
/* On close, the browser hides the content the instant [open] is gone,
   so the collapse never animates — it just snaps shut. */

Es sieht richtig aus und animiert sogar das Öffnen. Aber beim Schließen versagt es: Sobald das Attribut [open] entfernt wird, verbirgt der Browser den Inhalt sofort, sodass das Einklappen nie die Chance bekommt, sich zu animieren — es klappt einfach schlagartig zu. Eine halbe Lösung ist es, was alle bei JavaScript hielt.

Die Lösung: ::details-content + interpolate-size

Drei CSS-Features, die 2024 eingeführt wurden, lösen es gemeinsam. Der Schlüssel ist ein neues Pseudo-Element, ::details-content, das genau den einklappbaren Teil eines <details> anspricht — sodass du es direkt stylen und animieren kannst:

:root {
  /* lets height/block-size transition to and from the 'auto' keyword */
  interpolate-size: allow-keywords;
}

details::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 300ms ease,
    /* keeps the content rendered while it collapses, instead of vanishing */
    content-visibility 300ms allow-discrete;
}

details[open]::details-content {
  block-size: auto;
}

Als Einheit gelesen, sind das drei Dinge, die zusammenarbeiten:

  • ::details-content gibt dir einen Zugriff auf den verborgenen Inhaltsbereich, den du zuvor gar nicht selektieren konntest.
  • interpolate-size: allow-keywords erlaubt das Animieren von block-size zwischen 0 und der intrinsischen auto-Höhe — genau das, was CSS historisch verweigerte.
  • transition-behavior: allow-discrete (indem man content-visibility in der Transition auflistet) hält den Inhalt während des Einklappens gerendert, sodass das Schließen animiert wird, statt zuzuschnappen.

Zusammen animieren sie beide Richtungen — Öffnen und Schließen — wobei die Höhe sanft zur und von der natürlichen Größe des Inhalts überblendet. Kein Messen, kein Hook, kein State.

Exklusives Akkordeon, mit einem einzigen Attribut

Die meisten Akkordeons wollen "nur eines gleichzeitig geöffnet". In der JavaScript-Version ist dafür der State openId zuständig. Nativ ist es ein einziges Attribut: Gib jedem <details> in der Gruppe denselben name, und der Browser erzwingt die Exklusivität — das Öffnen eines schließt die übrigen:

<!-- Same name = one exclusive group. Opening one closes the others. -->
<details name="faq"><summary>First</summary><p>…</p></details>
<details name="faq"><summary>Second</summary><p>…</p></details>
<details name="faq"><summary>Third</summary><p>…</p></details>

Ein wissenswerter Vorbehalt: name definiert eine exklusive Gruppe über die gesamte Seite hinweg. Wenn du also zwei unabhängige Akkordeons renderst, gib jedem seinen eigenen eindeutigen name — sonst schließt das Öffnen eines Elements im einen ein Element im anderen.

Der Chevron, ohne JavaScript

Das letzte Stück, für das man zu JS greift, ist der kleine Pfeil, der sich umdreht, wenn sich der Abschnitt öffnet. Es ist bloß ein Transform, der am Attribut [open] hängt — der Browser setzt dieses Attribut, also ist die Rotation reines CSS. Verstecke bei der Gelegenheit auch das standardmäßige Disclosure-Dreieck:

summary {
  list-style: none;            /* remove the default triangle marker */
  cursor: pointer;
}
summary::-webkit-details-marker { display: none; }  /* Safari */

summary .chevron {
  transition: transform 200ms ease;
}
details[open] summary .chevron {
  transform: rotate(180deg);   /* flip on open — pure CSS, no JS */
}

Alles zusammenfügen

Hier das Ganze — ein exklusives, animiertes Akkordeon-Element mit rotierendem Chevron und einem Abstand zwischen Frage und Antwort:

<details name="faq" class="item">
  <summary>
    <h3>How does it work?</h3>
    <svg class="chevron" aria-hidden="true"><!-- ↓ --></svg>
  </summary>
  <div class="answer">
    <p>Native open/close, exclusive grouping, smooth animation — zero JS.</p>
  </div>
</details>
:root { interpolate-size: allow-keywords; }

.item::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 300ms ease,
    content-visibility 300ms allow-discrete;
}
.item[open]::details-content { block-size: auto; }

.item[open] summary .chevron { transform: rotate(180deg); }
.answer { padding-top: 12px; }   /* gap between question and answer */

Das ist das vollständige Feature. Öffnen/Schließen, Tastaturunterstützung, Barrierefreiheit, Exklusivität, eine flüssige Animation in beide Richtungen und ein umklappender Chevron — und es liefert nicht ein einziges Byte JavaScript aus. Es wird als statisches HTML auf dem Server gerendert und braucht nichts auf dem Client, um zu funktionieren.

Browser-Unterstützung

::details-content, interpolate-size und allow-discrete sind neu (Chromium 129–131, Safari 18.x, Firefox folgt). Das klingt riskant, aber die Degradation ist die beste Art — du brauchst überhaupt kein @supports:

/* No @supports needed: browsers that don't know ::details-content simply
   ignore these rules. The <details> still opens and closes — just instantly.
   The animation is a progressive enhancement, never a requirement. */

In einem Browser, der diese Regeln nicht versteht, öffnet und schließt sich das <details> weiterhin korrekt; es tut dies einfach sofort, ohne die Animation. Die Animation ist eine progressive Verbesserung, die auf ein Element aufgesetzt wird, das bereits überall funktioniert — es gibt also keinen Fallback zu schreiben und nichts, was kaputtgehen könnte.

Das Verhalten — Öffnen, Schließen, Tastatur, Barrierefreiheit, Exklusivität — ist nativ und funktioniert überall. Die Animation ist reines CSS obendrauf. Ist ein Browser zu alt für die Animation, funktioniert das Akkordeon trotzdem; es überblendet nur nicht.

CSS vs. JavaScript, ehrlich betrachtet

AspektJS-Akkordeon (State + Hook)Natives <code>&lt;details&gt;</code> + CSS
Öffnen / SchließenReact-State + onClickIn den Browser eingebaut
Ausgeliefertes JavaScriptJa (Client-Komponente, hydratisiert)Keins
Flüssige AnimationJa (Grid / gemessene Höhe)Ja (::details-content)
Nur eines geöffnetManueller State (openId)Attribut name
Tastatur + a11ySelbst verkabeln (oder eine Bibliothek)Nativ
Funktioniert vor Hydratation / mit deaktiviertem JSNeinJa
Chevron-UmklappenKlasse per JS umgeschaltetCSS über [open]

JavaScript gewinnt weiterhin, wenn der Inhalt des Panels wirklich dynamisch ist — bei Bedarf geladen, vom App-State gesteuert oder wenn das Öffnen Nebeneffekte auslösen muss, die du in JS kontrollierst. Aber für den überwältigend häufigen Fall — eine darstellende FAQ oder Disclosure-Liste — entspricht das native Element der JS-Version Feature für Feature, ohne jegliches Bundle.

Erkenntnisse

  • Das JavaScript-Akkordeon, das alle schreiben, existiert vor allem, weil sich das native <details> nicht animieren ließ — eine Einschränkung, die jetzt weg ist.
  • ::details-content + interpolate-size: allow-keywords animieren die Höhe zwischen 0 und auto.
  • Der Trick, der das Schließen animiert (nicht nur das Öffnen), ist transition-behavior: allow-discrete auf content-visibility.
  • <details name="…"> gibt dir eine exklusive "eines gleichzeitig geöffnet"-Gruppe mit null JavaScript — verwende einen eindeutigen Namen pro Akkordeon.
  • Das Chevron-Umklappen ist ein CSS-transform, das am Attribut [open] hängt.
  • Es degradiert elegant ohne @supports: Alte Browser öffnen/schließen sofort, und die Animation ist eine reine Verbesserung.

Keines dieser Teile ist exotisch — <details> ist jahrzehntealt, und die Animations-Features sind langweiliges, gut spezifiziertes CSS. Der Wandel besteht in der Erkenntnis, dass du nicht länger zu React-State greifen musst, um ein poliertes Akkordeon zu bekommen. Lösche die Client-Komponente, liefere statisches HTML aus und lass den Browser tun, was er im Stillen schon die ganze Zeit konnte.