Press / to search

    No results

    Search failed

    npm GitHub

    <disclosure-elemental>

    A <button> wired to a region it shows and hides, per the APG Disclosure pattern. Light DOM, no shadow root, nothing wrapped and nothing moved β€” the region stays where your markup put it, which is the whole point.

    Native <details> is a disclosure already, and where it fits it wins β€” <accordion-elemental> is built on it for that reason. It fits when the region can live inside the trigger's element. This is for when it cannot.

    Large glacial boulders on cropped grass, three of them raised on smaller stones behind, with flat farmland and a bright broken sky beyond.
    The picture, by the numbers
    FigureValue
    PhotographerJoeri RΓΆmer
    Source size1920 Γ— 1280
    Aspect ratio3 : 2
    Pixels2,457,600
    Served here at900 Γ— 600
    Lorem Picsum id168
    Photograph from Unsplash, served here through Lorem Picsum.

    The numbers behind a figure, in the table beside it, for the reader the alt cannot carry them to. Try find-in-page for "2,457,600" with the table closed.

    Usage

    Write a <button> and the region it controls, and wrap them β€” edit the sample and the preview above it follows as you type:

    <disclosure-elemental>
      <button>Figures for this photograph</button>
      <div id="photo-data">Two columns of numbers.</div>
    </disclosure-elemental>
    import 'book-of-elementals/disclosure';
    @use "book-of-elementals/disclosure/style.scss"; // structure and motion
    @use "book-of-elementals/disclosure/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/disclosure.min.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/book-of-elementals/dist/elementals/disclosure.min.css" />
    <link rel="stylesheet" href="https://unpkg.com/book-of-elementals/dist/elementals/disclosure-theme.min.css" />

    It registers itself on include and upgrades on connect. Nothing on window, nothing to instantiate, no init call to forget.

    Note

    The trigger must be a real <button> and a direct child. A <div role="button"> would mean reimplementing Enter, Space and the disabled state the platform hands you, and a button nested deeper would be ambiguous with the buttons inside the region. Neither is enforced β€” in both cases there is simply no trigger, and the region stays visible.

    API

    Attributes

    Attribute Type Default Description
    open boolean false Whether the region is showing. Reflected β€” it tracks the live state.
    for string β€” id of the region. Defaults to the button's next element sibling.
    open-when string β€” A media query that owns open: held open while it matches, closed when it stops.

    for is also read as data-for. open is not β€” it is state, not configuration, so it has one spelling.

    Properties

    Property Type Description
    open boolean Get/set the state. Writes the attribute.
    button HTMLButtonElement Read-only. The direct-child button.
    region Element Read-only. What for names, else the button's next sibling.
    <disclosure-elemental open>…</disclosure-elemental>
    const disclosure = document.querySelector('disclosure-elemental');
    disclosure.open = true; // slides, and fires disclosure-toggle

    Everything that changes it β€” a click, a script, find-in-page β€” goes through the attribute, so there is one place to read the state and one place to watch it.

    Events

    disclosure-toggle fires on the element on every state change, and bubbles. It fires with the state change, not with the slide:

    document.querySelector('disclosure-elemental')
      .addEventListener('disclosure-toggle', (e) => {
        console.log(e.detail.region, e.detail.open);
      });
    Property Value
    detail.region The element being shown or hidden
    detail.open Its new state, as a boolean

    What it writes

    Attribute On Value
    aria-expanded the button true / false
    aria-controls the button The region's id, generated if the markup has none
    type the button button, only if the markup did not set a type
    hidden the region until-found while closed, absent while open β€” set at the end of the close slide
    data-state the region open / closed, flipped with the click rather than with the slide
    class the region disclosure-elemental-region added, nothing removed
    data-mode both pinned / free, only while open-when is set β€” see A breakpoint that owns it

    type="button" because a <button> in a form submits it unless told otherwise, and a disclosure that posts the page away on its first click is not a disclosure.

    Keyboard

    All of it is the button's, which is the point of using one:

    Key Action
    Tab Move to the button
    Enter / Space Toggle the region

    A closed region is out of the tab order and out of the accessibility tree, because hidden takes it out of both. No aria-hidden anywhere, and no focus to manage.

    Styling hooks

    disclosure-elemental[open] { }                       /* the host, reflected state */
    disclosure-elemental > button[aria-expanded="true"] { } /* what the theme keys off */
    .disclosure-elemental-region { }                     /* the region, wherever it lives */
    .disclosure-elemental-region[data-state="closed"] { } /* the region, from the click onwards */
    [data-mode="free"] { }                               /* the element and the region, with `open-when` set */
    disclosure-elemental:not(:defined) { }               /* before upgrade */

    [aria-expanded="false"] already means "closing or closed" β€” the ARIA and open both flip at the click, so there is no closing class to pair with, the way the accordion needs one. data-state on the region is that same moment carried to the other end of the document, for the regions for puts out of the button's reach: hidden is the region's state as well, but it cannot land until the close slide is over, so anything transitioned has to key off data-state or it starts a whole slide late. Everything else can use either.

    Why not just <details>?

    Because a <details> owns its content: the region has to be a descendant of the element that toggles it. Four places that is not allowed or not survivable:

    • A <figcaption>. HTML requires it to be the first or last child of its <figure>. Inside a <details> it is no longer the figure's caption.
    • A table row, cell or <dd>. The content model does not admit a <details> in between, and neither does the layout.
    • A grid or flex item. Its parent lays it out directly; wrap it and the parent is laying out the wrapper instead.
    • A region nowhere near its button. A navigation drawer, a filter panel, a "show more" that opens below a whole card grid.

    For anything else β€” a FAQ, a "read more" on top of its own text β€” use <details>, or <accordion-elemental> for a set of them. This element is deliberately not the general answer.

    A region elsewhere

    Point for at the region's id and the element wraps nothing but the button. The region then lives wherever the markup needs it:

    Two people on a bench with a dog at their feet, looking out over a bay towards a suspension bridge in the haze.

    The bench is a slatted wooden one on a dark metal frame, standing on a concrete apron at the water's edge and seen from behind. Two people sit towards its right-hand end with their backs to the camera β€” one with long fair hair, one in a checked shirt β€” and a dog lies on the ground behind them, in the gap under the seat. Beyond the water the far shore is a low line of hills, and one tower of a suspension bridge stands out of the haze to the left of them, pale enough that the span reads as a single thin stroke. The whole frame is washed warm, the way photographs taken into a low sun over water usually are.

    Photograph by Charlie Foster, from Unsplash, served here through Lorem Picsum.
    <figure>
      <img src="…" alt="Two people on a bench with a dog at their feet, looking out…" />
      <disclosure-elemental for="bench-caption">
        <button>Describe this image</button>
      </disclosure-elemental>
      <figcaption id="bench-caption">…</figcaption>
    </figure>

    The <figcaption> is still a child of the <figure>, so it is still the figure's caption β€” the case the element exists for, and one <details> cannot reach at all.

    The region is resolved once, at upgrade, so it has to be in the document by then. With the bundle deferred or at the end of the body, it is.

    A breakpoint that owns it

    Some disclosures stop being disclosures at a width. A navigation rail is a drawer on a phone and simply there on a desktop; a long description is a toggle in a narrow column and prose beside the figure when there is room. That is one widget whose state belongs to the viewport rather than to the reader, and open-when is how you say so:

    <disclosure-elemental for="sidebar" open-when="(min-width: 60rem)">
      <button aria-label="Documentation navigation">…</button>
    </disclosure-elemental>

    While the query matches the region is open; when it stops matching the region closes and the button has it back. The element watches the query for as long as it is connected, so a rotation, a window drag or a browser-zoom step all land the same way, and the attribute can be rewritten at runtime β€” it starts watching the new query and drops the old one.

    Without it this is nine lines of matchMedia in every page that wants it, and the version most pages write instead is CSS:

    /* the common bug, not a suggestion */
    @media (width >= 60rem) {
      .sidebar { display: block; }
      .nav-toggle { display: none; }
    }

    That shows the panel without telling anyone. aria-expanded still says false and the region still says hidden, so a screen reader is told the thing it is reading does not exist β€” and hiding the button with display: none does not make its lie quieter, it just removes the only control that could have corrected it. Scott O'Hara's responsive accessibility walks through the CSS-only version of this and calls it "a little hacky", needing script anyway. open-when is that script, once, in the element that already owns the state.

    Note

    The query owns the state at each change, not every moment in between. Within one side of a breakpoint the button still toggles normally, which is what you want for a drawer that opens and shuts all day β€” and crossing the breakpoint resets it, so a drawer left open never survives into a layout that has no drawer. If the button would be meaningless on the matching side, hide it there in your CSS: the element writes state, not layout.

    Crossing lands instantly rather than sliding. A breakpoint change is the page being rearranged around the reader, and animating the region through a window drag would be animating something nobody asked for. If the reader's focus is inside a region the crossing closes β€” zooming in is the usual way to be mid-read when it happens β€” it moves to the button rather than falling to <body>, so the way back open is under their hands.

    Measured against a container

    A plain query measures the viewport, which is the wrong ruler for a disclosure living inside a component: a 400px card in a desktop page is as cramped as a phone, and a media query cannot see that. container: in front hands the same condition to the nearest ancestor container instead β€” a container name goes before the parenthesis, exactly where @container puts it:

    <div style="container-type: inline-size">
      <disclosure-elemental for="extras" open-when="container:(min-width: 30rem)">
        <button aria-label="More tools">…</button>
      </disclosure-elemental>
    </div>

    No API evaluates a container query from script, so the element does not try: the condition becomes a real @container rule in a <style> the element keeps in <head> β€” setting a custom property on itself, read back out of the computed styles β€” and a ResizeObserver on the container plays the part of the change event. The rule leaves with the element. Everything else is the plain-query behaviour: same crossings, same data-mode, same focus handoff. With no ResizeObserver to hear a crossing the query is ignored and the button alone is in charge; with no container ancestor to observe, the condition can never match and the disclosure stays free.

    What it takes is a size query, whole: a name, not, and, or, and both axes where the container is container-type: size β€” container:card not (min-width: 30rem) and container:(min-width: 40rem) and (min-height: 10rem) are both watched the way a plain one is.

    What it refuses is style() and scroll-state(), and a condition mixing one of those with a size query is refused whole. Nothing tells script that a custom property moved or that a box came unstuck β€” there is no event and no observer for either β€” so the element would read such a condition once and be wrong from the first flip onward. Refused means the attribute is ignored and the button alone is in charge, which is the same thing that happens with no ResizeObserver. Your stylesheet can still act on a style query; what the element adds is state, and state needs to be told when it changes.

    The same container: is vertical-when on <splitter-elemental> and flyout-when on <menu-elemental> β€” one probe, three attributes, and this is the page that explains it.

    The mode is on both ends

    Setting open-when also puts data-mode on the element and on the region β€” pinned while the query matches, free while it does not, and nothing at all on an element with no open-when:

    <disclosure-elemental for="sidebar" open-when="(min-width: 60rem)" data-mode="free">…</disclosure-elemental>
    
    <aside class="sidebar" id="sidebar" data-mode="free" hidden="until-found">…</aside>

    Which is there so your stylesheet does not have to say the breakpoint a second time:

    /* no media query in here, and no `60rem` to keep in step with the attribute */
    .sidebar { /* the rail */ }
    .sidebar[data-mode="free"] { position: fixed; /* the drawer */ }

    Written by hand, a responsive panel is a number in the HTML and the same number in the CSS, in two languages, with nothing checking that they still agree β€” move one and the panel and its state describe different layouts. Here only the element knows the breakpoint. Which is also what makes such a stylesheet worth shipping: it carries no breakpoint, so it lands in a project whose breakpoint is different and works.

    On the region as well as the element because for lets the two live at opposite ends of the document, and reaching back up to the button through :root:has(…) in every rule is a stylesheet nobody wants to read.

    Note

    data-mode arrives at upgrade, so [data-mode] cannot match before the element is defined β€” which makes it the progressive-enhancement guard as well. Layout that would strand the page if the script never loaded (an off-canvas panel, say) goes behind [data-mode="free"] and cannot apply until something is there to bring it back.

    The sidebar drawer is all of this on one page β€” the rail this site's own navigation is at the wide end, the drawer it is on a phone, and a stylesheet that never says the breakpoint.

    Find-in-page

    A closed region is hidden with hidden="until-found", not a bare hidden. Find-in-page still searches it, a link to a fragment inside it still lands there, and either one reveals the region and fires beforematch, which the element answers by opening. Same behaviour <details> has natively, on markup <details> could not have held.

    <!-- while closed -->
    <button aria-expanded="false" aria-controls="photo-data">…</button>
    <div id="photo-data" class="disclosure-elemental-region" hidden="until-found">…</div>

    Browsers without until-found read it as a plain hidden β€” the same content, just not found.

    Note

    hidden="until-found" skips the region's contents, not its box, so a padded or bordered region would leave an empty strip behind while closed. The element's stylesheet zeroes the region's own margin, padding, border and box-shadow while hidden, which is what the disclosure-elemental-region class is for.

    box-shadow is in that list for a case the others do not have. A closed region is normally not painted at all, so its shadow is moot β€” but a region closed by being moved rather than by being unpainted is a different thing. A drawer sits off-canvas under a translate, and a shadow reaches out of its box by its blur radius, so a panel you cannot see paints a smear down the edge of the viewport it just left. The same is true for the length of a close on a panel that defers content-visibility with allow-discrete so the slide can finish.

    Those zeroing rules are ordinary declarations at one class and one attribute, though, so a selector of equal weight later in the cascade beats them β€” .drawer[data-mode="free"] ties with .disclosure-elemental-region[hidden] and wins on order. If a shadow has to hold in every case, scope it to the open state rather than relying on the zeroing:

    .drawer:not([hidden]) { box-shadow: 0 0 2rem rgb(0 0 0 / 25%); }

    [hidden] is on the region the whole time it is closed, so a shadow declared only for :not([hidden]) cannot be painted by a panel that is not there.

    Animation

    The region slides. Retime it with two custom properties β€” the element reads the duration back out of the computed styles, so the stylesheet stays the single source of truth:

    .disclosure-elemental-region {
      --disclosure-elemental-duration: 250ms;
      --disclosure-elemental-easing: ease;
    }

    They default on :root, and the region is the place to override them β€” not disclosure-elemental, which a region pointed at by for is not inside.

    prefers-reduced-motion: reduce switches it off, in CSS and in the element. So does a duration of 0s, worth reaching for when the region is a table row or anything else a height transition cannot move.

    The region is the animated box, so do not pad or border it. Block padding is a floor the height cannot get under β€” box-sizing: border-box renders height: 0 as the padding β€” so the region would slide shut down to it and then cut. The accordion animates a wrapper of its own and hands you a box inside it; this element wraps nothing, on purpose, so the inset goes on a box inside the region:

    <figcaption id="bench-caption">
      <div class="caption-body">…</div>
    </figcaption>

    Margin is fine β€” it is outside the height, and it transitions too, so the gap closes with the height rather than dropping out from under it. It is zeroed on [data-state="closed"] rather than on [hidden], because hidden only lands once the close slide is over and a margin waiting for it would still be at full size while the panel went, then take a quarter second of its own afterwards.

    Margin inside the region stays inside it: the region is display: flow-root, because the slide measures it under an overflow: hidden that makes it a block formatting context, and a first or last child whose margin collapsed back out at rest would leave the region shorter than it was measured to be β€” it would open past its resting height and snap back. The rule skips tr, td, th, thead, tbody, tfoot and caption, where display is the table's structure, and it is one class's worth of specificity, so a display of your own still wins.

    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 takes the browser's button down to the page's own type and color and leaves it reading as text: no border, no inset, a medium-weight label, an underline on hover, and a leading Octicon chevron masked rather than painted so it follows a theme switch. It is still a <button> β€” the control toggles content in the same page β€” so the role, the Enter/Space handling and the focus ring are untouched. One property:

    Property Default
    --disclosure-elemental-caret-size 1em

    Turn it in the Options tab and copy the rule out of the bottom of the panel β€” the same table, with the values live:

    <disclosure-elemental>
      <button>What the panel writes</button>
      <p>Whatever you set here goes into a stylesheet, never into the sample.</p>
    </disclosure-elemental>

    Closed, the chevron points at its own label; open, it points down at what it revealed β€” the same turn a native <summary> marker makes. Pointing at the label is a writing-mode question rather than a direction, so the quarter turn goes the other way under :dir(rtl).

    Your own icon has to be a mask too, for the same reason:

    disclosure-elemental > button::before {
      mask-image: url("my-caret.svg"); /* not background-image β€” see above */
    }
    
    /* Or drop it and use your own affordance β€” the button then has none until you give it one */
    disclosure-elemental > button {
      padding-inline-start: 0;
    
      &::before {
        content: none;
      }
    }

    The theme stops short of a full link look on purpose: no link color, no underline until hover. GOV.UK's research on their details component found that "some users avoid clicking the link to show more details, as they think it will take them away from the page", and Adrian Roselli's testing found a link-styled trigger confusing across every profile. Color is the cue that reads as navigation, so the caret carries the affordance and the label stays the color of the text around it.

    A show/hide label

    No attribute for it, because it is one CSS rule and two spans:

    Grapefruit pith and wet stone up front, a long saline finish. Drink it cold enough that the glass fogs.


    [aria-expanded="false"] .hide,
    [aria-expanded="true"] .show {
      display: none;
    }

    display: none on the wrong label takes it out of the accessible name too, so the button is announced as "Show the tasting notes" or "Hide the tasting notes" and never as both. No JavaScript, and no label state to get out of step.

    Without JavaScript

    The region is not authored hidden β€” it is ordinary, visible markup that the element hides on upgrade. The stylesheet splits the time before that on @media (scripting):

    /* in the element's own stylesheet */
    @media (scripting: none) {
      disclosure-elemental:not(:defined) > button {
        display: none;
      }
    }
    
    @media (scripting: enabled) {
      disclosure-elemental:not(:defined):not([open]):not([for]):not([data-for]) > button + * {
        display: none;
      }
    }

    Scripting off: the region stays visible, which for a long description is the right way round β€” the content is there, and the button that would have hidden it is not offered. Scripting on: the page paints the closed disclosure from the first frame, so the region does not show for a moment and fold away while the bundle loads. Only for the sibling shape β€” a region for names lives under an id the stylesheet cannot know, so that one still has the moment; author it hidden yourself if you would rather trade a scripting-off reader's access for stillness, since the element takes over from whatever state it finds.

    The line that gate draws is worth knowing: a bundle that never arrives while scripting is on β€” blocked, 404 β€” leaves a button that opens nothing in front of a hidden region. The fallback covers scripting turned off, not every way a script can fail to run.

    Layout

    <disclosure-elemental> is display: contents. It exists for the cases native cannot reach β€” a figcaption, a table row, a grid item β€” and every one of those is a case where an extra box in the tree is exactly the problem. Dropping it around existing markup changes no layout at all. Give it display: block in your own CSS if you want something to style.