En mjuk dragspelsmeny helt utan JavaScript
Alla griper efter React-state och en höjdmätande hook för att bygga en dragspelsmeny. Webbläsaren har levererat en inbyggd variant i åratal — och sedan 2024 animeras den öppen och stängd med ren CSS. Här är hela tekniken: details[name], ::details-content, interpolate-size, och den enda egenskap som gör att även stängningen animeras.
En dragspelsmeny är en av de mest omimplementerade widgetarna på webben. Det vanliga receptet: en bit React-state för vad som är öppet, en onClick för att växla det, och lite CSS (eller en höjdmätande hook) för att animera fram innehållet. Det fungerar, men det förvandlar en bit statiskt innehåll till en klientkomponent som levererar och hydratiserar JavaScript — bara för att öppna och stänga.
Webbläsaren har haft ett inbyggt dragspelselement i åratal: <details>. Dess enda historiska svaghet — att man inte kunde animera det — är borta sedan 2024. Den här artikeln bygger en fullt animerad, exklusiv dragspelsmeny med en roterande pilmarkör och noll JavaScript: inget state, inga hooks, ingen hydratisering.
Dragspelsmenyn som alla levererar
Här är mönstret i sin vanligaste form — React-state för det öppna objektet, en klickhanterare för att växla det, och tricket grid-template-rows: 0fr → 1fr för att animera höjden utan att mäta någonting:
"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 är helt okej, och grid-tricket är faktiskt smart. Men notera vad det kostar: "use client" högst upp. Den här komponenten laddas nu ner, parsas och hydratiseras på varje sida där den förekommer — och den gör allt detta för att hantera en enda boolean. För en presentationell FAQ som aldrig behövde vara interaktiv i React-mening är det ren omkostnad.
Det inbyggda elementet du glömde
<details> och <summary> är en inbyggd visningswidget. Webbläsaren sköter öppna/stängt-tillståndet, klick- och tangentbordsinteraktion samt tillgänglighet — helt gratis:
<details>
<summary>What does it cost?</summary>
<p>Nothing — it's a native browser element.</p>
</details>Det är en fungerande dragspelsmeny utan JavaScript och utan CSS. Klicka (eller tryck Enter på) sammanfattningen så växlar den. Haken var, i åratal, att den växlade omedelbart — det fanns inget sätt att animera fram innehållet, vilket är exakt varför team i stället grep efter JavaScript-varianten.
Varför den inte kunde animeras — förrän nyligen
Anledningen är subtil. När en <details> är stängd döljer webbläsaren inte bara innehållet visuellt — den tar bort innehållet från renderingen helt (en döljning i stil med content-visibility). Man kan inte övergå till eller från "inte renderat", och under lång tid kunde man inte heller övergå height till eller från auto. Så det fanns ingenting att animera mellan.
Folk provade samma grid-trick som JavaScript-varianten använder och flyttade det till attributet [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 rätt ut, och det animerar till och med öppningen. Men det misslyckas vid stängning: i samma ögonblick som attributet [open] tas bort döljer webbläsaren innehållet omedelbart, så ihopfällningen får aldrig chansen att animeras — den bara slår igen. En halv lösning är vad som höll kvar alla på JavaScript.
Lösningen: ::details-content + interpolate-size
Tre CSS-funktioner som landade 2024 löser det tillsammans. Nyckeln är ett nytt pseudoelement, ::details-content, som pekar ut exakt den ihopfällbara delen av en <details> — så att du kan styla och animera den direkt:
: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 helhet är det tre saker som samverkar:
::details-contentger dig ett grepp om det dolda innehållsområdet, som du tidigare inte kunde selektera alls.interpolate-size: allow-keywordstillåter animering avblock-sizemellan0och den intrinsiskaauto-höjden — det som CSS historiskt vägrade göra.transition-behavior: allow-discrete(genom att listacontent-visibilityi övergången) håller innehållet renderat under hela ihopfällningen, så att stängningen animeras i stället för att slå igen.
Tillsammans animerar de i båda riktningarna — öppna och stänga — med höjden som mjukt övergår till och från innehållets naturliga storlek. Ingen mätning, ingen hook, inget state.
Exklusiv dragspelsmeny, med ett enda attribut
De flesta dragspelsmenyer vill ha "bara en öppen åt gången". I JavaScript-varianten är det vad openId-statet är till för. Inbyggt är det ett enda attribut: ge varje <details> i gruppen samma name, så upprätthåller webbläsaren exklusiviteten — när en öppnas stängs 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>En sak värd att känna till: name definierar en exklusiv grupp över hela sidan, så om du renderar två oberoende dragspelsmenyer bör du ge var och en sitt eget unika name — annars kommer öppnandet av ett objekt i den ena att stänga ett objekt i den andra.
Pilmarkören, helt utan JavaScript
Den sista biten folk griper efter JS för är den lilla pilen som vänds när sektionen öppnas. Det är bara en transform kopplad till attributet [open] — webbläsaren sätter det attributet, så rotationen är ren CSS. Passa också på att dölja den förvalda visningstriangeln här:
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 */
}Att sätta ihop allt
Här är hela grejen — ett exklusivt, animerat dragspelsobjekt med en roterande pilmarkör och ett mellanrum mellan fråga och 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 är den kompletta funktionen. Öppna/stänga, tangentbordsstöd, tillgänglighet, exklusivitet, en mjuk tvåvägsanimering och en pilmarkör som vänds — och den levererar inte en enda byte JavaScript. Den renderas som statisk HTML på servern och behöver ingenting på klienten för att fungera.
Webbläsarstöd
::details-content, interpolate-size och allow-discrete är färska (Chromium 129–131, Safari 18.x, Firefox på gång). Det låter riskabelt, men degraderingen är av bästa sorten — du behöver inte @supports alls:
/* 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 webbläsare som inte förstår dessa regler öppnas och stängs <details> fortfarande korrekt; den gör det bara omedelbart, utan animeringen. Animeringen är en progressiv förbättring lagd ovanpå ett element som redan fungerar överallt — så det finns ingen fallback att skriva och ingenting att gå sönder.
Beteendet — öppna, stänga, tangentbord, tillgänglighet, exklusivitet — är inbyggt och fungerar överallt. Animeringen är ren CSS ovanpå. Om en webbläsare är för gammal för animeringen fungerar dragspelsmenyn ändå; den bara övergår inte mjukt.
CSS kontra JavaScript, ärligt talat
| Aspekt | JS-dragspelsmeny (state + hook) | Inbyggd <code><details></code> + CSS |
|---|---|---|
| Öppna / stänga | React-state + onClick | Inbyggt i webbläsaren |
| Levererad JavaScript | Ja (klientkomponent, hydratiserar) | Ingen |
| Mjuk animering | Ja (grid / uppmätt höjd) | Ja (::details-content) |
| Bara en öppen | Manuellt state (openId) | Attributet name |
| Tangentbord + a11y | Du kopplar det själv (eller ett bibliotek) | Inbyggt |
| Fungerar före hydratisering / med JS av | Nej | Ja |
| Pilmarkörens vändning | Klass växlad av JS | CSS på [open] |
JavaScript vinner fortfarande när panelens innehåll är genuint dynamiskt — laddat på begäran, drivet av app-state, eller när öppnandet måste utlösa sidoeffekter som du styr i JS. Men för det överväldigande vanliga fallet — en presentationell FAQ eller visningslista — matchar det inbyggda elementet JS-varianten funktion för funktion, utan någon del av bundeln.
Sammanfattning
- JavaScript-dragspelsmenyn som alla skriver finns mest för att inbyggd
<details>inte kunde animeras — en begränsning som nu är borta. ::details-content+interpolate-size: allow-keywordsanimerar höjden mellan0ochauto.- Tricket som får stängningen att animeras (inte bara öppningen) är
transition-behavior: allow-discretepåcontent-visibility. <details name="…">ger dig en exklusiv "en öppen åt gången"-grupp helt utan JavaScript — använd ett unikt name per dragspelsmeny.- Pilmarkörens vändning är en CSS-
transformkopplad till attributet[open]. - Den degraderas elegant utan
@supports: gamla webbläsare öppnar/stänger omedelbart, och animeringen är en ren förbättring.
Ingen av dessa bitar är exotisk — <details> är decennier gammalt, och animeringsfunktionerna är trist, väl specificerad CSS. Skiftet ligger i insikten att du inte längre behöver gripa efter React-state för att få en polerad dragspelsmeny. Radera klientkomponenten, leverera statisk HTML, och låt webbläsaren göra det den i tysthet har kunnat hela tiden.