Skip to main content
Tilbage til bloggen
CSSHTMLAccordionFrontendPerformance

En jævn accordion helt uden JavaScript

Alle griber efter React-state og en højdemålende hook for at bygge en accordion. Browseren har haft en indbygget af slagsen i årevis — og siden 2024 animerer den både åbning og lukning med ren CSS. Her er hele teknikken: details[name], ::details-content, interpolate-size og den ene egenskab, der også får lukningen til at animere.

Udgivet 28. juli 20269 min læsning

En accordion er en af de mest genimplementerede widgets på nettet. Den sædvanlige opskrift: et stykke React-state til det, der er åbent, en onClick til at slå den til og fra, og lidt CSS (eller en højdemålende hook) til at animere afsløringen. Det virker, men det gør et stykke statisk indhold om til en klientkomponent, der sender og hydrerer JavaScript — bare for at åbne og lukke.

Browseren har haft et indbygget accordion-element i årevis: <details>. Dets ene historiske svaghed — at man ikke kunne animere det — er væk siden 2024. Denne artikel bygger en fuldt animeret, eksklusiv accordion med en roterende chevron og helt uden JavaScript: ingen state, ingen hooks, ingen hydrering.

Den accordion, alle sender

Her er mønsteret i sin mest almindelige form — React-state til det åbne element, en klik-handler til at slå den til og fra, og grid-template-rows: 0fr → 1fr-tricket, der animerer højden uden at måle noget som helst:

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; }

Det er fint, og grid-tricket er faktisk smart. Men læg mærke til, hvad det koster: "use client" øverst. Denne komponent bliver nu hentet, parset og hydreret på hver eneste side, hvor den optræder — og det gør den alt sammen for at styre en enkelt boolean. For en præsentationsorienteret FAQ, der aldrig behøvede at være interaktiv i React-forstand, er det ren overhead.

Det indbyggede element, du glemte

<details> og <summary> er en indbygget disclosure-widget. Browseren håndterer åbn/luk-tilstanden, klik- og tastaturinteraktion samt tilgængelighed — gratis:

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

Det er en fungerende accordion helt uden JavaScript og uden CSS. Klik på (eller tryk Enter på) summary, og den slår til og fra. Hagen ved det var i årevis, at den slog til og fra øjeblikkeligt — der var ingen måde at animere afsløringen på, hvilket er præcis derfor, teams greb efter JavaScript-versionen i stedet.

Hvorfor den ikke kunne animere — indtil for nylig

Grunden er subtil. Når et <details> er lukket, skjuler browseren ikke bare indholdet visuelt — den tager indholdet helt ud af renderingen (en skjulning i stil med content-visibility). Man kan ikke transitionere til eller fra "ikke renderet", og i lang tid kunne man heller ikke transitionere height til eller fra auto. Så der var ikke noget at animere imellem.

Folk prøvede det samme grid-trick, som JavaScript-versionen bruger, og flyttede det over på [open]-attributten:

/* 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. */

Det ser rigtigt ud, og det animerer endda åbningen. Men det fejler ved lukning: i det øjeblik [open]-attributten fjernes, skjuler browseren indholdet med det samme, så sammenklapningen får aldrig chancen for at animere — den smækker bare i. En halv løsning var det, der holdt alle på JavaScript.

Løsningen: ::details-content + interpolate-size

Tre CSS-funktioner, der landede i 2024, løser det tilsammen. Nøglen er et nyt pseudoelement, ::details-content, der rammer præcis den sammenklappelige del af et <details> — så man kan style og animere den direkte:

: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;
}

Læst som en helhed er det tre ting, der samarbejder:

  • ::details-content giver dig et greb om det skjulte indholdsområde, som du tidligere slet ikke kunne selektere.
  • interpolate-size: allow-keywords tillader at animere block-size mellem 0 og den intrinsiske auto-højde — netop det, CSS historisk har nægtet at gøre.
  • transition-behavior: allow-discrete (ved at liste content-visibility i transitionen) holder indholdet renderet gennem sammenklapningen, så lukningen animerer i stedet for at smække i.

Tilsammen animerer de begge retninger — åbning og lukning — hvor højden glider blødt til og fra indholdets naturlige størrelse. Ingen måling, ingen hook, ingen state.

