npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, πŸ‘‹, I’m Ryan HefnerΒ  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you πŸ™

Β© 2026 – Pkg Stats / Ryan Hefner

book-of-elementals

v3.3.0

Published

πŸ““ Stamat's Book of Elementals β€” custom elements, accessible, light DOM.

Readme

πŸ““ Book of Elementals npm version license mit

Accessible custom elements. Light DOM, no build step required.

Sibling to book-of-spells β€” that one holds the JavaScript helpers, this one holds the elements.

Principles

  • Native first β€” an element exists only where the platform leaves a real gap
  • Light DOM, always β€” no shadow roots, your CSS reaches every part
  • Accessible or it does not ship β€” W3C APG patterns, keyboard and reduced-motion included
  • One dependency, and it is the sibling β€” the helpers live in book-of-spells and are bundled into dist/, so a script tag still costs you exactly one file

Elements

| Element | Pattern | | ------------------------ | -------------------------------------------------------------------------------------------------- | | <accordion-elemental> | APG Accordion, over native <details> | | <carousel-elemental> | APG Carousel on a scroll-snapping list β€” the scroller is the state, read back off the layout and never written from an index | | <checkbox-group-elemental> | APG Checkbox (Mixed-State), a select-all that shows the dash when it is some of them | | <combobox-elemental> | APG Combobox, a <select> you can type your way down, one value or many | | <disclosure-elemental> | APG Disclosure, where <details> cannot go | | <feed-elemental> | APG Feed β€” a stream of articles that keeps growing: the indices, the keys and the way out, with the fetching left to you and the scrolling bounded by a budget | | <menu-elemental> | APG Menu Button, nested, and not a menu below a breakpoint | | <modal-elemental> | APG Modal Dialog on native <dialog> β€” nested, animated out, and dismissed the way the platform says | | <navbar-elemental> | APG Disclosure Navigation, folding itself away when the links stop fitting | | <segmented-elemental> | APG Radio Group on native radios, drawn as a track with a knob that slides | | <slider-elemental> | APG Slider on native range inputs, and Multi-Thumb when you write two β€” the thumb count is the markup | | <splitter-elemental> | APG Window Splitter β€” two panes and a draggable seam, keyboard included, for the one pattern the APG has never written an example for | | <suggest-elemental> | APG Combobox with a listbox popup β€” a list of links a text field drives with the arrow keys | | <switch-elemental> | APG Switch, for a setting that takes effect at once | | <tabs-elemental> | APG Tabs, horizontal or vertical, on a list of in-page links | | <toolbar-elemental> | APG Toolbar β€” a row of buttons the arrows walk and Tab passes in one step | | <tooltip-elemental> | APG Tooltip as far as it has consensus β€” a description on hover and focus, still on the page without script | | <tree-view-elemental> | APG Tree View on a nested list of links β€” one tab stop for the whole sidebar, and the arrows for the rest. The last pattern with no native equivalent at all | | <copy-elemental> | No APG pattern β€” a <button>, the clipboard write behind it, and the status message every copy button forgets | | <field-elemental> | No APG pattern β€” the control is already accessible and the constraints are already enforced, so this is only the half the platform leaves undone: the bubble cancelled, the browser's own message in a paragraph, and the aria-describedby and aria-invalid that tie it to the field | | <marquee-elemental> | No APG pattern β€” a strip that loops, with the copies counted against the container, kept out of the tab order with inert, and the stop button every other marquee leaves you to write | | <password-elemental> | No APG pattern β€” a reveal button for a password field: aria-pressed rather than a swapped name, the change announced rather than left to an icon, and the mask back on before the value is submitted | | <progress-elemental> | No APG pattern β€” <progress> already is one, so this adds only what it has never had: where its fill ends as something CSS can draw with, and a second value for the part loaded but not played | | <rearrange-elemental> | No APG pattern β€” a list, a table body or a board of named columns the reader rearranges by hand: the move buttons first, because WCAG 2.2 SC 2.5.7 asks for the non-drag path, and pointer dragging as the option on top | | <search-elemental> | No APG pattern β€” the query half of a search field: the debounce, the abort, the loading state and the status message a panel filling itself does not make | | <sortable-table-elemental> | No APG pattern β€” <table> already is one, so this adds only what the APG's own sortable table example describes: a button in the header, aria-sort on the column, and the caption note that explains them once instead of once per column | | <tilt-elemental> | No APG pattern β€” a card that leans under the pointer, with layers that rise out of it and a glare that follows, and the prefers-reduced-motion switch every other tilt library animates straight through |

Docs

stamat.github.io/book-of-elementals

Installation

npm install book-of-elementals

Import one element and only that element is registered:

import 'book-of-elementals/accordion';
import 'book-of-elementals/disclosure';
import 'book-of-elementals/switch';
@use "book-of-elementals/accordion/style.scss";
@use "book-of-elementals/disclosure/style.scss";
@use "book-of-elementals/switch/style.scss";

Or the whole book:

import 'book-of-elementals';
@use "book-of-elementals/styles/index.scss";

Or the CDN, no build step. Every element ships its own bundle:

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

No global, no boot call β€” including a bundle registers its element and it upgrades itself wherever it appears, including markup added later. The whole book is one directory up: dist/book-of-elementals.min.js.

Those stylesheets carry structure and motion only. Each element's look is a separate, optional one, off unless you ask for it:

@use "book-of-elementals/accordion/theme.scss";
<link
  rel="stylesheet"
  href="https://unpkg.com/book-of-elementals/dist/elementals/accordion-theme.min.css"
/>

Every element's look at once is book-of-elementals/theme.scss, or dist/book-of-elementals-theme.min.css β€” beside the whole-book script, one directory up from the per-element bundles.

<accordion-elemental>

Wraps native <details>/<summary> instead of reimplementing disclosure on <div>s, so the semantics, Enter/Space activation, screen-reader announcement and find-in-page expansion are the browser's.

<accordion-elemental exclusive>
  <details open>
    <summary>First question</summary>
    <p>First answer.</p>
  </details>
  <details>
    <summary>Second question</summary>
    <p>Second answer.</p>
  </details>
</accordion-elemental>

What the element adds on top of native:

  • exclusive β€” assigns a shared name so only one panel stays open
  • Up/Down/Home/End navigation between headers (APG accordion)
  • deep links β€” a URL fragment pointing inside a panel opens it
  • an accordion-toggle event on the group, since toggle does not bubble
  • a height animation on open and close, in every browser

| Attribute | Type | Default | Description | | ----------- | ------- | ------- | ---------------------------------------------------------- | | exclusive | boolean | false | Only one panel open at a time. | | name | string | auto | The shared name used by exclusive. Generated if unset. |

Put a heading inside the <summary> when the panels are page sections, so screen reader users can navigate to them by heading:

<summary><h3>Question</h3></summary>

Styling is yours β€” there is no shadow DOM. On upgrade the element wraps each panel body in <div class="accordion-elemental-content-wrapper"> with a <div class="accordion-elemental-content"> inside it β€” the wrapper is the box whose height transitions, so padding goes on the content β€” and holds the close open until the transition ends β€” <details> sets its contents to display: none the moment it closes, which would otherwise cut the animation off at frame one. Retime it in CSS; the element reads the duration back out of the stylesheet:

accordion-elemental {
  --accordion-elemental-duration: 250ms;
  --accordion-elemental-easing: ease;
}

prefers-reduced-motion: reduce switches it off, and without JavaScript there is no wrapper and no animation β€” native instant toggling, which is still correct.

<carousel-elemental>

A row of slides you scroll through, on the list you would have written anyway:

<carousel-elemental aria-label="Places">
  <ul>
    <li><figure><img src="canyon.jpg" alt="A river running through a rocky canyon" /></figure></li>
    <li><figure><img src="ridge.jpg" alt="Mist over a forested ridge at dawn" /></figure></li>
    <li><figure><img src="lake.jpg" alt="A lake below a range of bare mountains" /></figure></li>
  </ul>
</carousel-elemental>

The scroll container is the state. There is no transform engine, no cloned slides and no index attribute to keep in step with where the row actually is: the slides sit in a scroll-snapping scroller, moving is one assignment to scrollLeft, and which slide is current is read off the boxes themselves, whenever a ResizeObserver says the row changed shape. That is what makes it responsive for nothing β€” resize the window, change --carousel-elemental-slide-size at a breakpoint, put the whole thing in a container query, and the answer comes from the layout that came out of it rather than from an index kept in step by hand. There is no key handler either: a focused scroll container already answers to the arrows, Home, End and the page keys.

The element writes the roles of the APG Carousel pattern and appends the controls β€” previous, a picker with one button per slide, next β€” which is the enhancement working rather than a preference: a previous button authored in the markup is a button that does nothing until the script lands. Without the script the same list is a plain list, every slide on the page and in reading order β€” the row, the snap and the controls all arrive together.

The arrows stop at the ends and say so before you press them: the one with nowhere to go takes aria-disabled and is dimmed, and data-carousel-at-start / data-carousel-at-end on the element let a page style its own. That state is the scroller's answer to "is there anywhere left to scroll" rather than arithmetic on the index β€” the only version that holds when three slides of five are on screen and the row is at its end while the current slide is the third.

autoplay adds rotation and the control that stops it, first in the tab order inside the carousel, with a name that says what pressing it will do. Hover and focus pause it; rotation you started by hand ignores both until that button stops it; prefers-reduced-motion: reduce means it does not start on its own, and the control is still there for a reader who wants it. The rotation is the one thing that wraps at the end.

fade swaps the row for a stack that cross-fades β€” the same controls, picker, rotation and events, and the only mode where the scroller is not the state. It pays for itself honestly: the slides not showing are visibility: hidden, so they leave the accessibility tree and find-in-page, which is exactly the case the APG writes its live region for, and fade gets one. Scrolling there is none, and no aria-hidden on anything, because every slide is in the tree the whole time. A stack is not a scroll container either, so fade is the one place this element reads a gesture of its own: a touch swipe across it moves one slide, where the scrolling row gets that from the browser for nothing.

No infinite loop, no mouse drag, no vertical axis, and no slides-per-page, which is one custom property.

<checkbox-group-elemental>

The "select all" over the checkboxes it stands for: ticked when all of them are, empty when none are, and showing the dash when it is some of them.

<checkbox-group-elemental>
  <label><input type="checkbox" /> All notifications</label>
  <ul>
    <li><label><input type="checkbox" name="n" value="mentions" checked /> Mentions</label></li>
    <li><label><input type="checkbox" name="n" value="replies" /> Replies</label></li>
  </ul>
</checkbox-group-elemental>

No attributes. The first checkbox in the element is the parent, everything after it is a child, and the element writes two properties on the parent plus data-state on itself β€” no role, no aria-checked, because a native checkbox with indeterminate set is already announced as mixed.

The dash is the whole reason it exists. HTMLInputElement.indeterminate has no HTML attribute behind it β€” it can only be set from script, so no server or template can render that state, and every project writes the same twenty lines. It is also purely visual: submission is decided by checked alone, which is why the parent should have no name.

Pressing it cycles the APG's way: mixed β†’ all on β†’ all off β†’ back to the combination they were last mixed in, so two ticks out of twenty survive a press instead of being destroyed by it. That last step is skipped when there is nothing worth going back to. A disabled checkbox is outside the set the parent speaks for β€” never moved and never counted, because counting one that is unticked would put "all" out of reach and freeze the cycle. Every child that does move fires input and change, so nothing listening downstream is left holding stale state.

One level, not a tree: a nested group is a separate group. Without the script the parent is hidden rather than offered dead, and the children are ordinary working checkboxes β€” the stylesheet does that for a direct child, and hidden in the markup does it at any depth, which is what a select-all in a table header needs. The bulk actions example is that arrangement end to end: the header checkbox, a toolbar that wakes up, and a count kept honest by the per-row change.

The boxes are drawn rather than left to accent-color, which recolours the browser's box and can say nothing about the weight of a dash β€” and since a page cannot have one drawn checkbox and a browserful of default ones, that drawing is a stylesheet of its own that any checkbox can wear. Opt in with a class, on a container or on a <label>:

<form class="checkbox-elemental">…</form>
<label class="checkbox-elemental"><input type="checkbox" /> Remember me</label>
@use "book-of-elementals/checkbox.scss"; // or dist/book-of-elementals-checkbox.min.css

It arrives with checkbox-group/theme.scss already, and never as a bare input[type="checkbox"] selector: importing a theme for an accordion must not silently redraw every checkbox on the page. The seven --checkbox-elemental-* properties and the forced-colors behaviour are on the drawn checkbox β€” the only look in the package that is not an element's, and the line is stated in CONTRIBUTING.md: a control gets one only when an element cannot be drawn without it.

<combobox-elemental>

A <select> with a text field to search it with β€” one value, or many with a chip each. Wrap the <select> you would have written anyway; nothing else is read:

<label for="city">City</label>
<combobox-elemental>
  <select id="city" name="city">
    <option value="">Choose a city</option>
    <option value="bg">Beograd</option>
    <option value="ns">Novi Sad</option>
  </select>
</combobox-elemental>

| Attribute | Type | Default | Description | | ------------- | ------- | ------------ | ------------------------------------------------------------------ | | open | boolean | false | Whether the popup is showing. Reflected β€” it tracks the live state. | | placeholder | string | β€” | The field's placeholder. Single select falls back to the label of the option whose value is empty. | | empty-text | string | No matches | What the popup says when the query matches nothing. | | remove-text | string | Remove | The verb in a chip's remove button, before the option's label β€” or holding {label}, wherever the language puts it: {label} entfernen. |

Everything else is the <select>'s β€” multiple, required, disabled, name, and the options β€” because the <select> is still the control. It holds the value, submits, resets, restores and goes down with a <fieldset disabled>, so this element has no event of its own: every pick sets option.selected and lets the <select> fire the input and change that were going to be listened for anyway.

Filtering is the whole of the gap. A dropdown that is merely styled is now native β€” appearance: base-select takes the button, the picker and every <option> in plain CSS with no script at all. What no browser does is let you type your way down four hundred cities. If you are not filtering, you do not need this element.

The field is a role="combobox" input and the popup a role="listbox", with the cursor kept in aria-activedescendant so focus never leaves the field and typing keeps narrowing the list. The query matches anywhere in a label, and both sides fold first β€” cacak finds Čačak, dordevic finds ĐorΔ‘eviΔ‡, and Π±Π΅ΠΎ still finds Π‘Π΅ΠΎΠ³Ρ€Π°Π΄, because the folding is removeAccents and not slugify, which would have left that label an empty string. multiple adds the chips and drops the caret, Backspace on an empty field removes the last chip, and that half is the one with no APG example behind it: the pattern's six are all single-select.

The <select> is hidden by being made transparent and un-clickable rather than by display: none, which is not a detail β€” a display: none control that is required blocks its own form, because the browser refuses to submit and then cannot focus what it cannot draw. Rendered, it validates; the element cancels the browser's bubble, which would be aimed at something invisible, and keeps the message β€” validationMessage, the platform's own words in the reader's own language β€” in a role="alert" under the field, with focus moved onto the field itself. Without the script the <select> is a plain, working <select>; nothing is hidden until the field that replaces it exists.

<copy-elemental>

A real <button> that puts text on the clipboard and says so, in both of the ways a reader might be listening:

<code id="install">npm i book-of-elementals</code>

<copy-elemental for="install">
  <button type="button">Copy</button>
</copy-elemental>

| Attribute | Type | Default | Description | | ------------- | ------ | ------------- | ----------------------------------------------------------------- | | for | string | β€” | id of the element to copy. Also read as data-for. | | value | string | β€” | Literal text, exactly as written. Wins over for. | | copied-text | string | Copied | What the live region announces on success. | | error-text | string | Copy failed | What it announces when there was nothing to copy, or it refused. |

There is no APG pattern here, because there is no widget. It is a button, and a button is already accessible; what is missing is the half after the click. Every copy button on the web swaps an icon or floats a "Copied!" tooltip, and neither of those is anything at all to a reader using a screen reader β€” they press it and are told exactly what they were told before pressing it. That is WCAG 2.2 SC 4.1.3 Status Messages unmet, and closing it is why this element exists rather than a snippet copied off a blog. @github/clipboard-copy is the smaller install if you are drawing the feedback yourself.

So one press does three things: the clipboard write, data-state="copied" or "error" on the element for two seconds, and the matching words in a <span role="status"> the element appends to itself at upgrade β€” clipped out of sight, never display: none, which would take it back out of the accessibility tree and undo the point. A bubbling copy-done carries detail.ok and detail.text.

A field is copied by its current .value, anything else by the text it shows, with leading newlines and trailing whitespace stripped β€” a code block's trailing newline pasted into a terminal runs the command the reader was still reading. A value is never trimmed.

A copy button that cannot copy is a button that lies, so it is not offered at all until the element upgrades and finds a clipboard: no script, a page served over plain http where navigator.clipboard does not exist, or markup that named nothing to copy, and there is no button β€” data-unavailable says which. Keep the text selectable on the page; the button is a shortcut past selecting it, not the only way to it.

<disclosure-elemental>

A real <button> wired to a region it shows and hides. <details> is a disclosure already and wins wherever it fits β€” it fits when the region can live inside the trigger's element. This one is for when it cannot: a <figcaption>, which HTML requires to be a child of its <figure>; a table row; a grid item its parent lays out directly; a panel on the other side of the page from the button that opens it.

