Skip to main content
Tilbake til bloggen
CSSHTMLAccordionFrontendPerformance

En jevn trekkspillmeny helt uten JavaScript

Alle griper etter React-state og en høydemålende hook for å bygge en trekkspillmeny. Nettleseren har levert en innebygd variant i årevis — og fra og med 2024 animeres den åpen og lukket med ren CSS. Her er hele teknikken: details[name], ::details-content, interpolate-size, og den ene egenskapen som gjør at også lukkingen animeres.

Publisert 28. juli 20269 min lesing

En trekkspillmeny er en av de mest reimplementerte widgetene på nettet. Den vanlige oppskriften: en bit React-state for hva som er åpent, en onClick for å veksle den, og litt CSS (eller en høydemålende hook) for å animere frem innholdet. Det fungerer, men det gjør en bit statisk innhold om til en klientkomponent som leverer og hydrerer JavaScript — bare for å åpne og lukke.

Nettleseren har hatt et innebygd trekkspillelement i årevis: <details>. Dens eneste historiske svakhet — at du ikke kunne animere det — er borte fra og med 2024. Denne artikkelen bygger en fullt animert, eksklusiv trekkspillmeny med en roterende pilmarkør og null JavaScript: ingen state, ingen hooks, ingen hydrering.

Trekkspillmenyen alle leverer

Her er mønsteret i sin vanligste form — React-state for det åpne elementet, en klikkhåndterer for å veksle det, og trikset grid-template-rows: 0fr → 1fr for å animere høyden uten å måle noe 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 helt greit, og grid-trikset er virkelig smart. Men merk deg hva det koster: "use client" øverst. Denne komponenten lastes nå ned, parses og hydreres på hver side den vises på — og den gjør alt dette for å håndtere en enkelt boolean. For en presentasjonell FAQ som aldri trengte å være interaktiv i React-forstand er det ren overhead.

Det innebygde elementet du glemte

<details> og <summary> er en innebygd visningswidget. Nettleseren håndterer åpen/lukket-tilstanden, klikk- og tastaturinteraksjon samt tilgjengelighet — helt gratis:

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

Det er en fungerende trekkspillmeny uten JavaScript og uten CSS. Klikk (eller trykk Enter på) sammendraget, så veksler den. Haken var, i årevis, at den vekslet umiddelbart — det fantes ingen måte å animere frem innholdet på, som er nøyaktig grunnen til at team i stedet grep etter JavaScript-varianten.

Hvorfor den ikke kunne animeres — inntil nylig

Grunnen er subtil. Når en <details> er lukket, skjuler ikke nettleseren bare innholdet visuelt — den tar innholdet helt ut av renderingen (en skjuling i stil med content-visibility). Du kan ikke gå over til eller fra "ikke rendret", og i lang tid kunne du heller ikke gå over height til eller fra auto. Så det fantes ingenting å animere mellom.

Folk prøvde det samme grid-trikset som JavaScript-varianten bruker, og flyttet det til attributtet [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. */

Det ser riktig ut, og det animerer til og med åpningen. Men det svikter ved lukking: i det øyeblikket attributtet [open] fjernes, skjuler nettleseren innholdet umiddelbart, så sammenfoldingen får aldri sjansen til å animeres — den bare smekker igjen. En halv løsning er det som holdt alle på JavaScript.

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

Tre CSS-funksjoner som landet i 2024 løser det sammen. Nøkkelen er et nytt pseudoelement, ::details-content, som peker ut nøyaktig den sammenfoldbare delen av en <details> — slik at du 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;
}

Lest som en helhet er det tre ting som samarbeider:

  • ::details-content gir deg et grep om det skjulte innholdsområdet, som du tidligere ikke kunne selektere i det hele tatt.
  • interpolate-size: allow-keywords tillater animering av block-size mellom 0 og den intrinsiske auto-høyden — det CSS historisk nektet å gjøre.
  • transition-behavior: allow-discrete (ved å liste content-visibility i overgangen) holder innholdet rendret gjennom hele sammenfoldingen, slik at lukkingen animeres i stedet for å smekke igjen.

Sammen animerer de i begge retninger — åpne og lukke — med høyden som glir jevnt til og fra innholdets naturlige størrelse. Ingen måling, ingen hook, ingen state.

Eksklusiv trekkspillmeny, med ett enkelt attributt

