<accordion-elemental>
A set of disclosure panels. It wraps native <details>/<summary> rather than
reimplementing the APG Accordion pattern
on <div>s, so the browser keeps the semantics and the element adds only what the browser
leaves out: exclusivity, arrow keys, deep links, a bubbling event and a slide.
Why build on <details>?
Because it is already an accordion. It announces expanded state to screen
readers, activates on Enter and Space, opens itself for find-in-page, and
with a shared name it makes panels mutually exclusive without a
line of JavaScript.
So what does the element actually do?
Five things: assigns the shared name for exclusive mode,
adds arrow-key navigation between headers, opens the panel a URL fragment
points at, re-emits panel toggles as one bubbling event on the group, and
slides the panels open and shut.
Does it need JavaScript to work?
No. With scripting off you still have a working, accessible set of disclosure widgets โ you lose the arrow keys, the deep links and the slide, not the content.
Live, with exclusive and both theme classes. Focus a header and press the arrow keys.
Usage
Write ordinary <details> panels and wrap them โ edit the sample and the preview above it
follows as you type:
<accordion-elemental>
<details>
<summary>First question</summary>
<p>First answer.</p>
</details>
<details>
<summary>Second question</summary>
<p>Second answer.</p>
</details>
</accordion-elemental>import 'book-of-elementals/accordion';
@use "book-of-elementals/accordion/style.scss"; // structure and motion
@use "book-of-elementals/accordion/theme.scss"; // the look, optional
Or the single-element bundle โ no build step, no script to write:
<script src="https://unpkg.com/book-of-elementals/dist/elementals/accordion.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/book-of-elementals/dist/elementals/accordion.min.css" />
<link rel="stylesheet" href="https://unpkg.com/book-of-elementals/dist/elementals/accordion-theme.min.css" />
It registers itself on include and upgrades on connect. Nothing on window, nothing to
instantiate, no init call to forget.
Note
Put a heading inside the <summary> โ <summary><h3>Question</h3></summary> โ when the
panels are page sections. Screen reader users navigate by heading, and that is the one
thing bare <summary> does not give them. It stays a valid native disclosure either way.
API
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
exclusive |
boolean | false |
Only one panel open at a time. |
name |
string | auto | The shared name exclusive assigns. Generated if unset. |
Read bare, as data-*, or in kebab-case.
Properties and methods
| Member | Type | Description |
|---|---|---|
panels |
HTMLDetailsElement[] |
Read-only. Direct-child <details>, in order. |
headers |
HTMLElement[] |
Read-only. Their <summary> elements. |
openPanel(panel) |
โ | Opens it with the slide, closing a sibling if exclusive. |
closePanel(panel) |
โ | Slides it shut, then closes it. |
Setting panel.open directly works too โ it just skips the animation.
Events
toggle fires on a <details> and does not bubble, which makes a whole group awkward to
listen to. The element re-emits it on the group, bubbling:
document.querySelector('accordion-elemental')
.addEventListener('accordion-toggle', (e) => {
console.log(e.detail.panel, e.detail.open);
});
| Property | Value |
|---|---|
detail.panel |
The <details> that toggled |
detail.open |
Its new open state, as a boolean |
It fires when the panel actually closes โ at the end of the slide, not at the click.
Keyboard
Enter, Space and Tab are native. The rest is the APG's recommended header navigation, which the element adds:
| Key | Action |
|---|---|
| Enter / Space | Toggle the focused panel |
| Tab | Move through headers and panel content |
| Down / Up | Focus the next / previous header, wrapping |
| Home / End | Focus the first / last header |
Styling hooks
| Hook | On | Meaning |
|---|---|---|
.accordion-elemental-content-wrapper |
the outer box | The box the height animation measures and clips. Do not pad it. |
.accordion-elemental-content |
the box inside it | The panel body. Yours to style โ the inset goes here. |
.accordion-elemental-closing |
a <details> |
Open, but sliding shut. |
/* not "is open" but "is staying open" */
accordion-elemental > details[open]:not(.accordion-elemental-closing) > summary::after {
rotate: 180deg;
}
Exclusive panels
exclusive gives every panel the same name, which is what makes native <details>
mutually exclusive โ including for anything the element does not see, like a panel opened
from script. On a click the element closes the open sibling itself instead, so it slides
shut rather than vanishing.
<accordion-elemental exclusive>
<details open>โฆ</details>
<details>โฆ</details>
</accordion-elemental>
If several panels are authored open, the first wins and the rest close on upgrade.
Without exclusive the panels are independent โ open as many as you like:
Open me
Nothing else closes. Without exclusive the element assigns no
shared name, so each panel is an ordinary
<details> answering only to its own summary.
And me
Two open at once. Useful when the panels are reference material a reader wants side by side rather than a set of alternatives.
Assigning the name is the one part of exclusivity that needs the script. Write the
shared name on the panels yourself and the browser enforces it with none โ the element
picks that name up rather than minting its own. A name on the group does the same across
two separate groups, or keeps it stable across renders:
<accordion-elemental exclusive name="faq">โฆ</accordion-elemental>
<accordion-elemental exclusive>
<details name="faq" open>โฆ</details>
<details name="faq">โฆ</details>
</accordion-elemental>
Deep links
A link to #some-id inside a panel opens that panel, and any panels around it when they
are nested. Runs on load and on hashchange, instantly rather than animated โ a deep link
should arrive at the content, not at a panel still on its way open.
<details>
<summary id="shipping">How long does shipping take?</summary>
<p>โฆ</p>
</details>
An id on the <summary> rather than the panel gives every answer its own URL, so a
support link lands the reader on one answer instead of a wall of closed questions.
Animation
Panels slide. Retime with two custom properties โ the element reads the duration back out of the computed styles, so the stylesheet stays the single source of truth:
accordion-elemental {
--accordion-elemental-duration: 250ms;
--accordion-elemental-easing: ease;
}
prefers-reduced-motion: reduce switches it off, in CSS and in the element. With
JavaScript off there is no wrapper, the transition rule matches nothing, and the panels
toggle natively and instantly.
Two things make this awkward, and both are why the markup gains boxes:
<details>gives you no box to animate. The body is a bare run of siblings after the<summary>. On upgrade the element wraps it in two โ the outer one is measured and clipped, the inner one is yours, because the animated box cannot be padded:box-sizing: border-boxrendersheight: 0as the padding, so a padded box slides shut down to it and then cuts.<div class="accordion-elemental-content-wrapper"> <div class="accordion-elemental-content">โฆ</div> </div>accordion-elemental > details > .accordion-elemental-content-wrapper > .accordion-elemental-content { padding: 0.75rem 1rem; }Closing hides the content instantly.
<details>sets its contents todisplay: nonethe momentopengoes away, cutting a close off at frame one. So the element takes over the click, slides the body up while the panel is still open, and closes it after. For the length of that slide the panel is still[open], which is what.accordion-elemental-closingis for.
The look
style.scss is structure and motion; theme.scss is the look and is optional โ a
light-DOM element cannot scope a look away from a page that did not ask for one.
It draws in currentcolor โ borders and the hover fill mixed out of the text color, the
caret masked rather than painted โ so the panels land in the page's palette and follow a
theme switch with nothing configured. Two properties, for the two things a page usually
has its own value for:
| Property | Default |
|---|---|
--accordion-elemental-border-color |
color-mix(in srgb, currentcolor 15%, transparent) |
--accordion-elemental-radius |
0.5rem |
Turn them in the Options tab and copy the rule out of the bottom of the panel โ the same table, with the values live:
<accordion-elemental class="grouped">
<details open>
<summary>Both properties are on this one</summary>
<p>The rule the panel writes is the rule you would have written.</p>
</details>
<details>
<summary>And the border between us</summary>
<p>Set on the element, never on <code>:root</code>.</p>
</details>
</accordion-elemental>accordion-elemental {
--accordion-elemental-border-color: var(--border);
--accordion-elemental-radius: var(--radius);
}
That is the whole of this site's accordion CSS โ the demos wear the shipped theme, so what you see is what the import gives you.
Plain
No class: each panel its own bordered box, native disclosure marker.
Panels stand apart
A stack of separate boxes. Nothing shared between neighbours, so a panel can be moved, removed or rendered on its own without the run around it needing to know.
The marker is the browser's
Leading edge, and whatever shape the browser draws. Free, familiar, and not yours to design.
Grouped
class="grouped" โ one card instead of a stack. Every panel after the first drops its own
top border so a seam is one line, and the rounding goes on the outer corners of the run
rather than on a clipping wrapper: overflow: hidden would cut the focus ring off the
summary inside, which is the one thing on the element a keyboard user has to see.
Panels share their borders
Each panel after the first drops its top border and leans on its
neighbour's. Pulling the two onto each other by a pixel instead would draw
the seam at twice the alpha, because the border is mixed out of
currentcolor and composites with itself.
The focused panel lifts
A focus ring is drawn outside the summary's box, so in a flush run it reaches a pixel or two into the next panel โ which is later in the document and would paint over it. The focused panel is raised instead of anything being clipped. Tab through this group and the ring stays whole.
Caret
class="caret" โ an Octicon chevron
on the trailing edge instead of the native marker, turned over on open. Composes with
grouped; the demo at the top wears both.
Where does the caret come from?
A ::after on the summary, masked with an inline SVG. No
markup change โ the panels are the same <details> as
everywhere else on this page.
Why a mask and not a background image?
A mask paints with currentcolor, so the caret takes the
header's color and follows a theme switch. A background image bakes its
color into the SVG and needs a second copy for the dark theme.
Why half a turn and not a quarter?
This caret is parked at the far trailing edge with the header all the way
at the other end, so there is nothing beside it to point at: it points down
at the panel it opens and turns over. rotate: 180deg also reads
the same either way round in RTL. A caret that leads its label is
next to the thing it can point at, which is the quarter turn
<disclosure-elemental>
takes.
Your own icon has to be a mask too, for the same reason:
accordion-elemental.caret > details > summary::after {
mask-image: url("my-caret.svg"); /* not background-image โ see above */
}
The caret turns with the slide rather than after it, timed off the same two properties and
keyed on details[open]:not(.accordion-elemental-closing) so a close starts turning it
back on the first frame.
Building an FAQ
The pieces above, and the four decisions worth copying:
<accordion-elemental class="grouped caret">
<details>
<summary id="faq-shipping"><h3>How long does shipping take?</h3></summary>
<p>Two to five working days inside the EUโฆ</p>
</details>
<details>
<summary id="faq-returns"><h3>Can I return an order?</h3></summary>
<p>Within 30 days, unopened, for a full refund.</p>
</details>
</accordion-elemental>
- A heading in each
<summary>โ an FAQ is a run of page sections, and heading navigation is how a screen reader user skims one.<h3>follows the page's outline, not the component. - An
idon each<summary>, not on the panel โ every answer gets its own URL, and the element opens what a fragment points at. - No
exclusiveโ a reader comparing two answers wants both. Exclusive is for alternatives, not for reference material. groupedrather than a clipping wrapper โ the focus ring survives.
Everything else is the browser's: expanded state announced, Enter and Space handled,
find-in-page opening a closed panel to show a match. There is not one aria- attribute in
that markup, and that is the point.