# Accordion (Moonarc)

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.

- Import: `import Accordion from '@moonarc/core/Accordion'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/accordion.json`
- Tier A · category ui · trigger click
- Readout: `<Accordion exclusive>`
- Browser support: newly (Chrome 131 · Firefox 143 · Safari 18.4); elsewhere: items open and close instantly
- Measured cost: 0 B JS (CSS 5.2 kB raw)
- Page: https://moonarc.dev/components/accordion/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `{ title: string; body: string }[]` | none | Items; body is an HTML string. |
| `exclusive` | `boolean` | `false` | Only one open at a time, via the details name attribute. |
| `open` | `number` | none | Index of the item open on load. |
| `name` | `string` | `generated` | Group name used when exclusive. |

## Usage

```astro
<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>' },
  ]}
/>
```

## Reduced motion

Opens and closes instantly; the chevron still turns.

## With ClientRouter

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

## Craft

- grid-template-rows 0fr → 1fr is the one way to animate to auto height without JavaScript or interpolate-size; the body needs min-height: 0 and overflow: clip.
- content-visibility transitions with allow-discrete so the closing body stays visible until the row has collapsed.
- The chevron rotates on the same curve and duration as the height, so the two read as one motion.
- No custom marker element: the default disclosure triangle is hidden and the chevron is inside the summary, where the click target already is.

## Replaces

- Accordion (Radix / shadcn)
- Accordion (Aceternity)
- FAQ blocks

## Source

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

```