De fleste trekkspillmenyer vil ha "bare én åpen om gangen". I JavaScript-varianten er det det openId-staten er til for. Innebygd er det ett enkelt attributt: gi hver <details> i gruppen samme name, så håndhever nettleseren eksklusiviteten — når én åpnes, lukkes 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>

Ett forbehold verdt å vite: name definerer en eksklusiv gruppe på tvers av hele siden, så hvis du rendrer to uavhengige trekkspillmenyer, bør du gi hver sitt eget unike name — ellers vil åpning av et element i den ene lukke et element i den andre.

Pilmarkøren, helt uten JavaScript

Den siste biten folk griper etter JS for, er den lille pilen som vippes når seksjonen åpnes. Det er bare en transform koblet til attributtet [open] — nettleseren setter det attributtet, så rotasjonen er ren CSS. Benytt også anledningen til å skjule den forvalgte visningstrekanten her:

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

Å sette det sammen

Her er hele greia — et eksklusivt, animert trekkspillelement med en roterende pilmarkør og et mellomrom mellom spørsmå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 den komplette funksjonen. Åpne/lukke, tastaturstøtte, tilgjengelighet, eksklusivitet, en jevn toveisanimasjon og en pilmarkør som vippes — og den leverer ikke en eneste byte JavaScript. Den rendres som statisk HTML på serveren og trenger ingenting på klienten for å fungere.

Nettleserstøtte

::details-content, interpolate-size og allow-discrete er ferske (Chromium 129–131, Safari 18.x, Firefox på vei). Det høres risikabelt ut, men degraderingen er av beste sort — du trenger ikke @supports i det hele tatt:

/* 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 nettleser som ikke forstår disse reglene, åpnes og lukkes <details> fortsatt riktig; den gjør det bare umiddelbart, uten animasjonen. Animasjonen er en progressiv forbedring lagt oppå et element som allerede fungerer overalt — så det finnes ingen fallback å skrive og ingenting som kan gå i stykker.

Atferden — åpne, lukke, tastatur, tilgjengelighet, eksklusivitet — er innebygd og fungerer overalt. Animasjonen er ren CSS oppå. Hvis en nettleser er for gammel for animasjonen, fungerer trekkspillmenyen likevel; den glir bare ikke jevnt.

CSS kontra JavaScript, ærlig talt

HensynJS-trekkspillmeny (state + hook)Innebygd <code>&lt;details&gt;</code> + CSS
Åpne / lukkeReact-state + onClickInnebygd i nettleseren
Levert JavaScriptJa (klientkomponent, hydrerer)Ingen
Jevn animasjonJa (grid / målt høyde)Ja (::details-content)
Bare én åpenManuell state (openId)Attributtet name
Tastatur + a11yDu kobler det selv (eller et bibliotek)Innebygd
Fungerer før hydrering / med JS avNeiJa
Pilmarkørens vippKlasse vekslet av JSCSS på [open]

JavaScript vinner fortsatt når panelets innhold er genuint dynamisk — lastet på forespørsel, drevet av app-state, eller når åpning må utløse sideeffekter du styrer i JS. Men for det overveldende vanlige tilfellet — en presentasjonell FAQ eller visningsliste — matcher det innebygde elementet JS-varianten funksjon for funksjon, uten noe av bunten.

Oppsummering

  • JavaScript-trekkspillmenyen alle skriver finnes stort sett fordi innebygd <details> ikke kunne animeres — en begrensning som nå er borte.
  • ::details-content + interpolate-size: allow-keywords animerer høyden mellom 0 og auto.
  • Trikset som får lukkingen til å animeres (ikke bare åpningen) er transition-behavior: allow-discretecontent-visibility.
  • <details name="…"> gir deg en eksklusiv "én åpen om gangen"-gruppe helt uten JavaScript — bruk et unikt name per trekkspillmeny.
  • Pilmarkørens vipp er en CSS-transform koblet til attributtet [open].
  • Den degraderes elegant uten @supports: gamle nettlesere åpner/lukker umiddelbart, og animasjonen er en ren forbedring.

Ingen av disse bitene er eksotiske — <details> er tiår gammelt, og animasjonsfunksjonene er kjedelig, velspesifisert CSS. Skiftet ligger i å innse at du ikke lenger må gripe etter React-state for å få en polert trekkspillmeny. Slett klientkomponenten, lever statisk HTML, og la nettleseren gjøre det den i det stille har kunnet hele tiden.