Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

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

eun-icon · for the burger and close glyphs, below the mobile breakpoint eun-button · for the burger toggle and the mobile panel's close button
Overview API Examples Accessibility
Product Pricing Docs Sign in

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 drawer's sidebar variant. For a trail showing where the current page sits in a hierarchy, use breadcrumb, often placed inside a toolbar's own content on an inner page, not a replacement for it.

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.

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 slot attribute) so it lands in the burger's mobile panel; put whatever should also stay visible on desktop in center/end too, nothing relocates automatically between them
  • Set skip-link-target to 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 the skip-links slot instead
  • Pair adaptive-theme's data-toolbar-theme markers with sections that are genuinely dark/light, not every section: unmarked sections simply keep whatever theme was last resolved
  • Set both --toolbar-background-light/--toolbar-color-light and their -dark counterparts together whenever using theme or adaptive-theme, so contrast holds in both states
  • Combining transparent-at-top with low-contrast text over an unpredictable hero background: pair it with adaptive-theme instead of guessing a single color that reads on everything
  • Setting collapse-threshold so 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 slot attribute: that only ever renders inside the closed-by-default mobile panel, so put it in end instead, which never moves

Live testing

Properties

Toolbar <eun-toolbar>

Attributes

NameTypeDefaultDescription
mobile-breakpointnumber768The 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-sizestring—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-hidestring—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-backdropbooleanfalseWhether clicking outside the mobile panel closes it
menu-open-labelstring—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-labelstring—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-labelstring—The accessible name of the mobile panel itself. Defaults to the localized string for the current locale (English: "Menu")
aria-labelstring—The accessible name of the toolbar's navigation landmark. Defaults to the localized string for the current locale (English: "Main navigation")
collapse-on-scrollbooleanfalseEnables the scroll-triggered collapse mode
collapse-thresholdnumber24Pixels scrolled, of the scroll target or the page, past which the bar collapses
transparent-at-topbooleanfalseEnables the transparent-while-at-the-top mode
at-topbooleantrueWhether the scroll target or the page is currently scrolled to zero. Kept in sync automatically while transparentAtTop is set
scroll-targetstring—A CSS selector for a custom scroll container to track instead of the page, for collapseOnScroll and transparentAtTop
adaptive-themebooleanfalseEnables automatic theme switching driven by marked page sections scrolling underneath the bar
adaptive-theme-attributestring'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-selectorstring—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-targetstring—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-labelstring—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
openbooleanfalseWhether the mobile menu panel is open
collapsedbooleanfalseWhether 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 Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
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

NameDescription
burger-iconReplaces 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-iconReplaces 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-iconReplaces the burger's default close icon (an `eun-icon name="close"`) with any content, shown while the panel is open. See `menu-icon` above
startLeft-side content, such as the logo, as a link of your own wrapping an image
centerCenter content, truly centered independently of the start and end widths
endRight-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-linksCustom 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

NameTypeDescription
eunchangeChangeEventFired 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 Events.

CSS custom properties

NameDescription
--toolbar-heightSets the expanded height of the bar
--toolbar-height-collapsedSets the collapsed height of the bar, for collapseOnScroll only
--toolbar-paddingSets the padding of the bar
--toolbar-padding-blockSets the vertical padding, as one length applied to both sides. Ignored when --toolbar-padding is set
--toolbar-padding-inlineSets the horizontal padding, as one length applied to both sides. Ignored when --toolbar-padding is set
--toolbar-gapSets the gap between the start, center, and end columns themselves, and the fallback for the three below wherever they're unset
--toolbar-gap-startSets the gap between items inside the start slot, falling back to --toolbar-gap
--toolbar-gap-centerSets the gap between items inside the center slot, falling back to --toolbar-gap
--toolbar-gap-endSets the gap between items inside the end slot, falling back to --toolbar-gap
--toolbar-columnsSets the grid columns of start, center, and end, overriding the ones set by layout
--toolbar-backgroundSets the background color, overriding both light and dark variants below
--toolbar-background-at-topSets the background of the bar while transparent-at-top applies, transparent by default
--toolbar-backdrop-blurSets a backdrop blur radius for the bar itself, for a frosted glass effect behind a translucent background
--toolbar-colorSets the text color, overriding both light and dark variants below
--toolbar-background-lightSets the background color for the light theme
--toolbar-color-lightSets the text color for the light theme
--toolbar-background-darkSets the background color for the dark theme, --eun-surface-inverse by default
--toolbar-color-darkSets the text color for the dark theme, --eun-text-on-inverse by default
--toolbar-border-colorSets the bottom border color
--toolbar-shadowSets the shadow of the bar
--toolbar-z-indexSets the stacking order of the bar
--toolbar-transition-durationSets the duration of the collapse, transparency, theme, and panel transitions
--toolbar-color-transition-durationSets the duration of the background, text color, border, and shadow transitions of the bar, falling back to --toolbar-transition-duration
--toolbar-transition-easingSets the timing function of every transition of the toolbar
--toolbar-panel-sizeSets a custom open size for the mobile panel, also settable through the panel-size attribute
--toolbar-panel-backgroundSets the background of the mobile panel, defaulting to a translucent mix of the bar's own background
--toolbar-panel-colorSets the text color of the mobile panel
--toolbar-panel-paddingSets the padding of the mobile panel
--toolbar-panel-padding-blockSets the vertical padding, as one length applied to both sides. Ignored when --toolbar-panel-padding is set
--toolbar-panel-padding-inlineSets the horizontal padding, as one length applied to both sides. Ignored when --toolbar-panel-padding is set
--toolbar-panel-offsetSets 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-alignSets 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-paddingSets the padding around the burger button, defaulting to 0 on the block axis and --toolbar-panel-padding on the inline axis
--toolbar-burger-padding-blockSets the vertical padding, as one length applied to both sides. Ignored when --toolbar-burger-padding is set
--toolbar-burger-padding-inlineSets the horizontal padding, as one length applied to both sides. Ignored when --toolbar-burger-padding is set
--toolbar-burger-colorSets the color of the burger button, defaulting to the bar's text color, or the panel's once open
--toolbar-burger-background-hoverSets the background of the burger button on hover, defaulting to a light mix of its own color
--toolbar-burger-background-activeSets the background of the burger button while pressed, defaulting to a light mix of its own color
--toolbar-panel-gapSets the gap inside the mobile panel
--toolbar-panel-backdrop-blurSets a backdrop blur radius for the mobile panel
--toolbar-skip-links-heightSets the height of the skip-links bar
--toolbar-skip-links-backgroundSets 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-colorSets the text color of the skip-links bar
--toolbar-skip-links-focus-outline-colorSets the keyboard focus outline color of the skip links, defaulting to their text color
--toolbar-skip-links-background-focusSets the background color of a skip link on keyboard focus
--toolbar-skip-links-separator-colorSets the color of the divider drawn between skip links
--toolbar-skip-links-separatorReplaces the thin divider drawn between skip links with a glyph, as a CSS content string such as "/"
--toolbar-skip-links-gapSets the gap between skip links, with the divider centered in it
--toolbar-skip-links-padding-inlineSets the horizontal padding of the skip-links bar
--toolbar-skip-links-font-sizeSets the font size of the skip links
--toolbar-skip-link-paddingSets the padding of each skip link
--toolbar-focus-outline-colorSets the keyboard focus outline color of the links in the bar
--toolbar-link-background-focusSets 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).

Eunomia Centered, independent of start/end width Settings Sign in
<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.

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

Acme Inc. Product Pricing Docs Sign in
<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 date picker's doc page does. It's pinned to a real, separate 375px-wide <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.
  }
});
Eunomia Sign in
Scroll this box, the bar above shrinks past 24px.
<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.

Eunomia Sign in
Hero section
Scroll up to see the bar turn transparent again at the very top.
<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.

Eunomia Sign in
Light section
Dark section, scroll here, the bar inverts
Light section again
<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.

Eunomia

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.

Eunomia Skip to navigation Skip to main content Skip to footer Product Pricing
Footer 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 SPA navigation guide), a skip link's #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

The default slot (the mobile panel's own content, everything slotted in without a 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;
}
Eunomia Sign in
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

WAI-ARIA Dialog (Modal) Pattern
WAI-ARIA Authoring Practices: Landmark Regions (navigation)
HTML Specification: Popover API
WebAIM: "Skip Navigation" Links
WCAG 2.1: Animation from Interactions