book-of-elementals
v3.3.0
Published
π Stamat's Book of Elementals β custom elements, accessible, light DOM.
Maintainers
Keywords
Readme
π Book of Elementals

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-elementalsImport 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 sharednameso 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-toggleevent on the group, sincetoggledoes 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.cssIt 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 & 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