<figure>
  <img src="chart.png" alt="A tapering band showing an army shrinking…" />
  <disclosure-elemental for="chart-desc">
    <button>Describe this image</button>
  </disclosure-elemental>
  <figcaption id="chart-desc">…</figcaption>
</figure>

Nothing is wrapped and nothing is moved β€” the region stays exactly where the markup put it, which is the whole point. The element writes aria-expanded and aria-controls onto the button and hidden onto the region, and that is all the ARIA there is.

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

open-when is for the disclosures that stop being disclosures at a width β€” a rail that is a drawer on a phone, a long description that is prose beside the figure when there is room. The state stays in the one place that already holds it, instead of a media query showing the panel while aria-expanded still says false. With it set, the element writes data-mode="pinned" or data-mode="free" on itself and on the region, so your stylesheet keys off which side of the query you are on rather than repeating the number β€” and, since the attribute only lands at upgrade, layout that would strand a scriptless page cannot apply before the element is alive. The sidebar drawer example is the whole arrangement on one page.

A closed region is hidden with hidden="until-found", so find-in-page still searches it and a link to a fragment inside it still lands there β€” either one reveals the region and the element opens to match. State changes fire a bubbling disclosure-toggle.

The region slides open and closed, timed off --disclosure-elemental-duration and --disclosure-elemental-easing in the stylesheet. It is the animated box, so put its inset on a box inside it: block padding is a floor the height cannot get under.

The element is display: contents, so dropping it around existing markup changes no layout. With scripting off the region is simply visible and the button is not offered, which for a long description is the right way round.

<feed-elemental>

A stream of articles that keeps growing, with the loading left to the page:

<feed-elemental aria-label="Reviews" auto-load="2">
  <article><h3>Gino's</h3><p>Thin crust, long queue.</p></article>
  <article><h3>La Bella</h3><p>Four things on the menu.</p></article>
</feed-elemental>
<button type="button" class="more">Load more</button>

| Attribute | Type | Default | Description | | ----------- | ------ | ------- | -------------------------------------------------------------------------------------------- | | auto-load | number | β€” | How many times the feed may ask for more on its own as the last article comes into view. Absent and it never does. | | total | number | β€” | How many articles there are in all. Absent and each one says aria-setsize="-1". |

role="feed" is a contract rather than a widget, and the standing criticism of it is that pages ship the role and none of the rest. The rest is this: aria-posinset and aria-setsize on every article, a name pointed at the article's own heading, aria-busy while a load is out, Page Up/Page Down between articles and Ctrl+End past the whole feed β€” which lands on the button after it, so the way out and the way on are the same key.

It does not fetch. feed-load hands over a count, an AbortSignal and a wait(promise), and whatever you append is what it indexes. It does not scroll forever either: auto-load is a budget, not a switch, and once it is spent the button β€” a click away from feed.load() β€” is the only way on. Without script the articles are articles, in order.

<field-elemental>

Wrap a label and a control, and the browser's refusal to submit becomes a sentence under the field β€” tied to it, announced, and yours to style.

<field-elemental>
  <label for="email">Email address</label>
  <input type="email" id="email" name="email" required />
</field-elemental>

No attributes. The constraints are already on the control β€” required, type, pattern, minlength β€” and none of them is re-implemented here; what the platform leaves undone is the half after the refusal. The native bubble cannot be styled, disappears the moment the reader clicks the field to fix it, is shown for the first invalid control and no other, and is not reliably announced. So every form on the web either lives with it or hand-writes a replacement, and the replacement is where the accessibility goes: a red paragraph that no aria attribute ties to the field trades WCAG 3.3.1 Error Identification for a colour.

Invalid, the field ends up as this β€” the control given an id if it had none, the message taking that id plus -error:

<input type="email" id="email" name="email" required
       aria-invalid="true" aria-describedby="email-error" />
<p class="field-elemental-error" id="email-error">Please fill out this field.</p>

Valid again, the <p> is hidden and empty, aria-invalid comes off rather than going to false, and aria-describedby goes back to whatever it held β€” a hint you wrote is never lost and never left describing a field with nothing wrong with it.

aria-describedby and not aria-errormessage, which is the attribute written for exactly this and still not the one that works: Adrian Roselli's testing found it "generally not exposed when navigating through fields" against aria-describedby being "consistently exposed". There is no live region on the message either β€” aria-describedby is already announced when focus leaves the field, and aria-live on top of it is the same sentence twice in NVDA and JAWS and stops VoiceOver reading the description at all.

When it speaks is neither the bubble's one moment nor a validator's every keystroke: a refused submit always says why; focus leaving says why only if something was typed in; typing never starts a complaint but does end one that is answered. Write the <p class="field-elemental-error"> yourself with a server's message in it and the element adopts it β€” same wiring, no second code path, and a working page with no script.

There is no message vocabulary β€” setCustomValidity() is the platform's own, and whatever it holds is what appears. It styles no control: [aria-invalid="true"] is on the control the whole time the message is up, and the rim, the ring and the tint are your CSS. field-validity fires whenever the message appears, changes or goes.

<marquee-elemental>

A strip that scrolls forever β€” a logo wall, a ticker β€” built out of the list you already wrote:

<marquee-elemental aria-label="Sponsors" speed="60">
  <ul>
    <li><a href="/ferrum"><img src="/logo/ferrum.svg" alt="Ferrum &amp; Co."></a></li>
  </ul>
</marquee-elemental>

| Attribute | Type | Default | Description | | ------------- | ------- | --------------------------- | ---------------------------------------------------------------------- | | speed | number | 50 | Pixels a second. Anything not a positive number is the default. | | reverse | boolean | off | Travel the other way. Flipped again under dir="rtl". | | no-controls | boolean | off | Do not write the stop button β€” the mechanism becomes yours. | | play-text | string | Start the moving content | The button's accessible name while stopped. | | pause-text | string | Stop the moving content | Its name while moving. |

There is no APG pattern here, because nothing is operated. What there is instead is an obligation, and it is a Level A one: WCAG 2.2 SC 2.2.2 Pause, Stop, Hide asks for a mechanism to stop movement that starts on its own and runs past five seconds, and an infinite loop runs past five seconds. Every marquee in the ecosystem leaves that mechanism to you β€” the CSS-only recipes pause on :hover, which no keyboard has, and react-fast-marquee, Nuxt UI and Chakra UI hand you a prop and a hook to build the button out of. This writes the button.

The second half is the one nobody has. A seamless loop is copies of the track, and a copy of a logo strip is a copy of its links: aria-hidden keeps them out of a screen reader and does nothing whatever about Tab, so the keyboard walks into copies of the same links scrolling past under the focus ring. The copies here are inert as well, and their ids are stripped, because a duplicated id is the same bug one layer down.

The copies are counted, not guessed. Two of everything is the usual recipe and it is right for one screen width; cover a wide monitor with a short track and the loop shows a hole. This measures the track against the container and clones until the strip covers it, again whenever a resize changes the answer β€” one copy when the track already fills the container, up to 20 when it does not, and none at all while nothing is moving, so a reader on prefers-reduced-motion is not handed copies of a page they never asked to see move. The button still says Start, because asking a system for less movement is not the same as never wanting this strip to move.

The pointer, focus and the button all hold it still β€” the first two everywhere except over the button itself, which sits on the strip: counting it would stop the strip as the pointer arrived, while the button still read Stop, so the press changed nothing anybody could see. With no script there is no strip at all: every rule in the stylesheet is behind :defined, so the logos wrap the way any list of things does.

<menu-elemental>

A <button> and the nested lists it opens β€” plus one thing the APG has no opinion about, because it is a layout question: below a breakpoint the whole thing stops being a menu.

<menu-elemental flyout-when="(min-width: 60rem)">
  <button>Account</button>
  <ul>
    <li><a href="/profile/">Profile</a></li>
    <li>
      <button>Preferences</button>
      <ul>
        <li><a href="/preferences/theme/">Theme</a></li>
      </ul>
    </li>
  </ul>
</menu-elemental>

| Attribute | Type | Default | Description | | --------- | ------- | ------- | --------------------------------------------------------------------------------- | | flyout-when | string | β€” | The query the flyout exists in. Outside it, nested disclosures. Unset means always a menu. | | open | boolean | false | Whether the root list is showing. Reflected. |

Inside flyout-when it is the APG Menu Button: role="menu", items out of the tab order, arrows and Home/End to move, Right/Left in and out of a branch, type-ahead, Escape back to the trigger, one branch open at a time.

Outside it, the roles come off. role="menu" is a promise that the arrows work and Tab does not, and on a phone the same markup is a stack of nested disclosures in a drawer β€” links you tab through, branches that stay where you left them. Two widgets, one set of markup, the viewport picks. The element writes data-mode so your CSS reads the breakpoint back off it instead of repeating the query.

Tab is never trapped: nothing behind the menu is inert, and a keyboard visitor who cannot tab out of a dropdown is stuck on your page.

For site navigation rather than commands, this is the wrong element and <navbar-elemental> is the right one β€” role="menuitem" costs the link semantics, and a page somebody might open in a new tab wants to stay a link. The docs page lays out the trade.

<modal-elemental>

A <dialog> opened as a modal β€” and opened by the browser, which is the whole argument for the element. showModal() already puts it in the top layer, makes the rest of the page inert, moves focus in, brings it back on close and closes on Escape. Nesting comes with that: a second modal is a second entry in the top layer, and the browser computes inertness from the topmost one, so nothing here tracks parents or arbitrates z-index.

<button type="button" command="show-modal" commandfor="confirm">Sign out</button>

<modal-elemental closedby="any">
  <dialog id="confirm">
    <h2>Sign out everywhere?</h2>
    <form method="dialog">
      <button type="submit" value="cancel">Cancel</button>
      <button type="submit" value="ok">Sign out</button>
    </form>
  </dialog>
</modal-elemental>

| Attribute | Type | Default | Description | | --------------- | ------- | -------------- | -------------------------------------------------------------------------- | | closedby | enum | closerequest | any, closerequest or none β€” HTML's own three values. Moved up from the <dialog> if written there | | close-others | boolean | β€” | Opening this one closes every modal already open, instead of stacking | | close-text | string | Close | The close button's accessible name |

What the platform leaves behind is the whole of the element: an exit animation, which otherwise needs the overlay property that Firefox and Safari do not have; a click on the backdrop, which needs closedby support Safari does not have either; the page behind not scrolling; the pile of backdrops a stack of modals would paint on top of each other, of which only the bottom one dims; and an aria-labelledby pointed at the first heading, since a <dialog> takes no name from its contents.

Every modal gets a cross in the corner, written by the element as its dialog's first child β€” the APG asks for a visible close button, and HTML gives you <form method="dialog"> and leaves the rest. It is positioned in the corner of the dialog, which a modal <dialog> is already the containing block for. closedby="none" gets no cross: that value is a dialog to be answered rather than dismissed.

Triggers are HTML's own invoker commands β€” command="show-modal" and commandfor β€” handled by the element rather than left to the browser, which is what animates the close and what makes them work where invoker commands have not landed yet. A <a href="#id"> opens one too, which is what deep links, the back button and a page with no script all ride on.

Closing stops what the modal was playing: <video> and <audio> are paused, and an <iframe> is parked at about:blank until the modal opens again, since a cross-origin player takes no instructions from here and a frame reloaded in place is a frame a lazy load leaves playing. That is all the lightbox and the YouTube embed on the docs page are β€” markup in a dialog, with nothing switched on.

It replaces modally, which is deprecated: nesting, closeOthers and hash-driven opening are all here, and the width and alignment options are custom properties, which is what they always were.

