Dialog
UIA 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.
Components / UI
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.
For this one, yes. The browser animates the height.
Native: Tab, Enter, Space.
details name=, also native.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
Chrome 131 · Firefox 143 · Safari 18.4; needs details-content, allow-discrete, details-name. Elsewhere: items open and close instantly.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>' },
]}
/>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/accordion.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| 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. |
Opens and closes instantly; the chevron still turns.
ClientRouterCSS-only; native elements keep their state per page.
Replaces: Accordion (Radix / shadcn) · Accordion (Aceternity) · FAQ blocks. See the migration table.
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.
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.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown