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, such as opening and closing, focus and 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 close-on-backdrop>
<a slot="start" href="/" class="logo"
><img src="/logo.svg" alt="Eunomia"
/></a>
<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 close-on-backdrop>
<a slot="start" href="/" class="logo"
><img src="/logo.svg" alt="Eunomia"
/></a>
<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 close-on-backdrop>
<a slot="start" href="/" className="logo">
<img src="/logo.svg" alt="Eunomia" />
</a>
<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 Link from "next/link";
import "@eunomia/elements/toolbar.js";
import "@eunomia/elements/button.js";
export function AppToolbar() {
return (
<eun-toolbar close-on-backdrop>
<Link slot="start" href="/" className="logo">
<img src="/logo.svg" alt="Eunomia" />
</Link>
<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 close-on-backdrop>
<a slot="start" href="/" class="logo"
><img src="/logo.svg" alt="Eunomia"
/></a>
<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 close-on-backdrop>
<a slot="start" href="/" class="logo"
><img src="/logo.svg" alt="Eunomia"
/></a>
<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
| You want… | Prefers |
|---|---|
| Navigation that lives beside the page's content instead of above it, such as a sidebar menu | eun-drawer |
| Grouping several pages under one entry of the bar, opened on hover or click | eun-nav-menu |
Guidance
- 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, so put it inendinstead, which never moves
Live testing
Properties
Toolbar <eun-toolbar>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| 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% |
| mobile-hide | string | — | The bar slots hidden below the mobile breakpoint, as a space-separated list of start, center and end, such as "center end" when their content is repeated in the panel. Applied by toolbar-ssr.css before the upgrade too |
| close-on-backdrop | boolean | false | Whether clicking outside the mobile panel closes it |
| menu-open-label | string | — | The accessible label of the burger button while the panel is closed. Defaults to the localized string for the current locale (English: "Open menu") |
| menu-close-label | string | — | The accessible label of the burger button while the panel is open. Defaults to the localized string for the current locale (English: "Close menu") |
| menu-label | string | — | The accessible name of the mobile panel itself. Defaults to the localized string for the current locale (English: "Menu") |
| aria-label | string | — | The accessible name of the toolbar's navigation landmark. Defaults to the localized string for the current locale (English: "Main navigation") |
| 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-attribute | string | 'data-toolbar-theme' | The marker attribute adaptiveTheme reads dark or light from, for a page that already marks its sections with an attribute of its own |
| adaptive-theme-selector | string | — | A selector for the marker elements adaptiveTheme observes, defaulting to every element carrying adaptiveThemeAttribute. Each should carry that attribute set to dark or light |
| 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 | — | The text of the default skip link built from skipLinkTarget. Defaults to the localized string for the current locale (English: "Skip to main content") |
| position | 'sticky' | 'fixed' | 'static' | 'sticky' | How the toolbar is positioned relative to the page |
| layout | 'centered' | 'between' | 'centered' | How start, center, and end share the bar. centered keeps center truly centered on the bar whatever the widths of start and end, while between spaces the three out evenly, like a flexbox space-between |
| 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
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Slots
| Name | Description |
|---|---|
| burger-icon | Replaces both burger icons with a single piece of content, kept in place while the panel opens and closes, so it can animate between the two states itself, styled from the host's own open attribute (eun-toolbar[open]). Takes priority over menu-icon and close-icon |
| menu-icon | Replaces the burger's default open icon (an `eun-icon name="menu"`) with any content, shown while the panel is closed. Useful wherever `eun-icon`'s own dynamic SVG resolution doesn't fit, e.g. some bundler setups |
| close-icon | Replaces the burger's default close icon (an `eun-icon name="close"`) with any content, shown while the panel is open. See `menu-icon` above |
| start | Left-side content, such as the logo, as a link of your own wrapping an image |
| 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 once whenever open, collapsed, or, under adaptiveTheme, theme changes. Its value holds the property that changed and the current open, collapsed, and theme. On close, it fires once the panel has finished closing and page scroll is released |
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-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --toolbar-padding is set |
| --toolbar-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --toolbar-padding is set |
| --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-columns | Sets the grid columns of start, center, and end, overriding the ones set by layout |
| --toolbar-background | Sets the background color, overriding both light and dark variants below |
| --toolbar-background-at-top | Sets the background of the bar while transparent-at-top applies, transparent by default |
| --toolbar-backdrop-blur | Sets a backdrop blur radius for the bar itself, for a frosted glass effect behind a translucent background |
| --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, --eun-surface-inverse by default |
| --toolbar-color-dark | Sets the text color for the dark theme, --eun-text-on-inverse by default |
| --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-color-transition-duration | Sets the duration of the background, text color, border, and shadow transitions of the bar, falling back to --toolbar-transition-duration |
| --toolbar-transition-easing | Sets the timing function of every transition of the toolbar |
| --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-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --toolbar-panel-padding is set |
| --toolbar-panel-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --toolbar-panel-padding is set |
| --toolbar-panel-offset | Sets the room reserved above the mobile panel content for the burger, added to its padding. Defaults to the bar's height, or 0 with panel-direction="bottom" |
| --toolbar-burger-align | Sets the vertical alignment of the burger button within the bar's height, defaulting to center ; set flex-start to pin it to the top instead |
| --toolbar-burger-padding | Sets the padding around the burger button, defaulting to 0 on the block axis and --toolbar-panel-padding on the inline axis |
| --toolbar-burger-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --toolbar-burger-padding is set |
| --toolbar-burger-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --toolbar-burger-padding is set |
| --toolbar-burger-color | Sets the color of the burger button, defaulting to the bar's text color, or the panel's once open |
| --toolbar-burger-background-hover | Sets the background of the burger button on hover, defaulting to a light mix of its own color |
| --toolbar-burger-background-active | Sets the background of the burger button while pressed, defaulting to a light mix of its own color |
| --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 background, or of primary-600 in the light theme without --toolbar-background |
| --toolbar-skip-links-color | Sets the text color of the skip-links bar |
| --toolbar-skip-links-focus-outline-color | Sets the keyboard focus outline color of the skip links, defaulting to their text color |
| --toolbar-skip-links-background-focus | Sets the background color of a skip link on keyboard focus |
| --toolbar-skip-links-separator-color | Sets the color of the divider drawn between skip links |
| --toolbar-skip-links-separator | Replaces the thin divider drawn between skip links with a glyph, as a CSS content string such as "/" |
| --toolbar-skip-links-gap | Sets the gap between skip links, with the divider centered in it |
| --toolbar-skip-links-padding-inline | Sets the horizontal padding of the skip-links bar |
| --toolbar-skip-links-font-size | Sets the font size of the skip links |
| --toolbar-skip-link-padding | Sets the padding of each skip link |
| --toolbar-focus-outline-color | Sets the keyboard focus outline color of the links in the bar |
| --toolbar-link-background-focus | Sets the background color of an eun-link in the bar on keyboard focus |
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, since it's the mobile panel's own content,
opened by the burger button below mobile-breakpoint (see "Mobile menu"
below).
<eun-toolbar close-on-backdrop>
<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>
With layout="between", start, center and end are spaced out evenly
instead, like a flexbox space-between: center then sits halfway between
the other two rather than in the middle of the bar. For any other split,
--toolbar-columns sets the grid columns directly.
<eun-toolbar close-on-backdrop layout="between">...</eun-toolbar>
eun-toolbar {
--toolbar-columns: auto 1fr auto;
}
Logo placement
Logo
The logo is a link of your own, slotted into start with the image
inside. Since it stays in your markup, your framework's link component,
such as Next's <Link>, keeps its client-side navigation and prefetching,
with no router bridge. A link slotted into start gets the same focus
outline as the rest of the bar.
The bar's own current height (--toolbar-height, or
--toolbar-height-collapsed while collapsed) is the ceiling for anything
slotted into start/center/end, so the link never makes the bar grow.
That ceiling follows the bar while it animates, so a logo shrinking with it
stays smooth.
Size the image itself from your own CSS, and target theme="dark" to adapt
it once the bar goes dark.
<eun-toolbar close-on-backdrop>
<a slot="start" href="/" class="logo"
><img src="/logo.svg" alt="Eunomia"
/></a>
<eun-link slot="end" href="/docs" icon="menu_book">Docs</eun-link>
</eun-toolbar>
eun-toolbar .logo img {
display: block;
height: 40px;
}
/* A single-color logo turned white on a dark bar */
eun-toolbar[theme="dark"] .logo img {
filter: brightness(0) invert(1);
}
Logo with start content
The logo is one element of start among others: whatever else is slotted
there follows it in order, all 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 close-on-backdrop>
<a slot="start" href="/" class="logo"
><img src="/logo.svg" alt="Eunomia"
/></a>
<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 unless mobile-hide lists them (see below). 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. Whatever the direction and size,
the burger stays at the end of the bar, and the panel content starts
below it. 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 close-on-backdrop>
<a slot="start" href="/" class="logo"
><img src="/logo.svg" alt="Eunomia"
/></a>
<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 close-on-backdrop panel-direction="top">...</eun-toolbar>
<eun-toolbar close-on-backdrop panel-direction="bottom">...</eun-toolbar>
<eun-toolbar close-on-backdrop panel-direction="left">...</eun-toolbar>
<eun-toolbar close-on-backdrop panel-direction="right">...</eun-toolbar>
The panel content starts below the burger, its padding adding the bar's
height on top (nothing with panel-direction="bottom", where the sheet
doesn't reach the bar). Set --toolbar-panel-offset to change that room,
for example 0px to let the content run alongside the burger.
While the panel is open, the page behind it can't scroll. Closing it puts the page back where it was, unless a link in the panel led to another page through client-side navigation: that page then stays at the top, where the router put it.
The burger takes the bar's text color, and the panel's once it opens, so it
stays visible on a dark bar. --toolbar-burger-color sets another one, and
--toolbar-burger-background-hover/--toolbar-burger-background-active
set its hover and pressed backgrounds, a light mix of its color by default.
Hiding bar content on mobile
mobile-hide lists the bar slots to hide below mobile-breakpoint, among
start, center and end. It suits content repeated in the panel, such
as a search field or a few actions that no longer fit next to the burger.
toolbar-ssr.css hides them before the upgrade too.
<eun-toolbar close-on-backdrop mobile-hide="center end">
<a slot="start" href="/"><img src="/logo.svg" alt="Eunomia" /></a>
<eun-search clearable slot="center"></eun-search>
<eun-button slot="end">New</eun-button>
<!-- The panel repeats them -->
<eun-search clearable></eun-search>
<eun-button>New</eun-button>
</eun-toolbar>
Overriding the burger icon
The burger button falls back to eun-icon for its open/close glyphs
(menu/close). Slot your own into menu-icon/close-icon to replace
either one, for example wherever eun-icon's own dynamic SVG resolution
doesn't fit a given bundler setup. Only the icon matching the panel's
current state is ever shown, so both can be slotted at once with no
manual toggling needed. Tap the burger below to see it swap from the
custom hamburger glyph to the custom X on open.
<eun-toolbar close-on-backdrop>
<a slot="start" href="/" class="logo"
><img src="/logo.svg" alt="Eunomia"
/></a>
<svg
slot="menu-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<line x1="4" y1="7" x2="20" y2="7"></line>
<line x1="4" y1="12" x2="20" y2="12"></line>
<line x1="4" y1="17" x2="20" y2="17"></line>
</svg>
<svg
slot="close-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<line x1="5" y1="5" x2="19" y2="19"></line>
<line x1="19" y1="5" x2="5" y2="19"></line>
</svg>
...
</eun-toolbar>
menu-icon and close-icon swap one element for the other, so nothing can
animate between them. To animate the burger, slot a single element into
burger-icon instead: it stays in place in both states, and wins over
menu-icon/close-icon. Style its two states from the toolbar's own
open attribute, as in this burger whose three bars turn into a cross.
<eun-toolbar close-on-backdrop>
<span slot="burger-icon" class="burger" aria-hidden="true">
<span></span><span></span><span></span>
</span>
...
</eun-toolbar>
.burger {
display: grid;
gap: 4px;
width: 20px;
}
.burger > span {
height: 2px;
background: currentColor;
border-radius: 1px;
transition:
transform 300ms ease-out,
opacity 300ms ease-out;
}
eun-toolbar[open] .burger > :nth-child(1) {
transform: translateY(6px) rotate(45deg);
}
eun-toolbar[open] .burger > :nth-child(2) {
opacity: 0;
}
eun-toolbar[open] .burger > :nth-child(3) {
transform: translateY(-6px) rotate(-45deg);
}
Scroll behavior
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.
eunchange fires once per change of open, collapsed or theme. Its
value says which one changed, along with the toolbar's current state.
When the panel closes, it fires once the panel has finished closing and
the page can scroll again.
toolbar.addEventListener("eunchange", (event) => {
const { property, open, collapsed, theme } = event.value;
if (property === "open" && !open) {
// The panel is fully closed.
}
});
<eun-toolbar close-on-backdrop 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 close-on-backdrop transparent-at-top>...</eun-toolbar>
--toolbar-background-at-top sets the background used at the top of the
page instead of transparent, for example to keep a background on some
screen sizes only. For a frosted glass effect once the bar has a
translucent background, --toolbar-backdrop-blur blurs what scrolls
behind it.
@media (width < 1024px) {
eun-toolbar[theme="dark"] {
--toolbar-background-at-top: var(--eun-surface-inverse);
}
}
eun-toolbar[collapsed] {
--toolbar-background: rgb(255 255 255 / 70%);
--toolbar-backdrop-blur: 20px;
}
Adaptive theme
adaptive-theme flips theme between light/dark as the page scrolls,
driven by an IntersectionObserver watching elements matching
adaptive-theme-selector (by default, every element carrying the marker
attribute) 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.
The logo is yours to style once the bar goes dark: the demo below keeps its multi-color icon as is, and the CSS in the "Logo" example above shows how to whiten a single-color one.
<eun-toolbar close-on-backdrop 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;
}
Sections added, removed or re-marked later, such as a new page after a
client-side navigation, are picked up automatically. Only an element that
starts matching adaptive-theme-selector without a change to the DOM or
the marker attribute, through a class for example, needs a call to
refreshAdaptiveTheme():
document.querySelector("eun-toolbar").refreshAdaptiveTheme();
A page that already marks its sections with an attribute of its own can
keep it: adaptive-theme-attribute names the attribute the theme is read
from, and the observed sections follow it unless adaptive-theme-selector
is set.
<eun-toolbar
close-on-backdrop
adaptive-theme
adaptive-theme-attribute="data-nav-theme"
>
...
</eun-toolbar>
<section data-nav-theme="dark">...</section>
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 close-on-backdrop 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, 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 close-on-backdrop>
<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 the #id
href is naturally excluded from that already, since a bridge leaves
same-page anchors to the browser.
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.
--toolbar-skip-links-separator replaces the divider with a glyph, given
as a CSS string, in the same color. --toolbar-skip-links-padding-inline
sets the side padding of the bar, for example to line the links up with
the page's container, while --toolbar-skip-links-font-size and
--toolbar-skip-link-padding set the size and padding of the links.
eun-toolbar {
--toolbar-skip-links-separator: "/";
--toolbar-skip-links-padding-inline: max(16px, (100vw - 1200px) / 2);
--toolbar-skip-links-font-size: 14px;
--toolbar-skip-link-padding: 4px 8px;
}
Server-side rendering
slot attribute) is only ever hidden by
eun-toolbar's own Shadow DOM: tucked inside a closed native popover
once the element is upgraded and mobile is true, or
simply never projected anywhere once upgraded and it's false. Server-
rendered HTML has no Shadow DOM yet, so that content renders as
plain, unstyled light-DOM children instead, on top of everything
else on the page, until the element upgrades.
Link toolbar-ssr.css in the page's <head> to fix this. It mirrors
that same hidden starting state in a plain stylesheet the browser applies
from first paint, using :not(:defined) so it stops matching, and hands
off cleanly to eun-toolbar's own styles, the moment the element upgrades:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/toolbar-ssr.css"
/>
A bundler that resolves package exports (Vite, webpack, Turbopack, or a
Sass @use/@import) can reach the same file through the package
specifier instead, which survives a hoisted or monorepo install the raw
path above doesn't:
@use "@eunomia/elements/toolbar-ssr.css";
It also hides skip-links, burger-icon, menu-icon and close-icon, and lays out
start/center/end along a row with the bar's own height, padding
and gap, so that content stays visible (and part of LCP) without running
together at the top-left before the upgrade. When start and end hold
one element each (a logo, a wrapper around several links), center is
exactly centered whatever their widths. With several elements on either
side, it's only centered when both sides have similar widths, so wrap
them in a single element to get the exact centering. With
layout="between", the three are spaced out evenly, as once upgraded.
position is mirrored as well, so a fixed bar is out of the flow from
first paint instead of pushing the page down when it upgrades. Write the
attribute in the server-rendered markup for this to apply.
The bar's background, text color and bottom border are painted from first
paint too, following theme and the --toolbar-* color properties, so the
bar never shows up transparent before turning to its own color. With
transparent-at-top, it stays transparent, as it is once upgraded on a
page scrolled to the top.
mobile-hide is mirrored as well, for the default mobile-breakpoint
and for the library breakpoints (600, 768, 1024 and 1280). A static
stylesheet can't read any other mobile-breakpoint value, so with another
value, or for content your own CSS hides below the breakpoint, repeat
those rules for the unupgraded bar with the same breakpoint:
/* mobile-breakpoint="768", the default */
@media (width < 768px) {
eun-toolbar:not(:defined) > [slot="center"] {
display: none;
}
}
Refine any of this with eun-toolbar:not(:defined) rules in your own
CSS, loaded after this file.
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.
--toolbar-transition-duration times every transition of the toolbar.
--toolbar-color-transition-duration gives the bar's background, text
color, border and shadow a duration of their own, and
--toolbar-transition-easing sets the timing function of all of them.
eun-toolbar {
--toolbar-transition-duration: 300ms;
--toolbar-color-transition-duration: 500ms;
--toolbar-transition-easing: ease-out;
}
eun-toolbar.custom {
--toolbar-background: #4c1d95;
--toolbar-color: #fff;
--toolbar-height: 64px;
}
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--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-background-at-top |
Sets the background of the bar while transparent-at-top applies, transparent by default |
--toolbar-border-color |
Sets the bottom border color |
--toolbar-shadow |
Sets the shadow of the bar |
--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-burger-color |
Sets the color of the burger button, defaulting to the bar's text color, or the panel's once open |
--toolbar-burger-background-hover |
Sets the background of the burger button on hover, defaulting to a light mix of its own color |
--toolbar-burger-background-active |
Sets the background of the burger button while pressed, defaulting to a light mix of its own color |
--toolbar-skip-links-background |
Sets the background of the skip-links bar, defaulting to a darker mix of the bar's background, or of primary-600 in the light theme without --toolbar-background |
--toolbar-skip-links-color |
Sets the text color of the skip-links bar |
--toolbar-skip-links-focus-outline-color |
Sets the keyboard focus outline color of the skip links, defaulting to their text color |
--toolbar-skip-links-background-focus |
Sets the background color of a skip link on keyboard focus |
--toolbar-skip-links-separator-color |
Sets the color of the divider drawn between skip links |
--toolbar-focus-outline-color |
Sets the keyboard focus outline color of the links in the bar |
--toolbar-link-background-focus |
Sets the background color of an eun-link in the bar on keyboard focus |
Keyboard and focus
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 keeps it on the burger button otherwise,
so its focus ring stays visible and the next Tab lands on the panel's
first item.
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 (by default "Main navigation", in the active locale). The mobile panel
carries role="dialog" and aria-modal="true", labeled via
menu-label. The burger button carries aria-expanded (reflecting
the panel's open state) and swaps its aria-label between
menu-open-label/menu-close-label to match.
Visual and motion
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, or the one named by
adaptive-theme-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