Toolbar
Toolbar is a sticky top page toolbar with start, center, and end layout
slots and a burger button that opens a slide-in panel below a set mobile
breakpoint. The toolbar only manages that button and the panel's own
mechanics — opening/closing, focus, scroll lock ; what shows up inside is
entirely up to you, slotted in directly with no slot attribute. On top
of that base bar, three independent, opt-in behaviors are available:
shrinking the bar once the page scrolls, stripping its background,
border, and shadow while scroll is at the top, and flipping the bar
between light and dark as marked page sections scroll underneath it.
Dependencies
When to use
Reach for toolbar for the primary top-of-page navigation bar, the one row
someone sees on every page, holding branding, top-level navigation, and
account or call-to-action buttons. It's built for that single, page-wide
role: one instance per page (or per app shell), sticky by default. For
navigation that lives beside the page's content instead of above it, such
as a sidebar menu, use
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/toolbar.js";
import "@eunomia/elements/button.js";
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<a slot="center" href="/product">Product</a>
<a slot="center" href="/pricing">Pricing</a>
<a slot="center" href="/docs">Docs</a>
<eun-button slot="end" size="s">Sign in</eun-button>
</eun-toolbar>
Importing toolbar.js registers <eun-toolbar>. It renders eun-icon and
eun-button internally (burger button, panel close button), see the
dependencies listed above.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/toolbar.js";
import "@eunomia/elements/button.js";
</script>
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<a slot="center" href="/product">Product</a>
<a slot="center" href="/pricing">Pricing</a>
<eun-button slot="end" size="s">Sign in</eun-button>
</eun-toolbar>
npm install @eunomia/elements
import "@eunomia/elements/toolbar.js";
import "@eunomia/elements/button.js";
function AppToolbar() {
return (
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<a slot="center" href="/product">
Product
</a>
<a slot="center" href="/pricing">
Pricing
</a>
<eun-button slot="end" size="s">
Sign in
</eun-button>
</eun-toolbar>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/toolbar.js";
import "@eunomia/elements/button.js";
export function AppToolbar() {
return (
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<a slot="center" href="/product">
Product
</a>
<a slot="center" href="/pricing">
Pricing
</a>
<eun-button slot="end" size="s">
Sign in
</eun-button>
</eun-toolbar>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/toolbar.js";
import "@eunomia/elements/button.js";
</script>
<template>
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<a slot="center" href="/product">Product</a>
<a slot="center" href="/pricing">Pricing</a>
<eun-button slot="end" size="s">Sign in</eun-button>
</eun-toolbar>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/toolbar.js";
import "@eunomia/elements/button.js";
@Component({
selector: "app-toolbar",
template: `
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<a slot="center" href="/product">Product</a>
<a slot="center" href="/pricing">Pricing</a>
<eun-button slot="end" size="s">Sign in</eun-button>
</eun-toolbar>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ToolbarComponent {}
Alternatives
eun-drawerGuidance
- Slot your primary nav directly (no
slotattribute) so it lands in the burger's mobile panel; put whatever should also stay visible on desktop incenter/endtoo, nothing relocates automatically between them - Set
skip-link-targetto your page's main-content id: a real, working skip link costs one attribute. Need more than one bypass target (navigation, main content, a footer, ...)? Use theskip-linksslot instead - Pair
adaptive-theme'sdata-toolbar-thememarkers with sections that are genuinely dark/light, not every section: unmarked sections simply keep whateverthemewas last resolved - Set both
--toolbar-background-light/--toolbar-color-lightand their-darkcounterparts together whenever usingthemeoradaptive-theme, so contrast holds in both states
- Combining
transparent-at-topwith low-contrast text over an unpredictable hero background: pair it withadaptive-themeinstead of guessing a single color that reads on everything - Setting
collapse-thresholdso low that the bar visibly flickers between states on a page that bounces near the top (e.g. elastic overscroll) - Slotting content that must always stay visible (like a search field) with no
slotattribute: that only ever renders inside the closed-by-default mobile panel — put it inendinstead, which never moves
Live testing
Properties
Toolbar <eun-toolbar>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| logo-src | string | — | The image URL for the start slot's fallback logo |
| logo-alt | string | 'Home' | The alt text for the fallback logo image |
| logo-href | string | '#' | The destination the fallback logo links to |
| mobile-breakpoint | number | 768 | The viewport width in pixels below which the burger button and its sliding panel become available |
| panel-direction | 'top' | 'bottom' | 'left' | 'right' | 'top' | Which edge the mobile panel slides in from |
| panel-size | string | — | A custom open size for the mobile panel, height for top or bottom, width for left or right. Any CSS length, defaulting to 100% |
| close-on-backdrop | boolean | true | Whether clicking outside the mobile panel closes it |
| aria-label-menu-open | string | 'Open menu' | The accessible label of the burger button while the panel is closed |
| aria-label-menu-close | string | 'Close menu' | The accessible label of the burger button while the panel is open |
| aria-label-menu | string | 'Menu' | The accessible name of the mobile panel itself |
| aria-label | string | 'Main navigation' | The accessible name of the toolbar's navigation landmark |
| collapse-on-scroll | boolean | false | Enables the scroll-triggered collapse mode |
| collapse-threshold | number | 24 | Pixels scrolled, of the scroll target or the page, past which the bar collapses |
| transparent-at-top | boolean | false | Enables the transparent-while-at-the-top mode |
| at-top | boolean | true | Whether the scroll target or the page is currently scrolled to zero. Kept in sync automatically while transparentAtTop is set |
| scroll-target | string | — | A CSS selector for a custom scroll container to track instead of the page, for collapseOnScroll and transparentAtTop |
| adaptive-theme | boolean | false | Enables automatic theme switching driven by marked page sections scrolling underneath the bar |
| adaptive-theme-selector | string | '[data-toolbar-theme]' | A selector for the marker elements adaptiveTheme observes. Each should carry a dark or light marker attribute |
| default-theme | 'light' | 'dark' | 'light' | The fallback theme used while adaptiveTheme is on and no marked section currently intersects the bar |
| skip-link-target | string | — | The id of the page's main-content landmark. When set, and nothing is slotted into skip-links, renders a real skip to main content link as the very first focusable element, visually hidden until keyboard-focused. For more than one bypass target, such as navigation, main content, or footer, use the skip-links slot instead |
| skip-link-label | string | 'Skip to main content' | The text of the default skip link built from skipLinkTarget |
| position | 'sticky' | 'fixed' | 'static' | 'sticky' | How the toolbar is positioned relative to the page |
| open | boolean | false | Whether the mobile menu panel is open |
| collapsed | boolean | false | Whether the bar is currently in its collapsed state. Kept in sync automatically while collapseOnScroll is set |
| theme | 'light' | 'dark' | 'light' | The toolbar's current color scheme. A plain manual switch when adaptiveTheme is off, and driven by the marker observer, though still settable to seed the initial value, when it's on |
Import the exact TypeScript type behind any property above, see
Slots
| Name | Description |
|---|---|
| start | Left-side content, rendered after a logo image linking to logoHref whenever logoSrc is set |
| center | Center content, truly centered independently of the start and end widths |
| end | Right-side content |
| (default) | The mobile slide-in panel's content, opened by the burger button below the mobile breakpoint. Entirely up to the consumer ; never rendered anywhere else |
| skip-links | Custom skip to X links, such as skip to navigation, main content, or footer, replacing the single default skip link built from skipLinkTarget and skipLinkLabel. Accepts plain `a` elements or `eun-link`, styled the same either way, separated by a thin divider drawn between them rather than on either link itself, so it never ends up inside either one's own focus ring. Hidden as a group, revealed as a small panel opening from the top-start corner as soon as focus lands on any of them, staying open while Tab cycles between them. Clicking any of them, default or slotted, also focuses its `href` target directly, on top of the native anchor jump, since that native jump alone doesn't reliably move focus across browsers |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired whenever open, collapsed, or, under adaptiveTheme, theme changes |
| eunnavigate | NavigationEvent | Fired before following the fallback logo's link, so a client-side router can intercept the navigation. Cancelable |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --toolbar-height | Sets the expanded height of the bar |
| --toolbar-height-collapsed | Sets the collapsed height of the bar, for collapseOnScroll only |
| --toolbar-padding | Sets the padding of the bar |
| --toolbar-gap | Sets the gap between the start, center, and end columns themselves, and the fallback for the three below wherever they're unset |
| --toolbar-gap-start | Sets the gap between items inside the start slot, falling back to --toolbar-gap |
| --toolbar-gap-center | Sets the gap between items inside the center slot, falling back to --toolbar-gap |
| --toolbar-gap-end | Sets the gap between items inside the end slot, falling back to --toolbar-gap |
| --toolbar-background | Sets the background color, overriding both light and dark variants below |
| --toolbar-color | Sets the text color, overriding both light and dark variants below |
| --toolbar-background-light | Sets the background color for the light theme |
| --toolbar-color-light | Sets the text color for the light theme |
| --toolbar-background-dark | Sets the background color for the dark theme |
| --toolbar-color-dark | Sets the text color for the dark theme |
| --toolbar-border-color | Sets the bottom border color |
| --toolbar-shadow | Sets the shadow of the bar |
| --toolbar-z-index | Sets the stacking order of the bar |
| --toolbar-transition-duration | Sets the duration of the collapse, transparency, theme, and panel transitions |
| --toolbar-logo-height | Sets the desired height of the default logo image ; never rendered taller than the bar's own current height regardless |
| --toolbar-logo-filter-dark | Sets a filter applied to the logo image in dark toolbar mode |
| --toolbar-panel-size | Sets a custom open size for the mobile panel, also settable through the panel-size attribute |
| --toolbar-panel-background | Sets the background of the mobile panel, defaulting to a translucent mix of the bar's own background |
| --toolbar-panel-color | Sets the text color of the mobile panel |
| --toolbar-panel-padding | Sets the padding of the mobile panel |
| --toolbar-panel-gap | Sets the gap inside the mobile panel |
| --toolbar-panel-backdrop-blur | Sets a backdrop blur radius for the mobile panel |
| --toolbar-skip-links-height | Sets the height of the skip-links bar |
| --toolbar-skip-links-background | Sets the background of the skip-links bar, defaulting to a darker mix of the bar's own background |
| --toolbar-skip-links-color | Sets the text color of the skip-links bar |
| --toolbar-skip-links-separator-color | Sets the color of the divider drawn between skip links |
Slots
start/center/end lay out independently. center stays truly
centered regardless of how wide start/end are, via CSS Grid rather than
flexbox space-between. Anything slotted in without a slot attribute
doesn't render in the bar at all — it's the mobile panel's own content,
opened by the burger button below mobile-breakpoint (see "Mobile menu"
below).
<eun-toolbar>
<strong slot="start">Eunomia</strong>
<span slot="center">Centered, independent of start/end width</span>
<eun-button slot="end" size="s" appearance="ghost" icon="settings"
>Settings</eun-button
>
<eun-button slot="end" size="s" icon="login" primary>Sign in</eun-button>
</eun-toolbar>
Logo
Set logo-src/logo-alt/logo-href for a ready-made home-linking logo,
rendered at the start of start whenever logo-src is set, before
whatever else is slotted into start, not replaced by it. Leave logo-src
unset to slot your own logo into start instead. Clicking it dispatches the
same cancelable eunnavigate event eun-link does, letting an SPA router
intercept it (see eun-link's own "SPA navigation" section for the pattern).
--toolbar-logo-height sets the desired height, but the bar's own
current height (--toolbar-height, or --toolbar-height-collapsed while
collapsed) is always the hard ceiling, so an oversized logo asset can
never make the bar itself grow. The same ceiling applies to anything else
slotted into start/center/end too, including your own logo if you
slot one in instead of using logo-src, so a high-resolution image never
blows out the bar's height by accident.
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<eun-link slot="end" href="/docs" icon="menu_book">Docs</eun-link>
</eun-toolbar>
Logo with start content
The logo doesn't get replaced by start content. It's rendered first, and
whatever's slotted into start follows right after it, both sharing the
slot's gap. center still lands exactly centered on the viewport below,
even though start (logo + company name) and end (a single button) end up
very different widths.
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<span slot="start">Acme Inc.</span>
<eun-link slot="center" href="/product" icon="storefront">Product</eun-link>
<eun-link slot="center" href="/pricing" icon="payments">Pricing</eun-link>
<eun-link slot="center" href="/docs" icon="menu_book">Docs</eun-link>
<eun-button slot="end" size="s" icon="login" primary>Sign in</eun-button>
</eun-toolbar>
Mobile menu
Below mobile-breakpoint (default 768), a burger button appears and
opens a slide-in panel, while start/center/end keep rendering as
usual. Whether to hide any of that content below the breakpoint, and how,
is left entirely up to the project's own CSS. The panel's content is
entirely up to you too: anything slotted in without a slot attribute
lands there and nowhere else, since the toolbar only manages the button
and the panel's own mechanics. panel-direction picks which edge it
slides from, and panel-size picks how far it opens, accepting any CSS
length rather than just full-screen. The panel's background is a
translucent mix of the bar's own color (--toolbar-panel-background),
not a separate opaque sheet.
There's only ever one burger button, not a second, separate close button inside the panel. The same element opens and closes it, swapping its icon and accessible label to match, and it's also the only thing that stays visible in front of the panel once it opens. Everything else behind it, including the logo and the rest of the bar, gets covered the same way it would behind any other opaque overlay, with no manual z-index tuning needed to make that happen.
Opening the panel also makes the rest of the page properly inert, so it
becomes unclickable, untabbable, and hidden from assistive technology, and
locks page scroll for as long as it stays open. Since these are genuine
page-level behaviors, a preview that just renders inline on this page, the
way every other demo above does, would either leak the scroll lock and
inert state onto the documentation page itself, or, resized into a small
box, let the panel break out of that box and cover the real, full-size
browser window instead, misrepresenting what actually happens on a phone.
The preview below sidesteps both problems the same way the mobile layout
preview on <iframe>, its
own genuine mobile viewport, rather than a resized <div>. Tap the menu
icon to see the panel slide down and cover that entire mobile view,
exactly as it would on a real phone.
<eun-toolbar logo-src="/logo.svg" logo-alt="Eunomia" logo-href="/">
<eun-link href="/product" icon="storefront">Product</eun-link>
<eun-link href="/pricing" icon="payments">Pricing</eun-link>
<eun-link href="/docs" icon="menu_book">Docs</eun-link>
<eun-button icon="login" primary>Sign in</eun-button>
</eun-toolbar>
To try panel-direction/panel-size live instead, edit the code in the
API tab's "Live testing" playground. Note that its preview isn't a
separate viewport like the phone frame above: resizing it narrow enough
switches the bar into mobile mode, but opening the panel there covers your
actual, full-size browser window (its position: fixed targets the real
page), not just the resized preview box.
Every direction: swap panel-direction for whichever edge fits your
layout:
<eun-toolbar panel-direction="top">...</eun-toolbar>
<eun-toolbar panel-direction="bottom">...</eun-toolbar>
<eun-toolbar panel-direction="left">...</eun-toolbar>
<eun-toolbar panel-direction="right">...</eun-toolbar>
Scroll collapse
collapse-on-scroll shrinks the bar from --toolbar-height to
--toolbar-height-collapsed once the page scrolls past
collapse-threshold (px). Scroll the demo below to see it. collapsed
reflects as an attribute and fires eunchange, so page-level CSS/JS can
react to it too.
<eun-toolbar collapse-on-scroll collapse-threshold="24">...</eun-toolbar>
eun-toolbar {
--toolbar-height: 72px;
--toolbar-height-collapsed: 56px;
}
Transparent at top
transparent-at-top strips the bar's background/border/shadow while
scroll is exactly at 0, so it reads as an overlay on a hero image until
the page actually scrolls. Commonly paired with adaptive-theme so text
stays legible against whatever's behind it.
<eun-toolbar transparent-at-top>...</eun-toolbar>
Adaptive theme
adaptive-theme flips theme between light/dark as the page scrolls,
driven by an IntersectionObserver watching elements matching
adaptive-theme-selector (default [data-toolbar-theme]) for a
data-toolbar-theme="dark"/"light" marker, not live pixel sampling,
which breaks against background images/gradients/video. Mark whichever
sections need the bar to invert, and unmarked sections leave theme at
whatever it last resolved to. The observer's root follows scroll-target
automatically (the demo below scrolls a bounded box, not the whole page,
so it sets scroll-target to that box). On a real page-wide toolbar
tracking the page itself, scroll-target is unset and this needs no
extra wiring.
By default, the logo is filtered to appear white once the bar itself goes
dark, which suits a single-color logo mark but isn't always what a
multi-color one wants. Set --toolbar-logo-filter-dark to none to keep
it exactly as provided, as the demo below does for its own multi-color
icon.
<eun-toolbar adaptive-theme>...</eun-toolbar>
<section data-toolbar-theme="light">...</section>
<section data-toolbar-theme="dark">...</section>
eun-toolbar {
--toolbar-background-light: #fff;
--toolbar-color-light: #071621;
--toolbar-background-dark: #0b1620;
--toolbar-color-dark: #f5f7fa;
}
Content injected after the toolbar has already connected (client-rendered
sections) isn't picked up automatically. Call refreshAdaptiveTheme()
once it's in the DOM:
document.querySelector("eun-toolbar").refreshAdaptiveTheme();
Skip links
skip-link-target renders a real "skip to main content" link as the very
first focusable element. Skip links stay hidden as a group until one of
them is focused, at which point they reveal as a small panel opening
downward from the top-start corner (rather than each link individually
popping in and out of the same spot). It stays open while Tab moves
between them, and closes once focus leaves the group. Try it: click into
this example, then press Tab.
Main content lands here.
<eun-toolbar skip-link-target="main-content">...</eun-toolbar>
...
<main id="main-content" tabindex="-1">...</main>
Multiple skip links
Real pages often need to bypass more than one block: navigation, main
content, a footer, and so on. skip-link-target only ever renders one
link, so for more than one target, slot in as many <a href="#id">
elements as needed via skip-links instead. It's a real slot, and
skip-link-target's single link is just its native fallback content (same
fallback-content pattern as start's logo), so slotting anything into
skip-links replaces it entirely. All of them share the same panel: it
opens once with every link stacked inside as soon as any of them is
focused, and Tab cycles through them in the order they're slotted
without it closing in between. Try it: click into the example below, then
press Tab repeatedly.
Main content lands here.
<eun-toolbar>
<strong slot="start">Eunomia</strong>
<a slot="skip-links" href="#nav">Skip to navigation</a>
<a slot="skip-links" href="#main-content">Skip to main content</a>
<a slot="skip-links" href="#footer">Skip to footer</a>
<a slot="end" href="/product">Product</a>
</eun-toolbar>
<nav id="nav" tabindex="-1">...</nav>
<main id="main-content" tabindex="-1">...</main>
<footer id="footer" tabindex="-1">...</footer>
Every skip-link destination needs to be focusable to actually receive
focus when jumped to. A plain <div>/<nav>/<main> doesn't accept
focus natively, so give it tabindex="-1" (focusable programmatically,
skipped in the normal Tab order) the same way both examples above do.
Clicking a skip link, default or slotted, also focuses that target
directly, on top of the native href="#id" jump : that native jump alone
doesn't reliably move focus across browsers, Safari in particular, so this
is handled for you rather than left as something to wire up yourself. If
an app-level router bridge intercepts eunnavigate for client-side
routing (see eun-link's "SPA navigation" section), a skip link's #id
href is naturally excluded from that already, since it never starts with
/.
skip-links accepts eun-link in place of a plain <a>, styled the same
way, including against the bar's usually-dark skip-links background. A
plain <a> picks that color up by inheriting color, but eun-link
doesn't, so its own --link-color/--link-color-hover/--link-color-focus
are set for you instead. The divider drawn between links is drawn on the
shared container rather than on either link, which matters for more than
just supporting both tags with one rule: it keeps the divider completely
outside each link's own box, so a link's :focus-visible outline never
ends up wrapping around the divider sitting next to it. Its color is
still yours to adjust through --toolbar-skip-links-separator-color
when the default doesn't fit ; the links themselves stay entirely
yours to style, the same way any content you slot in is.
Custom styling
Every visual aspect is a --toolbar-* custom property, listed in full in
the API tab. --toolbar-gap sets the gap for all three of start/
center/end at once, while --toolbar-gap-start/--toolbar-gap-center/
--toolbar-gap-end override it for just one of them, each falling back to
--toolbar-gap when unset. That's handy when, say, center's nav links
want more breathing room than end's tightly-packed icon and button.
eun-toolbar.custom {
--toolbar-background: #4c1d95;
--toolbar-color: #fff;
--toolbar-height: 64px;
}
Keyboard interactions
Mobile panel, when open:
| Key | Action |
|---|---|
Escape |
Closes the panel (subject to close-on-backdrop for outside clicks, Escape itself always works) |
Tab / Shift+Tab |
Cycles focus through the panel's focusable content |
Unlike <dialog>'s showModal(), the native Popover API doesn't give a
manual popover any of this for free. The library's own shared dialog
stack fills the gap instead : Tab/Shift+Tab wraps back around at
either end of the panel's own focusable content rather than continuing
into the rest of the page, and everything outside the panel (up to
document.body) is made inert, so it's unreachable by pointer,
keyboard, and assistive tech alike for as long as the panel stays open.
Same mechanism eun-drawer's temporary variant and eun-modal share.
The skip link(s), the single skip-link-target default, or every link
slotted into skip-links, are the very first focusable element(s) in the
toolbar: pressing Tab from the very start of the page reaches them
first, before the logo or any nav item, and in the order they're slotted
when there's more than one. They reveal as a group (:focus-within on
their wrapper, reaching through the slot boundary the same as it would
light-DOM children) rather than each individually popping in and out at
the same spot, and stay revealed for as long as Tab/Shift+Tab keeps
focus inside that group.
Focus management
Opening the mobile panel moves focus to the first [autofocus] element
inside its slotted content, or the panel surface itself otherwise.
Closing it, however it closes (burger button, Escape, outside click,
hide()), returns focus to whatever had it right before the panel opened
(typically the burger button itself), per the WAI-ARIA Dialog pattern.
Aria attributes
The bar itself is a native <nav> element (implicit role="navigation"),
labeled via aria-label (default "Main navigation"). The mobile panel
carries role="dialog" and aria-modal="true", labeled via
aria-label-menu. The burger button carries aria-expanded (reflecting
the panel's open state) and swaps its aria-label between
aria-label-menu-open/aria-label-menu-close to match.
Reduced motion
The bar's height/color/border/shadow transitions (collapse-on-scroll,
transparent-at-top, theme), the mobile panel's slide/fade transition,
and the skip-links panel's reveal all collapse to near-instant under
prefers-reduced-motion: reduce, rather than being skipped outright.
display/overlay still need an active transition to hook into (via
allow-discrete) for the mobile panel's popover to delay top-layer
removal correctly, so the duration is reduced to effectively zero instead
of removing the transition declaration. Same pattern eun-drawer uses
for its own popover surface.
Marker-based adaptive theme, not pixel sampling
adaptive-theme deliberately doesn't read live pixel colors from whatever
happens to be behind the bar (via elementsFromPoint/getComputedStyle
sampling). That approach breaks silently against background images,
gradients, video, and semi-transparent layering, with no way for a screen
reader user (or anyone) to know the bar's contrast might have gone wrong.
The data-toolbar-theme marker attribute is an explicit, authored contract
instead: a section is only ever considered dark or light because a real
person marked it so, matching how this library's own Sass color tooling
(eunomia-a11y-color.mixin.scss) treats contrast as something computed
deliberately, not guessed at runtime.
Reference links