Skip to content

Components / UI

Accordion

Native <details> items whose height animates open and closed, with an optional one-at-a-time rule the browser enforces. Zero JavaScript: ::details-content is a grid whose row interpolates from 0fr to 1fr, so height-to-auto costs nothing to measure.

Live demo

Zero JavaScript?

For this one, yes. The browser animates the height.

Keyboard?

Native: Tab, Enter, Space.

One at a time?

details name=, also native.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 5.2 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 131 · Firefox 143 · Safari 18.4; needs details-content, allow-discrete, details-name. Elsewhere: items open and close instantly.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Accordion from '@moonarc/core/Accordion';
---
<Accordion
  exclusive
  open={0}
  items={[
    { title: 'Is it really zero JS?', body: '<p>For this one, yes. The browser animates it.</p>' },
    { title: 'Keyboard?', body: '<p>Native: Tab, Enter, Space.</p>' },
  ]}
/>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/accordion.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

Accordion.astro
---
/**
 * Accordion — native <details> with an animated height, zero JS.
 *
 * The browser owns open/close, keyboard, and (with `exclusive`) the one-open
 * rule via the name attribute. The animation is ::details-content laid out
 * as a grid whose row goes 0fr → 1fr, so the body's height interpolates from
 * nothing to auto without measuring anything. Browsers without
 * ::details-content open instantly — a fallback, not a failure.
 */
import type { HTMLAttributes } from 'astro/types';

interface Item {
  title: string;
  /** HTML string. */
  body: string;
}

interface Props extends HTMLAttributes<'div'> {
  items: Item[];
  /** Only one item open at a time (details name=). */
  exclusive?: boolean;
  /** Index of the item open on load. */
  open?: number;
  /** Group name when exclusive; defaults to a generated one. */
  name?: string;
}

const { items, exclusive = false, open, name = `ma-acc-${Math.random().toString(36).slice(2, 7)}`, class: className, ...rest } = Astro.props;
---

<div class:list={['ma-accordion', className]} {...rest}>
  {
    items.map((item, i) => (
      <details class="ma-accordion__item" name={exclusive ? name : undefined} open={open === i}>
        <summary class="ma-accordion__summary">
          <span>{item.title}</span>
          <svg class="ma-accordion__chevron" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
        </summary>
        <div class="ma-accordion__body" set:html={item.body} />
      </details>
    ))
  }
</div>

<style is:global>
  @layer components {
    :where(.ma-accordion__item) {
      border-top: 1px solid var(--ma-edge);
    }
    :where(.ma-accordion__item:last-child) {
      border-bottom: 1px solid var(--ma-edge);
    }
    :where(.ma-accordion__summary) {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 1rem;
      padding: 0.875rem 0;
      cursor: pointer;
      list-style: none;
      font-weight: 500;
    }
    :where(.ma-accordion__summary)::-webkit-details-marker {
      display: none;
    }
    :where(.ma-accordion__chevron) {
      flex: none;
      opacity: 0.6;
      transition: rotate var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-accordion__item[open] .ma-accordion__chevron) {
      rotate: 180deg;
    }
    /* the animated part: the content box is a grid whose only row interpolates 0fr → 1fr */
    :where(.ma-accordion__item)::details-content {
      display: grid;
      grid-template-rows: 0fr;
      transition:
        grid-template-rows var(--ma-duration) var(--ma-ease),
        content-visibility var(--ma-duration) allow-discrete;
    }
    :where(.ma-accordion__item[open])::details-content {
      grid-template-rows: 1fr;
    }
    :where(.ma-accordion__body) {
      min-height: 0;
      overflow: clip;
      padding-bottom: 0;
      transition: padding-bottom var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-accordion__item[open] .ma-accordion__body) {
      padding-bottom: 1rem;
    }
    /* ::details-content in a rule of its own: in an engine that does not know it, a selector list that names it is
       dropped whole, and the body and the chevron kept their transitions under reduced motion. The minifier merges
       neighbouring rules with the same declarations back into one list, so this one sits behind a feature test */
    @media (prefers-reduced-motion: reduce) {
      @supports selector(::details-content) {
        :where(.ma-accordion__item)::details-content {
          transition: none;
        }
      }
      :where(.ma-accordion__body),
      :where(.ma-accordion__chevron) {
        transition: none;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
items{ title: string; body: string }[]noneItems; body is an HTML string.
exclusivebooleanfalseOnly one open at a time, via the details name attribute.
opennumbernoneIndex of the item open on load.
namestringgeneratedGroup name used when exclusive.

Reduced motion

Opens and closes instantly; the chevron still turns.

With ClientRouter

CSS-only; native elements keep their state per page.

Why it is built this way

Replaces: Accordion (Radix / shadcn) · Accordion (Aceternity) · FAQ blocks. See the migration table.

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

Raw bytes of JavaScript, from a production build, measured again for every change that can move them.

A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.

newly · Chrome 125 · Firefox 147 · Safari 26click
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json

Tabs with an indicator that slides to the selected one and panels that fade in.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown