Press / to search

    No results

    Search failed

    npm GitHub

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

    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-box renders height: 0 as 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 to display: none the moment open goes 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-closing is 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 id on 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.
    • grouped rather 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.