<navbar-elemental>

A site's navigation: a row of links, some of them opening a panel of more links, plus the two things such a row always ends up needing β€” somewhere for the ones that do not fit to go, and a way to be a drawer instead on a narrow screen.

<navbar-elemental bar-when="(min-width: 40rem)" hover>
  <div class="rail">
    <ul>
      <li><a href="/overview">Overview</a></li>
      <li>
        <button>Products</button>
        <ul>
          <li><a href="/cloud">Kestrel Cloud</a></li>
        </ul>
      </li>

      <!-- where the ones that do not fit go. The element fills it. -->
      <li data-navbar-more>
        <button>More</button>
        <ul></ul>
      </li>
    </ul>
  </div>

  <button data-navbar-toggle aria-label="Navigation"></button>
</navbar-elemental>

| Attribute | Type | Default | Description | | --------- | ------- | ------- | ----------------------------------------------------------------------------- | | bar-when | string | β€” | The query the bar exists in. Outside it, the drawer. Unset means a bar at every width. | | open | boolean | false | Whether the drawer is showing. Reflected. | | hover | boolean | false | A mouse opens a panel by pointing at it too. Never on touch, never stacked. |

Three optional hooks name the parts structure cannot: data-navbar-more on the last <li> is the overflow item, data-navbar-toggle on a button opens the drawer, and data-navbar-stack marks an item as the drawer's alone β€” a sign-in link, a language picker β€” so it never sits on the bar and is never measured against it.

The breakpoint that folds the links away is measured, not declared. How many links a site has, how long their labels are in the reader's font, whether that font has even arrived β€” none of it is knowable when the query is written, which is why a hand-picked width hides three short links on a tablet with room to spare. An IntersectionObserver watches a copy of the row instead: items that do not fit leave it one at a time and reappear under the overflow button. The copy and not the row, because an observer watching the box it is also changing is an infinite loop that eats a navigation one frame at a time. bar-when is the separate question of when the whole bar becomes a drawer, and it is a query because nothing the element does can change the width of the window.

data-mode is bar or stack, so your CSS reads the mode back off the element. The two are different widgets: on the bar the panels float over the page one at a time and a click outside closes them; in the drawer they are in the flow, stay where you left them, and are hidden with hidden="until-found" so find-in-page reaches a link inside a closed one.

No role anywhere, which is the pattern rather than an omission β€” the APG's own navigation menubar example opens by talking you out of itself, and links announced as menu items are links no longer. Panels stay on screen through CSS anchor positioning, with no script involved. Without JavaScript the whole thing is a nested list of visible links, which is what it was underneath all along.

<password-elemental>

A reveal button for a password field: the state in aria-pressed, the change announced, and the mask back on before the value is submitted.

<password-elemental>
  <input type="password" id="pw" name="password" autocomplete="current-password" />
  <button type="button"><span class="visually-hidden">Show password</span></button>
</password-elemental>

| Attribute | Type | Default | Description | | -------------- | ------- | --------------------------- | ------------------------------------------------------------------------ | | shown | boolean | false | Whether the value is visible. Reflected, and settable from script. | | label | string | Show password | The button's accessible name. Fixed on purpose β€” aria-pressed carries the state. | | shown-text | string | Your password is visible | What the live region says on reveal. | | hidden-text | string | Your password is hidden | What it says when the mask goes back on. |

No APG pattern, because there is no widget: a <button> next to an <input>, both already accessible. What is missing is the state. Most reveal buttons swap an eye for a crossed-out eye, which tells a sighted reader which way round it is and nobody else; the single most announce-worthy change on the page is announced nowhere.

The name is fixed and aria-pressed carries the state, which is the one thing the prior art disagrees about. GOV.UK swaps Show ⇄ Hide and leaves the state implied; Make Things Accessible keeps the name and uses aria-pressed; some do both, which says it twice. Of the two that are self-consistent this takes the toggle β€” the state is exposed programmatically rather than inferred from a verb, and nothing changes under a reader's focus, where a swapped name is re-announced by some screen readers and silently not by others. A role="status" region says which it now is on every press; status and not alert, because the reader pressed the button.

Submitting the form masks it again, always, and that is not configurable. A revealed field posts from an <input type="text">, and browsers remember what was typed into text fields β€” a reader who revealed their password would be offered it back in an autofill list on some unrelated page later. Nothing is traded for it: submit fires only when the form really is being submitted, so a refused submit leaves the field exactly as the reader left it.

It nests inside <field-elemental> and neither knows about the other. It does not measure strength, generate a password, confirm a second field, or style the input. Without script it is a field and a button that does nothing β€” so write the button as type="button", which is what keeps it from submitting the form.

<progress-elemental>

A native <progress> that says where its fill ends, so CSS can draw the bar instead of ::-webkit-progress-value and ::-moz-progress-bar β€” and a second value beside it, for the part that is loaded but not yet played:

<label for="upload">Uploading</label>
<progress-elemental buffer="82">
  <progress id="upload" value="45" max="100">45%</progress>
</progress-elemental>

The <progress> stays a <progress>, which is where role="progressbar", max, the indeterminate state and being labelled by a <label> already live β€” so the element writes no ARIA at all. What it writes is --progress-elemental-value and, with a buffer, --progress-elemental-buffer, both as percentages, plus data-indeterminate when there is no value: a bar with no value is a claim that nobody knows how far, and a bar at zero is a claim that nothing has started, and the two must not draw the same.

<progress> fires no event and both its value and its max are reflecting IDL attributes, so one MutationObserver on the child catches every way of moving it β€” bar.value = 60, bar.progress.value = 60 and setAttribute all land in the same place. Without the script the theme draws nothing, because it hangs off :defined: what shows is the browser's own bar with the real value on it, rather than a themed bar frozen at zero.

<rearrange-elemental>

A list, a table body, or a board of named columns the reader puts in their own order:

<rearrange-elemental drag>
  <ol>
    <li>Bananas</li>
    <li>Coffee</li>
  </ol>
</rearrange-elemental>

| Attribute | Type | Default | Description | | --------------- | ------- | ------------------------------------------------- | ------------------------------------------------------------------------------- | | drag | boolean | false | Let a pointer drag the items as well, by a grip the element adds to each one. The buttons stay either way. | | up-text | string | Move {label} up | The first button's name. {label} is the item's. | | down-text | string | Move {label} down | The second button's name. | | to-text | string | Move {label} to {container} | A board's crossing buttons. {container} is the column the item would land in. | | moved-text | string | {label} moved to position {position} of {total} | What the live region says after a move inside one list. | | moved-to-text | string | {label} moved to {container}, position {position} of {total} | What it says after a crossing. |

On the items: data-label names one in the buttons and announcements instead of its own text, data-rearrange-handle is dragged instead of the grip the element writes, and data-rearrange-cell picks which cell of a <tr> the controls go in β€” its last cell otherwise.

There is no APG pattern for rearranging; the nearest is the rearrangeable listbox example, and what is taken from it is the part that is not the listbox. The buttons are the element and drag is the option on top. Every pointer-drag library does this the other way round and WCAG 2.2 SC 2.5.7 asks for the opposite, so written this way the criterion is met before the first drag event. The fast path is Alt and an arrow key; Esc puts a drag back where it started.

Several named lists in one element is a board, with no attribute for it β€” two lists side by side have already said so. Each item then also gets a button for the column on either side of it, named by where it lands and never by a direction, since "move right" is a sentence a reader off the screen cannot use. A list in a board with no aria-labelledby or aria-label throws, naming the attribute it wanted. The keyboard path across is Alt + Shift + a sideways arrow, because Alt and a sideways arrow alone is the browser's Back and Forward; both flip in a right-to-left layout.

One rearrange-move per landing carries item, from, to, fromContainer, toContainer and sameContainer β€” branch on the last, since from and to count inside their own column and a card that keeps its place while changing column reports 0 to 0. Nothing is persisted, items arriving later need .update(), and a board wider than its viewport is scrolled by hand mid-drag. Not for a table that is also a <sortable-table-elemental>: that one derives the order from a key in the cells, and this is a hand order nothing derives.

<segmented-elemental>

One choice out of a few, drawn as a track with a knob that slides under it β€” the N-state answer to the switch, on the radio group you would have written anyway:

<segmented-elemental aria-label="Range">
  <label><input type="radio" name="range" value="day" /> Day</label>
  <label><input type="radio" name="range" value="week" checked /> Week</label>
  <label><input type="radio" name="range" value="month" /> Month</label>
</segmented-elemental>

The segments stay <input type="radio">, which is where the whole APG Radio Group pattern already lives β€” arrows that move the selection and wrap, Tab in and out of the group once, submission under the shared name, required, reset, restore, and a <fieldset disabled> that takes the lot. None of it is rewritten here, which is why the element has no roles, no aria-checked and no event of its own: a radio fires change, and change bubbles.

What is left for script is the one thing CSS cannot say for an unknown number of segments β€” which one is checked. The element writes --segmented-elemental-index, --segmented-elemental-count and data-index onto itself, and the theme's knob is a single pseudo-element one track wide that translates by the index. Without the script there is no knob at all rather than a knob parked on the first segment, and the selected label still takes its colour, because that comes from label:has(> input:checked) and needs nobody's help. The only ARIA it writes is role="group", and only when you have given the element an aria-label that would otherwise be read by nothing.

<search-elemental>

The query half of a search field, and the other side of <suggest-elemental>'s seam: it decides when to ask, hands you an AbortSignal, and turns what comes back into a state your CSS can draw and a sentence a screen reader hears. It does not fetch.

<search-elemental min="2">
  <search>
    <form action="/search/">
      <label for="q">Search</label>
      <input type="search" id="q" name="q" autocomplete="off" />
    </form>
  </search>
  <suggest-elemental for="q"><ul></ul></suggest-elemental>
</search-elemental>
search.addEventListener('search-query', (e) => {
  e.detail.wait(
    fetch(url + encodeURIComponent(e.detail.query), { signal: e.detail.signal })
      .then((r) => r.json())
      .then((rows) => list.replaceChildren(...rows.map(toRow)))
  );
});

| Attribute | Type | Default | Description | | --------------- | ------ | -------------- | ---------------------------------------------------------------- | | delay | number | 200 | Milliseconds the field has to stop changing before a query goes out. | | min | number | 1 | Characters needed before one goes out at all. 0 sends the empty query too. | | results-text | string | 5 results | Announced on a hit. {n} is the count. | | empty-text | string | No results | Announced when nothing matched. | | error-text | string | Search failed| Announced when the request failed. |

One request per pause instead of one per keystroke, one AbortController per query, and a sequence number that drops the slow answer arriving after the fast one β€” the bug that leaves results for car on screen under a field reading carousel. data-state runs idle β†’ pending β†’ results / empty / error, aria-busy goes on the panel, and the count is announced through a role="status" region the element appends, which is WCAG 2.2 4.1.3 met rather than skipped.

wait() is what buys the loading state; a page filtering a list it already has never calls it and never gets a spinner nothing will stop. With no script the <form> submits and the reader gets a search page.

<slider-elemental>

One <input type="range"> inside it is a slider; two is a range whose thumbs cannot pass each other. The count is the markup rather than an attribute, because it already is:

<span id="price-label">Price</span>
<slider-elemental aria-labelledby="price-label" gap="50">
  <input type="range" aria-label="Lowest price" min="0" max="1000" value="200" />
  <input type="range" aria-label="Highest price" min="0" max="1000" value="750" />
</slider-elemental>

