Eunomia v1.0.0-beta.2
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 — 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

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

Alternatives

You want .. Prefers Navigation that lives beside the page's content instead of above it, such as a sidebar menu eun-drawer

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 — put it in end instead, which never moves

Live testing

Properties

Toolbar <eun-toolbar>

Attributes

NameTypeDefaultDescription
logo-srcstringThe image URL for the start slot's fallback logo
logo-altstring'Home'The alt text for the fallback logo image
logo-hrefstring'#'The destination the fallback logo links to
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-sizestringA 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-backdropbooleantrueWhether clicking outside the mobile panel closes it
aria-label-menu-openstring'Open menu'The accessible label of the burger button while the panel is closed
aria-label-menu-closestring'Close menu'The accessible label of the burger button while the panel is open
aria-label-menustring'Menu'The accessible name of the mobile panel itself
aria-labelstring'Main navigation'The accessible name of the toolbar's navigation landmark
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-targetstringA 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-selectorstring'[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-targetstringThe 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'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
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.

Slots

NameDescription
startLeft-side content, rendered after a logo image linking to logoHref whenever logoSrc is set
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 whenever open, collapsed, or, under adaptiveTheme, theme changes
eunnavigateNavigationEventFired 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 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-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-backgroundSets the background color, overriding both light and dark variants below
--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
--toolbar-color-darkSets the text color for the dark theme
--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-logo-heightSets the desired height of the default logo image ; never rendered taller than the bar's own current height regardless
--toolbar-logo-filter-darkSets a filter applied to the logo image in dark toolbar mode
--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-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 own background
--toolbar-skip-links-colorSets the text color of the skip-links bar
--toolbar-skip-links-separator-colorSets 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).

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

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

Acme Inc. Product Pricing Docs Sign in
<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 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 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.

Eunomia Sign in
Scroll this box, the bar above shrinks past 24px.
<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.

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

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

Eunomia

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.

Eunomia Skip to navigation Skip to main content Skip to footer Product Pricing
Footer 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.

Eunomia Sign in
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

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