Eksklusiv accordion, med én attribut

De fleste accordions vil have "kun én åben ad gangen". I JavaScript-versionen er det, hvad openId-state er til for. Indbygget er det en enkelt attribut: giv hvert <details> i gruppen det samme name, så håndhæver browseren eksklusiviteten — at åbne ét lukker resten:

<!-- 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>

Ét forbehold værd at kende: name definerer en eksklusiv gruppe på tværs af hele siden, så hvis du renderer to uafhængige accordions, skal du give hver sit eget unikke name — ellers vil et element, du åbner i den ene, lukke et element i den anden.

Chevronen, helt uden JavaScript

Det sidste, folk griber efter JS til, er den lille pil, der vender, når sektionen åbnes. Det er bare en transform styret af [open]-attributten — browseren sætter den attribut, så rotationen er ren CSS. Skjul også den indbyggede disclosure-trekant, mens du er i gang:

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 */
}

Alt sat sammen

Her er det hele — et eksklusivt, animeret accordion-element med en roterende chevron og et mellemrum mellem spørgsmål og svar:

<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 */

Det er hele funktionen. Åbn/luk, tastaturunderstøttelse, tilgængelighed, eksklusivitet, en jævn tovejsanimation og en vendende chevron — og den sender ikke en eneste byte JavaScript. Den renderes som statisk HTML på serveren og behøver ingenting på klienten for at virke.

Browserunderstøttelse

::details-content, interpolate-size og allow-discrete er nye (Chromium 129–131, Safari 18.x, Firefox følger efter). Det lyder risikabelt, men degraderingen er af den bedste slags — du behøver slet ikke @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. */

I en browser, der ikke forstår disse regler, åbner og lukker <details> stadig korrekt; den gør det bare øjeblikkeligt, uden animationen. Animationen er en progressiv forbedring lagt oven på et element, der allerede virker overalt — så der er ingen fallback at skrive og ingenting at ødelægge.

Adfærden — åbne, lukke, tastatur, tilgængelighed, eksklusivitet — er indbygget og virker overalt. Animationen er ren CSS ovenpå. Er en browser for gammel til animationen, virker accordionen stadig; den glider bare ikke.

CSS vs. JavaScript, helt ærligt

HensynJS-accordion (state + hook)Indbygget <code>&lt;details&gt;</code> + CSS
Åbn / lukReact-state + onClickIndbygget i browseren
JavaScript sendtJa (klientkomponent, hydrerer)Ingen
Jævn animationJa (grid / målt højde)Ja (::details-content)
Kun én åbenManuel state (openId)name-attribut
Tastatur + a11yDu kobler det op (eller et bibliotek)Indbygget
Virker før hydrering / med JS slået fraNejJa
Chevron-vendingKlasse slået til/fra af JSCSS på [open]

JavaScript vinder stadig, når panelets indhold er ægte dynamisk — indlæst on demand, drevet af app-state, eller når åbning skal udløse sideeffekter, du styrer i JS. Men for det overvældende almindelige tilfælde — en præsentationsorienteret FAQ eller disclosure-liste — matcher det indbyggede element JS-versionen funktion for funktion, uden noget af bundlen.

Til at tage med

  • Den JavaScript-accordion, alle skriver, findes mest, fordi indbygget <details> ikke kunne animere — en begrænsning, der nu er væk.
  • ::details-content + interpolate-size: allow-keywords animerer højden mellem 0 og auto.
  • Tricket, der får lukningen til at animere (ikke bare åbningen), er transition-behavior: allow-discretecontent-visibility.
  • <details name="…"> giver dig en eksklusiv "én åben ad gangen"-gruppe helt uden JavaScript — brug et unikt name pr. accordion.
  • Chevron-vendingen er en CSS-transform styret af [open]-attributten.
  • Den degraderer elegant uden @supports: gamle browsere åbner/lukker øjeblikkeligt, og animationen er en ren forbedring.

Ingen af disse dele er eksotiske — <details> er årtier gammelt, og animationsfunktionerne er kedelig, veldefineret CSS. Skiftet ligger i at indse, at man ikke længere behøver at gribe efter React-state for at få en poleret accordion. Slet klientkomponenten, send statisk HTML, og lad browseren gøre det, den i al stilhed har kunnet hele tiden.