The thumbs stay <input type="range">, which is where the whole APG Slider pattern already lives β€” arrows, Home, End, PageUp/PageDown, step, touch, submission under each input's own name, reset, restore and a <fieldset disabled> that takes the lot. So there is no role="slider" here, no aria-valuenow, and no event of its own: a range input fires input and change, and both bubble.

What is left for script is what the platform will not say. Firefox draws the filled part of a track with ::-moz-range-progress and no other engine has an equivalent, so the element writes --slider-elemental-start and --slider-elemental-end and the fill is one box between them. They are ratios rather than percentages on purpose: a thumb travels from half its own width to half a width short of the far end, so a bare percentage is off by half a thumb at both ends β€” the misalignment nearly every two-input slider on the web has.

Two thumbs add the three things a second range input cannot do for itself: they are stacked so they share one track, the low one is stopped at the high one's value (gap sets how far short), and a press on the track moves the nearer of them, which the stacking would otherwise cost. aria-valuemin and aria-valuemax are deliberately not written β€” HTML-ARIA says authors should not put them on a range input, and rescaling one input to clamp it would move every pixel on it.

<sortable-table-elemental>

Wrap a <table> and its column headers sort it. You write the table; it writes the buttons.

<sortable-table-elemental>
  <table>
    <caption>Peaks</caption>
    <thead>
      <tr><th>Name</th><th>Height</th><th>First climbed</th><th data-sort="none">Notes</th></tr>
    </thead>
    <tbody>
      <tr><th scope="row">MidΕΎor</th><td>2169</td><td data-sort-value="1890-07-02">2 Jul 1890</td><td>on the border</td></tr>
    </tbody>
  </table>
</sortable-table-elemental>

| Attribute | Type | Default | Description | | ----------- | ------ | --------------------------- | -------------------------------------------------- | | note-text | string | a sentence about the buttons | What the caption says about them, off screen. |

No APG pattern, because <table> already is one β€” the roles, the row and column relationships and the header associations are the element's own. This adds only what the APG's sortable table example describes: the header text wrapped in a <button>, aria-sort on the sorted column, and a note appended to the <caption> saying what the buttons are β€” once, rather than repeated into every button's name. No live region: the rows reordering is the result of pressing the button, not a message about it, so 4.1.3 is not what this is. No keyboard handling either β€” the only interactive things here are <button>s.

data-sort="none" on a <th> leaves that column without one. data-sort-value on a cell is what it sorts by, which is how 3 Aug 2026 sorts by 2026-08-03 and $1,200 sorts above $900. There is no column-type vocabulary: one Intl.Collator with numeric: true, pointed at the document's lang, puts item 2 before item 10 and collates letters the way the page's language says. Sorting is stable, so sorting by name and then by height leaves the names in order inside each height.

An aria-sort already in the markup is believed rather than re-sorted β€” a table that arrived ordered was ordered by the server, possibly by a key that is not in the DOM at all.

No multi-column sort, no persistence, no paging, no filtering, no row selection; those are a data grid. Without script it is your table, in the order it arrived.

<splitter-elemental>

Two panes and a handle between them that gives one what it takes from the other β€” a sidebar you can widen, an editor beside its preview. You write the two boxes; it writes the handle.

<splitter-elemental position="35" min="15" max="70" label-text="Sidebar">
  <nav>…</nav>
  <article>…</article>
</splitter-elemental>

| Attribute | Type | Default | Description | | ------------ | ------- | -------- | ------------------------------------------------------------------------------- | | position | number | 50 | Where the separator sits, as a percentage of the track. Reflected, and written back as the handle is dragged. | | min | number | 0 | How far the primary pane may shrink. aria-valuemin, and the floor for Enter as well as for the drag. | | max | number | 100 | How far it may grow. aria-valuemax. | | vertical | boolean | false | The panes are stacked down the page rather than side by side. | | label-text | string | Resize | The handle's accessible name. The pattern asks for the separator to be named after the primary pane. |

The Window Splitter pattern is the one in the APG with no example to copy β€” work on one is tracked by issue 130 β€” so the keyboard here is read off its prose: arrows move the separator a per cent at a time, Home and End take the primary pane to its smallest and largest allowed size, and Enter collapses it and puts it back. The arrows on the other axis are left to the page, which is how a reader inside a pane still scrolls it.

The first element child is the primary pane: the one position measures, the one aria-controls names, the one Enter collapses. min and max bound the pointer and the keys alike, so a min above zero is you saying the pane may not disappear.

vertical describes the panes and aria-orientation describes the separator, and the two are opposites β€” stacked panes are split by a line lying across the page, so vertical writes aria-orientation="horizontal". A stacked splitter also needs a height of its own: percentage row tracks in an auto-height grid resolve as auto.

The handle is 24px thick because that is WCAG 2.2 2.5.8's minimum target, and the theme draws three dots inside it, one --splitter-elemental-dot-size across β€” the grip every other splitter makes you hit is a pseudo-element here, not the target. splitter-change fires when the gesture is over; a pane that has to keep up with the drag wants a ResizeObserver on itself.

Not compare-images-slider, which puts the same role="separator" on a handle and does the other thing with it: that one reveals β€” two full-size layers, one clipped over the other, nothing resized.

<suggest-elemental>

A list of links a text field drives with the arrow keys β€” the results panel a search box, a filter and a "jump to" field all end up needing, and only the half of it that has nothing to do with where the results came from.

<input type="search" id="q" autocomplete="off" />
<suggest-elemental for="q">
  <ul>
    <li><a href="/docs/install/">Install</a></li>
    <li><a href="/docs/config/">Configuration</a></li>
  </ul>
</suggest-elemental>

| Attribute | Type | Default | Description | | --------- | ------- | ------- | ------------------------------------------------------------------------------- | | for | string | β€” | id of the text field that drives it. Without it the element does nothing. | | open | boolean | false | Whether the panel is showing. Reflected, and settable so whatever fills the list can show it. |

Only <a href> becomes an option β€” a row that goes nowhere is a dead line on the list. The <ul> and its <li>s are mar