24 / 25
All 25 in 25 Tailwind CSS FAQ Accordion
- 01Tailwind Peer-Checked FAQ Accordion with No JavaScript
- 02Tailwind Native Details/Summary FAQ with Custom Styled Icons
- 03Tailwind Exclusive Accordion — Only One FAQ Open at a Time
- 04Tailwind Multi-Open FAQ Accordion with a Live CSS Counter
- 05Tailwind CSS FAQ Accordion Template (Free, Copy-Paste)
- 06Tailwind Dark Mode FAQ Accordion with light-dark() and No .dark Class
- 07Tailwind Glassmorphism FAQ Accordion with Frosted Backdrop Blur
- 08Tailwind Minimalist E-Commerce FAQ Accordion for Product Pages
- 09Tailwind Neumorphic Soft-UI FAQ Accordion Cards
- 10Tailwind Animate Height Auto FAQ with grid-rows 0fr to 1fr
- 11Tailwind FAQ Chevron Rotate 180 Degrees on Toggle
- 12Tailwind Animated Plus to Minus Icon Morph FAQ Accordion
- 13Tailwind Spring Bounce FAQ Accordion with linear() Easing
- 14Tailwind Two-Column FAQ Accordion Grid with Independent Columns
- 15Tailwind Horizontal FAQ Accordion with Sideways Expanding Panels
- 16Tailwind Categorised FAQ Help Centre with a Sticky Sidebar Menu
- 17Tailwind Card-Style FAQ Panels with a Layered Shadow Elevation System
- 18Tailwind FAQ Accordion with a Live Search Bar Filter
- 19Tailwind Expand All and Collapse All FAQ Accordion Buttons
- 20Tailwind FAQ with a Still Have Questions Contact CTA Card
- 21Tailwind Accessible ARIA FAQ Accordion with Keyboard Navigation
- 22Tailwind FAQ with FAQPage JSON-LD Schema for Rich Snippets and AI Search
- 23Bootstrap 5 Accordion Rebuilt in Tailwind (and the JavaScript You Delete)
- 24Tailwind FAQ Component in React, Vue, Svelte and Astro (One Port Recipe)
- 25Tailwind FAQ with Voice Search Using the Speech Recognition API
Tailwind FAQ Component in React, Vue, Svelte and Astro (One Port Recipe)
One accordion, five codebases: a working native-<details> FAQ up top, and beneath it a pure-CSS tab viewer showing the same component in React, Vue 3, Svelte 5 and Astro. The thesis is stronger in Tailwind than it was in CSS — because the class list travels with the markup, the port really is just a data-mapping shell, with no stylesheet to move at all.
Complete Tailwind CSS landing page templates in HTML, React, Next.js and Vue · from $49
Cruip · Affiliate link
The code
<section class="tfa-24 w-full min-h-svh grid place-items-center bg-tfa24-bg font-tfa24-sans text-tfa24-ink px-5 py-12 bg-[radial-gradient(900px_500px_at_85%_0%,var(--color-tfa24-glow),transparent_60%)]">
<div class="w-[min(700px,100%)]">
<header>
<h1 class="text-[clamp(28px,5vw,40px)] font-extrabold leading-[1.08] tracking-[-0.025em]">One accordion, five codebases</h1>
<p class="mt-3 max-w-[56ch] text-[15px] leading-relaxed text-tfa24-mut">The living component, then the same 15-line shell per framework. Behaviour stays in <code class="rounded bg-tfa24-tint px-1.5 py-0.5 font-tfa24-mono text-[0.87em] text-tfa24-acc"><details></code>, styling stays in the class list, frameworks only map data.</p>
</header>
<div data-panel class="mt-6 flex flex-col gap-2.5">
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge" open>
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Why no accordion library?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">Because the platform ships one. The details element gives you state, keyboard support and semantics; a library would re-implement all three in 40kB and still need this class list.</p>
</details>
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge">
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Where does state live?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">In the DOM open attribute — so SSR, static builds and server components render working accordions with zero hydration. Lift state into the framework only when a feature actually demands it.</p>
</details>
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge">
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Does the styling need porting?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">No, and that is new: utilities travel inside the markup. The CSS version still had to get a stylesheet into four different scoping systems.</p>
</details>
</div>
<div class="mt-5 overflow-hidden rounded-2xl border border-tfa24-edge bg-tfa24-viewer">
<div class="flex flex-wrap px-2.5 pt-2.5">
<input type="radio" name="tfa24-tab" id="tfa24-react" class="peer/tfa24-react absolute opacity-0 pointer-events-none" checked>
<label for="tfa24-react" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-react:outline-2 peer-focus-visible/tfa24-react:-outline-offset-2 peer-focus-visible/tfa24-react:outline-tfa24-acc peer-checked/tfa24-react:bg-tfa24-panel peer-checked/tfa24-react:text-tfa24-acc">React</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-react:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code">function Faq({ items }) {
return (
<div className="flex flex-col gap-2.5">
{items.map(it => (
<details key={it.q} className="group rounded-2xl border"
{...(it.open ? { open: true } : {})}>
<summary className="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i className="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]" />
</summary>
<p className="px-5 pb-4 text-sm">{it.a}</p>
</details>
))}
</div>
);
} // no useState — the DOM holds the state</code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-vue" class="peer/tfa24-vue absolute opacity-0 pointer-events-none">
<label for="tfa24-vue" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-vue:outline-2 peer-focus-visible/tfa24-vue:-outline-offset-2 peer-focus-visible/tfa24-vue:outline-tfa24-acc peer-checked/tfa24-vue:bg-tfa24-panel peer-checked/tfa24-vue:text-tfa24-acc">Vue 3</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-vue:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code"><script setup>
defineProps({ items: Array })
</script>
<template>
<div class="flex flex-col gap-2.5">
<details v-for="it in items" :key="it.q" :open="it.open"
class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{{ it.q }}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]" />
</summary>
<p class="px-5 pb-4 text-sm">{{ it.a }}</p>
</details>
</div>
</template></code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-svelte" class="peer/tfa24-svelte absolute opacity-0 pointer-events-none">
<label for="tfa24-svelte" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-svelte:outline-2 peer-focus-visible/tfa24-svelte:-outline-offset-2 peer-focus-visible/tfa24-svelte:outline-tfa24-acc peer-checked/tfa24-svelte:bg-tfa24-panel peer-checked/tfa24-svelte:text-tfa24-acc">Svelte 5</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-svelte:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code"><script>
let { items } = $props();
</script>
<div class="flex flex-col gap-2.5">
{#each items as it (it.q)}
<details open={it.open} class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]"></i>
</summary>
<p class="px-5 pb-4 text-sm">{it.a}</p>
</details>
{/each}
</div></code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-astro" class="peer/tfa24-astro absolute opacity-0 pointer-events-none">
<label for="tfa24-astro" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-astro:outline-2 peer-focus-visible/tfa24-astro:-outline-offset-2 peer-focus-visible/tfa24-astro:outline-tfa24-acc peer-checked/tfa24-astro:bg-tfa24-panel peer-checked/tfa24-astro:text-tfa24-acc">Astro</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-astro:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code">---
// Faq.astro — renders to plain HTML, ships zero JS
const { items } = Astro.props;
---
<div class="flex flex-col gap-2.5">
{items.map(it => (
<details open={it.open} class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]"></i>
</summary>
<p class="px-5 pb-4 text-sm">{it.a}</p>
</details>
))}
</div>
<!-- no client: directive — none needed --></code></pre>
</div>
</div>
</div>
<p class="mt-5 text-[12.5px] text-tfa24-mut">The tab viewer above is the radio hack from demo 01, with named peers. Patterns compound.</p>
</div>
</section><section class="tfa-24 w-full min-h-svh grid place-items-center bg-tfa24-bg font-tfa24-sans text-tfa24-ink px-5 py-12 bg-[radial-gradient(900px_500px_at_85%_0%,var(--color-tfa24-glow),transparent_60%)]">
<div class="w-[min(700px,100%)]">
<header>
<h1 class="text-[clamp(28px,5vw,40px)] font-extrabold leading-[1.08] tracking-[-0.025em]">One accordion, five codebases</h1>
<p class="mt-3 max-w-[56ch] text-[15px] leading-relaxed text-tfa24-mut">The living component, then the same 15-line shell per framework. Behaviour stays in <code class="rounded bg-tfa24-tint px-1.5 py-0.5 font-tfa24-mono text-[0.87em] text-tfa24-acc"><details></code>, styling stays in the class list, frameworks only map data.</p>
</header>
<div data-panel class="mt-6 flex flex-col gap-2.5">
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge" open>
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Why no accordion library?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">Because the platform ships one. The details element gives you state, keyboard support and semantics; a library would re-implement all three in 40kB and still need this class list.</p>
</details>
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge">
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Where does state live?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">In the DOM open attribute — so SSR, static builds and server components render working accordions with zero hydration. Lift state into the framework only when a feature actually demands it.</p>
</details>
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge">
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Does the styling need porting?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">No, and that is new: utilities travel inside the markup. The CSS version still had to get a stylesheet into four different scoping systems.</p>
</details>
</div>
<div class="mt-5 overflow-hidden rounded-2xl border border-tfa24-edge bg-tfa24-viewer">
<div class="flex flex-wrap px-2.5 pt-2.5">
<input type="radio" name="tfa24-tab" id="tfa24-react" class="peer/tfa24-react absolute opacity-0 pointer-events-none" checked>
<label for="tfa24-react" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-react:outline-2 peer-focus-visible/tfa24-react:-outline-offset-2 peer-focus-visible/tfa24-react:outline-tfa24-acc peer-checked/tfa24-react:bg-tfa24-panel peer-checked/tfa24-react:text-tfa24-acc">React</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-react:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code">function Faq({ items }) {
return (
<div className="flex flex-col gap-2.5">
{items.map(it => (
<details key={it.q} className="group rounded-2xl border"
{...(it.open ? { open: true } : {})}>
<summary className="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i className="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]" />
</summary>
<p className="px-5 pb-4 text-sm">{it.a}</p>
</details>
))}
</div>
);
} // no useState — the DOM holds the state</code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-vue" class="peer/tfa24-vue absolute opacity-0 pointer-events-none">
<label for="tfa24-vue" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-vue:outline-2 peer-focus-visible/tfa24-vue:-outline-offset-2 peer-focus-visible/tfa24-vue:outline-tfa24-acc peer-checked/tfa24-vue:bg-tfa24-panel peer-checked/tfa24-vue:text-tfa24-acc">Vue 3</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-vue:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code"><script setup>
defineProps({ items: Array })
</script>
<template>
<div class="flex flex-col gap-2.5">
<details v-for="it in items" :key="it.q" :open="it.open"
class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{{ it.q }}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]" />
</summary>
<p class="px-5 pb-4 text-sm">{{ it.a }}</p>
</details>
</div>
</template></code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-svelte" class="peer/tfa24-svelte absolute opacity-0 pointer-events-none">
<label for="tfa24-svelte" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-svelte:outline-2 peer-focus-visible/tfa24-svelte:-outline-offset-2 peer-focus-visible/tfa24-svelte:outline-tfa24-acc peer-checked/tfa24-svelte:bg-tfa24-panel peer-checked/tfa24-svelte:text-tfa24-acc">Svelte 5</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-svelte:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code"><script>
let { items } = $props();
</script>
<div class="flex flex-col gap-2.5">
{#each items as it (it.q)}
<details open={it.open} class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]"></i>
</summary>
<p class="px-5 pb-4 text-sm">{it.a}</p>
</details>
{/each}
</div></code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-astro" class="peer/tfa24-astro absolute opacity-0 pointer-events-none">
<label for="tfa24-astro" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-astro:outline-2 peer-focus-visible/tfa24-astro:-outline-offset-2 peer-focus-visible/tfa24-astro:outline-tfa24-acc peer-checked/tfa24-astro:bg-tfa24-panel peer-checked/tfa24-astro:text-tfa24-acc">Astro</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-astro:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code">---
// Faq.astro — renders to plain HTML, ships zero JS
const { items } = Astro.props;
---
<div class="flex flex-col gap-2.5">
{items.map(it => (
<details open={it.open} class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]"></i>
</summary>
<p class="px-5 pb-4 text-sm">{it.a}</p>
</details>
))}
</div>
<!-- no client: directive — none needed --></code></pre>
</div>
</div>
</div>
<p class="mt-5 text-[12.5px] text-tfa24-mut">The tab viewer above is the radio hack from demo 01, with named peers. Patterns compound.</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-tfa24-bg: #1e1a26;
--color-tfa24-bg: oklch(0.21 0.022 285);
--color-tfa24-panel: #262030;
--color-tfa24-panel: oklch(0.25 0.026 285);
--color-tfa24-viewer: #191521;
--color-tfa24-viewer: oklch(0.18 0.02 285);
--color-tfa24-glow: #3a2444;
--color-tfa24-glow: oklch(0.3 0.06 310 / 0.5);
--color-tfa24-ink: #f3f0f7;
--color-tfa24-ink: oklch(0.96 0.006 280);
--color-tfa24-mut: #bab2c6;
--color-tfa24-mut: oklch(0.79 0.018 282);
--color-tfa24-edge: #3a3348;
--color-tfa24-edge: oklch(0.35 0.028 284);
--color-tfa24-accedge: #9a6cc0;
--color-tfa24-accedge: oklch(0.62 0.11 310);
--color-tfa24-tint: #33244a;
--color-tfa24-tint: oklch(0.29 0.055 310);
--color-tfa24-acc: #dda8f0;
--color-tfa24-acc: oklch(0.82 0.13 310);
--color-tfa24-code: #ded2ec;
--color-tfa24-code: oklch(0.88 0.03 300);
--font-tfa24-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-tfa24-mono: 'JetBrains Mono', ui-monospace, monospace;
}
.tfa-24 {
width: 100%;
min-height: 100svh;
}
.tfa-24 [data-panel] {
interpolate-size: allow-keywords;
}
@supports (interpolate-size: allow-keywords) {
.tfa-24 details::details-content {
height: 0;
overflow: clip;
opacity: 0;
transition: height .35s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .35s allow-discrete;
}
.tfa-24 details[open]::details-content {
height: auto;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tfa-24 details::details-content {
transition-duration: 1ms;
}
}@import "tailwindcss";
@theme {
--color-tfa24-bg: #1e1a26;
--color-tfa24-bg: oklch(0.21 0.022 285);
--color-tfa24-panel: #262030;
--color-tfa24-panel: oklch(0.25 0.026 285);
--color-tfa24-viewer: #191521;
--color-tfa24-viewer: oklch(0.18 0.02 285);
--color-tfa24-glow: #3a2444;
--color-tfa24-glow: oklch(0.3 0.06 310 / 0.5);
--color-tfa24-ink: #f3f0f7;
--color-tfa24-ink: oklch(0.96 0.006 280);
--color-tfa24-mut: #bab2c6;
--color-tfa24-mut: oklch(0.79 0.018 282);
--color-tfa24-edge: #3a3348;
--color-tfa24-edge: oklch(0.35 0.028 284);
--color-tfa24-accedge: #9a6cc0;
--color-tfa24-accedge: oklch(0.62 0.11 310);
--color-tfa24-tint: #33244a;
--color-tfa24-tint: oklch(0.29 0.055 310);
--color-tfa24-acc: #dda8f0;
--color-tfa24-acc: oklch(0.82 0.13 310);
--color-tfa24-code: #ded2ec;
--color-tfa24-code: oklch(0.88 0.03 300);
--font-tfa24-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-tfa24-mono: 'JetBrains Mono', ui-monospace, monospace;
}
.tfa-24 {
width: 100%;
min-height: 100svh;
}
.tfa-24 [data-panel] {
interpolate-size: allow-keywords;
}
@supports (interpolate-size: allow-keywords) {
.tfa-24 details::details-content {
height: 0;
overflow: clip;
opacity: 0;
transition: height .35s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .35s allow-discrete;
}
.tfa-24 details[open]::details-content {
height: auto;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tfa-24 details::details-content {
transition-duration: 1ms;
}
}Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tfa24-bg: #1e1a26;
--color-tfa24-bg: oklch(0.21 0.022 285);
--color-tfa24-panel: #262030;
--color-tfa24-panel: oklch(0.25 0.026 285);
--color-tfa24-viewer: #191521;
--color-tfa24-viewer: oklch(0.18 0.02 285);
--color-tfa24-glow: #3a2444;
--color-tfa24-glow: oklch(0.3 0.06 310 / 0.5);
--color-tfa24-ink: #f3f0f7;
--color-tfa24-ink: oklch(0.96 0.006 280);
--color-tfa24-mut: #bab2c6;
--color-tfa24-mut: oklch(0.79 0.018 282);
--color-tfa24-edge: #3a3348;
--color-tfa24-edge: oklch(0.35 0.028 284);
--color-tfa24-accedge: #9a6cc0;
--color-tfa24-accedge: oklch(0.62 0.11 310);
--color-tfa24-tint: #33244a;
--color-tfa24-tint: oklch(0.29 0.055 310);
--color-tfa24-acc: #dda8f0;
--color-tfa24-acc: oklch(0.82 0.13 310);
--color-tfa24-code: #ded2ec;
--color-tfa24-code: oklch(0.88 0.03 300);
--font-tfa24-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-tfa24-mono: 'JetBrains Mono', ui-monospace, monospace;
}
.tfa-24 {
width: 100%;
min-height: 100svh;
}
.tfa-24 [data-panel] {
interpolate-size: allow-keywords;
}
@supports (interpolate-size: allow-keywords) {
.tfa-24 details::details-content {
height: 0;
overflow: clip;
opacity: 0;
transition: height .35s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .35s allow-discrete;
}
.tfa-24 details[open]::details-content {
height: auto;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tfa-24 details::details-content {
transition-duration: 1ms;
}
}@import "tailwindcss";
@theme {
--color-tfa24-bg: #1e1a26;
--color-tfa24-bg: oklch(0.21 0.022 285);
--color-tfa24-panel: #262030;
--color-tfa24-panel: oklch(0.25 0.026 285);
--color-tfa24-viewer: #191521;
--color-tfa24-viewer: oklch(0.18 0.02 285);
--color-tfa24-glow: #3a2444;
--color-tfa24-glow: oklch(0.3 0.06 310 / 0.5);
--color-tfa24-ink: #f3f0f7;
--color-tfa24-ink: oklch(0.96 0.006 280);
--color-tfa24-mut: #bab2c6;
--color-tfa24-mut: oklch(0.79 0.018 282);
--color-tfa24-edge: #3a3348;
--color-tfa24-edge: oklch(0.35 0.028 284);
--color-tfa24-accedge: #9a6cc0;
--color-tfa24-accedge: oklch(0.62 0.11 310);
--color-tfa24-tint: #33244a;
--color-tfa24-tint: oklch(0.29 0.055 310);
--color-tfa24-acc: #dda8f0;
--color-tfa24-acc: oklch(0.82 0.13 310);
--color-tfa24-code: #ded2ec;
--color-tfa24-code: oklch(0.88 0.03 300);
--font-tfa24-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-tfa24-mono: 'JetBrains Mono', ui-monospace, monospace;
}
.tfa-24 {
width: 100%;
min-height: 100svh;
}
.tfa-24 [data-panel] {
interpolate-size: allow-keywords;
}
@supports (interpolate-size: allow-keywords) {
.tfa-24 details::details-content {
height: 0;
overflow: clip;
opacity: 0;
transition: height .35s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .35s allow-discrete;
}
.tfa-24 details[open]::details-content {
height: auto;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tfa-24 details::details-content {
transition-duration: 1ms;
}
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The CSS version needed four ID-based
:checked ~ .paneselector pairs for the tab viewer — eight selectors in one long rule. Named peers replace all of it with one class per pane, and adding a fifth framework no longer means editing a selector list. - This is also the demo where the port's thesis gets stronger rather than weaker: the CSS collection still had a stylesheet to move into four scoping systems. Utilities travel with the markup, so the framework shell really is the only per-stack work.
- The snippets deliberately show real utility class lists, including the marker shim and
group-open:rotate-[225deg], so readers can see that arbitrary variants survive JSX and template syntax unchanged. - UI upgrade from the CSS original: muted copy
oklch(0.68)→0.79, code text0.85→0.88on a darker viewer ground, and the tab labels now hit 4.5:1 in both states — the original's inactive tabs sat near 3.4:1. - UI upgrade: each tab's focus ring comes from its own named peer, so keyboard tabbing through the viewer shows a ring on the correct label — the original's single adjacent-sibling rule only worked for one pair.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind FAQ Component in React, Vue, Svelte and Astro (One Port Recipe)
Source: https://codefronts.com/snippets/tailwind-css-faq-accordion/tailwind-faq-component-react-vue-svelte-astro/
One accordion, five codebases: a working native-<details> FAQ up top, and beneath it a pure-CSS tab viewer showing the same component in React, Vue 3, Svelte 5 and Astro. The thesis is stronger in Tailwind than it was in CSS — because the class list travels with the markup, the port really is just a data-mapping shell, with no stylesheet to move at all.
## HTML
```html
<section class="tfa-24 w-full min-h-svh grid place-items-center bg-tfa24-bg font-tfa24-sans text-tfa24-ink px-5 py-12 bg-[radial-gradient(900px_500px_at_85%_0%,var(--color-tfa24-glow),transparent_60%)]">
<div class="w-[min(700px,100%)]">
<header>
<h1 class="text-[clamp(28px,5vw,40px)] font-extrabold leading-[1.08] tracking-[-0.025em]">One accordion, five codebases</h1>
<p class="mt-3 max-w-[56ch] text-[15px] leading-relaxed text-tfa24-mut">The living component, then the same 15-line shell per framework. Behaviour stays in <code class="rounded bg-tfa24-tint px-1.5 py-0.5 font-tfa24-mono text-[0.87em] text-tfa24-acc"><details></code>, styling stays in the class list, frameworks only map data.</p>
</header>
<div data-panel class="mt-6 flex flex-col gap-2.5">
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge" open>
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Why no accordion library?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">Because the platform ships one. The details element gives you state, keyboard support and semantics; a library would re-implement all three in 40kB and still need this class list.</p>
</details>
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge">
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Where does state live?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">In the DOM open attribute — so SSR, static builds and server components render working accordions with zero hydration. Lift state into the framework only when a feature actually demands it.</p>
</details>
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge">
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Does the styling need porting?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">No, and that is new: utilities travel inside the markup. The CSS version still had to get a stylesheet into four different scoping systems.</p>
</details>
</div>
<div class="mt-5 overflow-hidden rounded-2xl border border-tfa24-edge bg-tfa24-viewer">
<div class="flex flex-wrap px-2.5 pt-2.5">
<input type="radio" name="tfa24-tab" id="tfa24-react" class="peer/tfa24-react absolute opacity-0 pointer-events-none" checked>
<label for="tfa24-react" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-react:outline-2 peer-focus-visible/tfa24-react:-outline-offset-2 peer-focus-visible/tfa24-react:outline-tfa24-acc peer-checked/tfa24-react:bg-tfa24-panel peer-checked/tfa24-react:text-tfa24-acc">React</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-react:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code">function Faq({ items }) {
return (
<div className="flex flex-col gap-2.5">
{items.map(it => (
<details key={it.q} className="group rounded-2xl border"
{...(it.open ? { open: true } : {})}>
<summary className="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i className="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]" />
</summary>
<p className="px-5 pb-4 text-sm">{it.a}</p>
</details>
))}
</div>
);
} // no useState — the DOM holds the state</code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-vue" class="peer/tfa24-vue absolute opacity-0 pointer-events-none">
<label for="tfa24-vue" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-vue:outline-2 peer-focus-visible/tfa24-vue:-outline-offset-2 peer-focus-visible/tfa24-vue:outline-tfa24-acc peer-checked/tfa24-vue:bg-tfa24-panel peer-checked/tfa24-vue:text-tfa24-acc">Vue 3</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-vue:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code"><script setup>
defineProps({ items: Array })
</script>
<template>
<div class="flex flex-col gap-2.5">
<details v-for="it in items" :key="it.q" :open="it.open"
class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{{ it.q }}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]" />
</summary>
<p class="px-5 pb-4 text-sm">{{ it.a }}</p>
</details>
</div>
</template></code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-svelte" class="peer/tfa24-svelte absolute opacity-0 pointer-events-none">
<label for="tfa24-svelte" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-svelte:outline-2 peer-focus-visible/tfa24-svelte:-outline-offset-2 peer-focus-visible/tfa24-svelte:outline-tfa24-acc peer-checked/tfa24-svelte:bg-tfa24-panel peer-checked/tfa24-svelte:text-tfa24-acc">Svelte 5</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-svelte:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code"><script>
let { items } = $props();
</script>
<div class="flex flex-col gap-2.5">
{#each items as it (it.q)}
<details open={it.open} class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]"></i>
</summary>
<p class="px-5 pb-4 text-sm">{it.a}</p>
</details>
{/each}
</div></code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-astro" class="peer/tfa24-astro absolute opacity-0 pointer-events-none">
<label for="tfa24-astro" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-astro:outline-2 peer-focus-visible/tfa24-astro:-outline-offset-2 peer-focus-visible/tfa24-astro:outline-tfa24-acc peer-checked/tfa24-astro:bg-tfa24-panel peer-checked/tfa24-astro:text-tfa24-acc">Astro</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-astro:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code">---
// Faq.astro — renders to plain HTML, ships zero JS
const { items } = Astro.props;
---
<div class="flex flex-col gap-2.5">
{items.map(it => (
<details open={it.open} class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]"></i>
</summary>
<p class="px-5 pb-4 text-sm">{it.a}</p>
</details>
))}
</div>
<!-- no client: directive — none needed --></code></pre>
</div>
</div>
</div>
<p class="mt-5 text-[12.5px] text-tfa24-mut">The tab viewer above is the radio hack from demo 01, with named peers. Patterns compound.</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tfa24-bg: #1e1a26;
--color-tfa24-bg: oklch(0.21 0.022 285);
--color-tfa24-panel: #262030;
--color-tfa24-panel: oklch(0.25 0.026 285);
--color-tfa24-viewer: #191521;
--color-tfa24-viewer: oklch(0.18 0.02 285);
--color-tfa24-glow: #3a2444;
--color-tfa24-glow: oklch(0.3 0.06 310 / 0.5);
--color-tfa24-ink: #f3f0f7;
--color-tfa24-ink: oklch(0.96 0.006 280);
--color-tfa24-mut: #bab2c6;
--color-tfa24-mut: oklch(0.79 0.018 282);
--color-tfa24-edge: #3a3348;
--color-tfa24-edge: oklch(0.35 0.028 284);
--color-tfa24-accedge: #9a6cc0;
--color-tfa24-accedge: oklch(0.62 0.11 310);
--color-tfa24-tint: #33244a;
--color-tfa24-tint: oklch(0.29 0.055 310);
--color-tfa24-acc: #dda8f0;
--color-tfa24-acc: oklch(0.82 0.13 310);
--color-tfa24-code: #ded2ec;
--color-tfa24-code: oklch(0.88 0.03 300);
--font-tfa24-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-tfa24-mono: 'JetBrains Mono', ui-monospace, monospace;
}
.tfa-24 {
width: 100%;
min-height: 100svh;
}
.tfa-24 [data-panel] {
interpolate-size: allow-keywords;
}
@supports (interpolate-size: allow-keywords) {
.tfa-24 details::details-content {
height: 0;
overflow: clip;
opacity: 0;
transition: height .35s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .35s allow-discrete;
}
.tfa-24 details[open]::details-content {
height: auto;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tfa-24 details::details-content {
transition-duration: 1ms;
}
}
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind FAQ Component in React, Vue, Svelte and Astro (One Port Recipe)
Source: https://codefronts.com/snippets/tailwind-css-faq-accordion/tailwind-faq-component-react-vue-svelte-astro/
One accordion, five codebases: a working native-<details> FAQ up top, and beneath it a pure-CSS tab viewer showing the same component in React, Vue 3, Svelte 5 and Astro. The thesis is stronger in Tailwind than it was in CSS — because the class list travels with the markup, the port really is just a data-mapping shell, with no stylesheet to move at all.
## HTML
```html
<section class="tfa-24 w-full min-h-svh grid place-items-center bg-tfa24-bg font-tfa24-sans text-tfa24-ink px-5 py-12 bg-[radial-gradient(900px_500px_at_85%_0%,var(--color-tfa24-glow),transparent_60%)]">
<div class="w-[min(700px,100%)]">
<header>
<h1 class="text-[clamp(28px,5vw,40px)] font-extrabold leading-[1.08] tracking-[-0.025em]">One accordion, five codebases</h1>
<p class="mt-3 max-w-[56ch] text-[15px] leading-relaxed text-tfa24-mut">The living component, then the same 15-line shell per framework. Behaviour stays in <code class="rounded bg-tfa24-tint px-1.5 py-0.5 font-tfa24-mono text-[0.87em] text-tfa24-acc"><details></code>, styling stays in the class list, frameworks only map data.</p>
</header>
<div data-panel class="mt-6 flex flex-col gap-2.5">
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge" open>
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Why no accordion library?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">Because the platform ships one. The details element gives you state, keyboard support and semantics; a library would re-implement all three in 40kB and still need this class list.</p>
</details>
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge">
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Where does state live?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">In the DOM open attribute — so SSR, static builds and server components render working accordions with zero hydration. Lift state into the framework only when a feature actually demands it.</p>
</details>
<details class="group rounded-[13px] border border-tfa24-edge bg-tfa24-panel transition-[border-color] duration-300 open:border-tfa24-accedge">
<summary class="flex min-h-11 cursor-pointer list-none items-center justify-between gap-4 px-4 py-3.5 text-[15px] font-semibold transition-colors duration-200 hover:text-tfa24-acc focus-visible:rounded-[13px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tfa24-acc [&::-webkit-details-marker]:hidden">Does the styling need porting?
<i aria-hidden="true" class="size-2 shrink-0 -translate-y-0.5 rotate-45 border-b-2 border-r-2 border-tfa24-acc transition-[rotate,translate] duration-350 group-open:translate-y-0.5 group-open:rotate-[225deg] motion-reduce:transition-none"></i>
</summary>
<p class="max-w-[60ch] px-4 pb-3.5 text-[13.5px] leading-[1.7] text-tfa24-mut">No, and that is new: utilities travel inside the markup. The CSS version still had to get a stylesheet into four different scoping systems.</p>
</details>
</div>
<div class="mt-5 overflow-hidden rounded-2xl border border-tfa24-edge bg-tfa24-viewer">
<div class="flex flex-wrap px-2.5 pt-2.5">
<input type="radio" name="tfa24-tab" id="tfa24-react" class="peer/tfa24-react absolute opacity-0 pointer-events-none" checked>
<label for="tfa24-react" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-react:outline-2 peer-focus-visible/tfa24-react:-outline-offset-2 peer-focus-visible/tfa24-react:outline-tfa24-acc peer-checked/tfa24-react:bg-tfa24-panel peer-checked/tfa24-react:text-tfa24-acc">React</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-react:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code">function Faq({ items }) {
return (
<div className="flex flex-col gap-2.5">
{items.map(it => (
<details key={it.q} className="group rounded-2xl border"
{...(it.open ? { open: true } : {})}>
<summary className="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i className="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]" />
</summary>
<p className="px-5 pb-4 text-sm">{it.a}</p>
</details>
))}
</div>
);
} // no useState — the DOM holds the state</code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-vue" class="peer/tfa24-vue absolute opacity-0 pointer-events-none">
<label for="tfa24-vue" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-vue:outline-2 peer-focus-visible/tfa24-vue:-outline-offset-2 peer-focus-visible/tfa24-vue:outline-tfa24-acc peer-checked/tfa24-vue:bg-tfa24-panel peer-checked/tfa24-vue:text-tfa24-acc">Vue 3</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-vue:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code"><script setup>
defineProps({ items: Array })
</script>
<template>
<div class="flex flex-col gap-2.5">
<details v-for="it in items" :key="it.q" :open="it.open"
class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{{ it.q }}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]" />
</summary>
<p class="px-5 pb-4 text-sm">{{ it.a }}</p>
</details>
</div>
</template></code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-svelte" class="peer/tfa24-svelte absolute opacity-0 pointer-events-none">
<label for="tfa24-svelte" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-svelte:outline-2 peer-focus-visible/tfa24-svelte:-outline-offset-2 peer-focus-visible/tfa24-svelte:outline-tfa24-acc peer-checked/tfa24-svelte:bg-tfa24-panel peer-checked/tfa24-svelte:text-tfa24-acc">Svelte 5</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-svelte:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code"><script>
let { items } = $props();
</script>
<div class="flex flex-col gap-2.5">
{#each items as it (it.q)}
<details open={it.open} class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]"></i>
</summary>
<p class="px-5 pb-4 text-sm">{it.a}</p>
</details>
{/each}
</div></code></pre>
</div>
<input type="radio" name="tfa24-tab" id="tfa24-astro" class="peer/tfa24-astro absolute opacity-0 pointer-events-none">
<label for="tfa24-astro" class="grid min-h-11 cursor-pointer place-items-center rounded-t-[10px] px-4 text-[13px] font-bold text-tfa24-mut transition-colors duration-200 hover:text-tfa24-ink peer-focus-visible/tfa24-astro:outline-2 peer-focus-visible/tfa24-astro:-outline-offset-2 peer-focus-visible/tfa24-astro:outline-tfa24-acc peer-checked/tfa24-astro:bg-tfa24-panel peer-checked/tfa24-astro:text-tfa24-acc">Astro</label>
<div class="hidden w-full border-t border-tfa24-edge bg-tfa24-panel peer-checked/tfa24-astro:block">
<pre class="overflow-x-auto px-5 py-4"><code class="font-tfa24-mono text-[12px] leading-[1.65] text-tfa24-code">---
// Faq.astro — renders to plain HTML, ships zero JS
const { items } = Astro.props;
---
<div class="flex flex-col gap-2.5">
{items.map(it => (
<details open={it.open} class="group rounded-2xl border">
<summary class="flex min-h-11 cursor-pointer list-none
items-center justify-between px-5 py-4
[&::-webkit-details-marker]:hidden">
{it.q}
<i class="size-2 rotate-45 border-b-2 border-r-2
group-open:rotate-[225deg]"></i>
</summary>
<p class="px-5 pb-4 text-sm">{it.a}</p>
</details>
))}
</div>
<!-- no client: directive — none needed --></code></pre>
</div>
</div>
</div>
<p class="mt-5 text-[12.5px] text-tfa24-mut">The tab viewer above is the radio hack from demo 01, with named peers. Patterns compound.</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tfa24-bg: #1e1a26;
--color-tfa24-bg: oklch(0.21 0.022 285);
--color-tfa24-panel: #262030;
--color-tfa24-panel: oklch(0.25 0.026 285);
--color-tfa24-viewer: #191521;
--color-tfa24-viewer: oklch(0.18 0.02 285);
--color-tfa24-glow: #3a2444;
--color-tfa24-glow: oklch(0.3 0.06 310 / 0.5);
--color-tfa24-ink: #f3f0f7;
--color-tfa24-ink: oklch(0.96 0.006 280);
--color-tfa24-mut: #bab2c6;
--color-tfa24-mut: oklch(0.79 0.018 282);
--color-tfa24-edge: #3a3348;
--color-tfa24-edge: oklch(0.35 0.028 284);
--color-tfa24-accedge: #9a6cc0;
--color-tfa24-accedge: oklch(0.62 0.11 310);
--color-tfa24-tint: #33244a;
--color-tfa24-tint: oklch(0.29 0.055 310);
--color-tfa24-acc: #dda8f0;
--color-tfa24-acc: oklch(0.82 0.13 310);
--color-tfa24-code: #ded2ec;
--color-tfa24-code: oklch(0.88 0.03 300);
--font-tfa24-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-tfa24-mono: 'JetBrains Mono', ui-monospace, monospace;
}
.tfa-24 {
width: 100%;
min-height: 100svh;
}
.tfa-24 [data-panel] {
interpolate-size: allow-keywords;
}
@supports (interpolate-size: allow-keywords) {
.tfa-24 details::details-content {
height: 0;
overflow: clip;
opacity: 0;
transition: height .35s cubic-bezier(.4,0,.2,1), opacity .3s, content-visibility .35s allow-discrete;
}
.tfa-24 details[open]::details-content {
height: auto;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tfa-24 details::details-content {
transition-duration: 1ms;
}
}
```How this works
Portability comes from strict separation. HTML owns behaviour (<details>/<summary>), utilities own presentation, and the framework owns only iteration and data. Every port is the same three moves: map the array, template the item, keep the class list.
Why this is a better story in Tailwind. The CSS version still had to get a stylesheet into each framework (global file, CSS module, scoped block, with the scoping rules differing per framework). Here the styles are in the markup, so the port is genuinely copy-paste — the only per-framework question is loop syntax.
Named peers make the tab viewer possible. Four radios, four labels, four panes; each pane keys off its own peer. Unnamed peers would all match the nearest sibling input and every pane would show at once — the named-group syntax is what turns a CSS trick into a maintainable one.
The deeper recipe: when porting any component, move behaviour INTO the platform first — details, dialog, popover. Every element the platform owns is a file of framework state management you never write.
Make it yours
- Exclusive mode in any framework: add
name="faq"in the template — one attribute replaces the sibling-closing effect or watcher you were about to write. - TypeScript: the item shape is
{ q: string; a: string; open?: boolean }in every port. - Design-system use: wrap the same markup in a web component for multi-framework orgs; the utilities compile the same either way.
- CMS content: all four ports map an array — point it at your FAQ collection and delete the hardcoded data.
Gotchas — read before shipping
- React:
open={false}can still emit the attribute in some SSR paths. Spread the prop conditionally, as shown. - Vue:
v-forneeds:keyon the<details>, or open state migrates between items when the array reorders. - Svelte 5:
bind:openis lovely, but binding every item to one$statearray recreates exclusive-mode bugs. Bind per item. - Astro: zero-JS is the default and
<details>keeps working — do not add aclient:directive to an accordion that needs none.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 129+ | not yet | not yet | 129+ |
Floor set by oklch(), interpolate-size as this demo is written. The core technique itself goes back further — Chrome 111+.
The rendered demo is native <details> plus the radio-tab viewer; the floor is Tailwind v4's sheet. Snippets target React 18+, Vue 3, Svelte 5 and Astro 4 — current majors in 2026.