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

Sidebar

Sidebar is a persistent, non-modal navigation panel pinned to one edge of the layout, sitting alongside the rest of the page's content rather than over it. There is no backdrop, no top layer, and no focus trap, so the rest of the page stays fully interactive the whole time it's visible. It sticks in place by default, so dropping it into a page that already lays out as a full-height row is all it takes to keep it in view while the rest of the page scrolls.

It collapses through its own edge toggle button, either down to a fixed-width icon rail or fully away, leaving only the toggle itself visible at the edge to reopen it. Independently, the border can be dragged, or adjusted with the arrow keys once focused, to resize the panel.

Dependencies

eun-icon · for the collapse toggle's chevron, if collapsible
Overview API Examples Accessibility
Dashboard

When to use

Reach for sidebar when you're building primary navigation or a panel that should stay put alongside the page's content the whole time someone's using it, not just for one task. If instead you need a focused side panel that comes and goes for a single action, such as editing a record, showing details for a row someone clicked, or a quick form, reach for drawer instead, since that's a modal overlay by design, with a backdrop, a focus trap, and a way to dismiss it, where sidebar is a persistent layout fixture with none of that. If the panel should block the rest of the page until someone responds, look at drawer's own unsaved-changes guard, or modal for anything centered on screen.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/sidebar.js";
import "@eunomia/elements/icon.js";
<div style="display: flex; height: 100vh;">
  <eun-sidebar collapsible resizable heading="Menu">
    <nav>
      <a href="/dashboard">
        <eun-icon name="home" size="18"></eun-icon>
        Dashboard
      </a>
    </nav>
  </eun-sidebar>
  <main>Page content</main>
</div>

Importing sidebar.js registers <eun-sidebar>. The collapse toggle's chevron icon is rendered internally with eun-icon, so import it too.

Alternatives

You want… Prefers
A focused side panel for a single task that comes and goes, such as editing a record or a detail view eun-drawer

Guidance

  • Use eun-sidebar for persistent, always-available navigation the user browses alongside the page
  • Prefer collapse-mode="mini" over "hidden" when the sidebar's items should stay reachable (as icons) without fully leaving the layout
  • Set position to match where it sits in the flex layout ("start" on the left, "end" on the right), since that decides which edge the border, toggle button, and resize handle render on
  • Switch to toggle-placement="inline" once the sidebar already has its own bottom content (a user menu, an app version) that a floating edge toggle would otherwise sit awkwardly next to
  • Turn off collapsible/resizable and drive the state yourself (e.g. from a page header menu button) when the built-in controls don't fit your layout, as shown in the Examples tab
  • Using eun-sidebar for a focused, single-task panel triggered by an action, since that's what eun-drawer is for
  • Relying on collapse-mode="hidden" for content the user still needs to glance at while collapsed, since nothing inside is reachable or visible then
  • Nesting interactive elements deep enough that the mini icon rail (collapse-mode="mini") can't render them meaningfully, so keep collapsed items to a flat list of icon-first actions

Live testing

Properties

Sidebar <eun-sidebar>

Attributes

NameTypeDefaultDescription
hide-below'mobile' | 'tablet' | 'laptop' | 'desktop'—Hides the sidebar below this breakpoint, for a layout where another navigation takes over on small screens, such as the toolbar's mobile panel. Also applies before the component loads when sidebar-ssr.css is linked
toggle-placement'edge' | 'inline''edge'Where the built-in toggle button renders: edge floats on the border, and inline sits inside the panel, pinned below the body and footer
collapse-mode'mini' | 'hidden''mini'The shape the collapsed state takes
expand-labelstring—The accessible label of the toggle button while collapsed. Defaults to the localized string for the current locale (English: "Expand sidebar")
collapse-labelstring—The accessible label of the toggle button while expanded. Defaults to the localized string for the current locale (English: "Collapse sidebar")
resize-labelstring—The accessible label of the drag-to-resize border. Defaults to the localized string for the current locale (English: "Resize sidebar")
position'start' | 'end''start'Which edge of the layout the sidebar is pinned against
theme'light' | 'dark''light'The sidebar's color scheme. Dark uses the inverse surface, for a sidebar on a dark background
headingstring—An optional heading, rendered as the header slot's fallback content, and used as the accessible name when set
collapsiblebooleanfalseWhether the built-in toggle button renders at all. The collapse, expand, and toggle methods still work regardless, for driving collapse from elsewhere, such as a page header menu button. Leave it off to rely on resizable alone
collapsedbooleanfalseWhether the sidebar is currently collapsed
resizablebooleanfalseWhether the border can be dragged, or, once focused, adjusted with arrow keys, to resize the panel

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
(default)The sidebar's scrollable body content, typically a navigation element
headerOptional header content, such as a logo, title, or search, fixed above the scrollable body
footerOptional footer content, such as a user menu, fixed below the scrollable body. Omitted entirely when nothing is slotted

Events

NameTypeDescription
eunchangeChangeEventFired whenever collapsed changes, including as a result of dragging or keyboard-resizing below the mini width. A collapse reached this way takes the shape set by collapseMode, and expanding again returns to --sidebar-width

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--sidebar-widthSets the expanded width of the sidebar, also the floor of the drag-resize range and, once resized, holds the user's chosen width
--sidebar-mini-widthSets the width of the mini collapsed state, also the floor of the drag-resize range
--sidebar-max-widthSets the ceiling of the drag-resize range
--sidebar-backgroundSets the background color of the sidebar. Use an opaque color, or transparent to show the background behind the sidebar
--sidebar-colorSets the text color of the sidebar
--sidebar-border-colorSets the color of the sidebar's side border
--sidebar-paddingSets the padding of the header, body, and footer
--sidebar-padding-blockSets the vertical padding, as one length applied to both sides. Ignored when --sidebar-padding is set
--sidebar-padding-inlineSets the horizontal padding, as one length applied to both sides. Ignored when --sidebar-padding is set
--sidebar-gapSets the gap inside the header and footer rows
--sidebar-toggle-colorSets the color of the toggle button
--sidebar-toggle-background-hoverSets the background color of the toggle button on hover
--sidebar-focus-outline-colorSets the keyboard focus color of the toggle button's outline and of the resize handle
--sidebar-resize-handle-delaySets how long the pointer must rest on the border before the resize handle shows. Set to 0 to show it right away
--sidebar-transition-durationSets the duration of the collapse and resize transition

Basic sidebar

The example below brings together the full picture in one place:

  • Collapse toggle: the button floating on the border (collapsible to hide it, or drive it entirely from outside via expand()/collapse()/toggleCollapse() and eunchange, as the buttons above the demo do).
  • Drag-to-resize: the border itself, freely between --sidebar-mini-width and --sidebar-max-width, or focused and adjusted with ArrowLeft/ArrowRight/Home/End. Releasing a drag below the floor collapses the panel into the shape collapse-mode sets, so with mini it stops at the icon rail, and expanding again brings back --sidebar-width. The handle only lights up once the pointer rests on the border for --sidebar-resize-handle-delay (300ms by default), so crossing it on the way to the content doesn't flash it. The handle only renders with resizable, independently of collapsible.
  • Icon and label items with ellipsis: full label at rest, ellipsized as the panel narrows (dragging, or the collapse-mode="mini" floor), hidden outright once fully collapsed. All three states are one small CSS block, no JavaScript beyond the nav's own click-to-select behavior.
expand() collapse() toggleCollapse()
Dashboard
<div style="display: flex; height: 100vh;">
  <eun-sidebar
    collapsible
    resizable
    id="sidebar"
    collapse-mode="mini"
    heading="Menu"
    style="--sidebar-max-width: 420px;"
  >
    <nav>
      <button type="button" class="sidebar-item active" data-label="Dashboard">
        <eun-icon name="home" size="18"></eun-icon>
        <span class="sidebar-label">Dashboard</span>
      </button>
      <button type="button" class="sidebar-item" data-label="Projects">
        <eun-icon name="folder" size="18"></eun-icon>
        <span class="sidebar-label">Projects</span>
      </button>
      <button type="button" class="sidebar-item" data-label="Settings">
        <eun-icon name="settings" size="18"></eun-icon>
        <span class="sidebar-label">Settings</span>
      </button>
    </nav>
  </eun-sidebar>
  <main id="main">Dashboard</main>
</div>
.sidebar-item {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 8px;
  border: none;
  border-radius: 8px;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  text-align: left;
}
.sidebar-item:hover {
  background: var(--eun-content-hover, #edeff0);
}
.sidebar-item.active {
  background: var(--eun-color-primary-alpha-20, #3d6fff33);
  color: var(--eun-color-primary-500, #3d6fff);
}
.sidebar-label {
  /* Ellipsizes rather than wrapping/overflowing as the panel narrows —
     via a drag, or collapse-mode="mini"'s floor. */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
/* eun-sidebar reflects `collapsed` on itself — reachable from plain,
   outside CSS, no JavaScript needed for this part. */
#sidebar[collapsed] .sidebar-label {
  display: none;
}
const sidebar = document.querySelector("#sidebar");
const main = document.querySelector("#main");

// The nav's own click-to-select behavior — everything else above
// (collapse, resize, label hide/ellipsis) needs none.
sidebar.querySelectorAll(".sidebar-item").forEach((item) => {
  item.addEventListener("click", () => {
    sidebar
      .querySelectorAll(".sidebar-item")
      .forEach((el) => el.classList.remove("active"));
    item.classList.add("active");
    main.textContent = item.dataset.label;
  });
});

// Driving collapse from outside the component : expand()/collapse()/
// toggleCollapse(), and eunchange to react to it (including a drag or
// keyboard resize crossing the collapse threshold, not just this button).
sidebar.addEventListener("eunchange", () => {
  console.log("collapsed:", sidebar.collapsed);
});

Layout

Position

position picks which edge of the layout the sidebar is pinned against, which in turn decides which edge the border, toggle button, and resize handle render on: always the edge facing the content next to it, never the outer one. Set it to "start" (the default) when the sidebar is first in DOM order and on the left, or "end" when it's last and on the right.

Page content

Sidebar pinned to the end edge.

<div style="display: flex; height: 100vh;">
  <main>Page content</main>
  <eun-sidebar collapsible resizable position="end" heading="Filters">
    ...
  </eun-sidebar>
</div>

Responsive

Sidebar has no breakpoint logic of its own. A page decides when narrow means "collapse this," typically with window.matchMedia, and just sets collapsed to match. Leave out collapsible and resizable so the built-in edge controls don't also fight over the same state, since a viewport change should be the one thing deciding it here.

When another navigation takes over on small screens, such as eun-toolbar's mobile panel, set hide-below to one of the breakpoint names (mobile, tablet, laptop, desktop) to hide the sidebar below it. Use tablet to match the toolbar's default mobile-breakpoint. It's a plain CSS switch, also covered by sidebar-ssr.css, so the sidebar is never painted on a small screen before it loads.

<eun-sidebar collapsible resizable hide-below="tablet" heading="Menu"
  >...</eun-sidebar
>

The live demo below drives the same idea off the demo box's own width (a ResizeObserver, so you can see it react by dragging the corner without resizing the whole browser), and the code sample underneath shows the realistic version, against the actual page viewport.

Drag the bottom-right corner to resize this box →
<div style="display: flex; height: 100vh;">
  <eun-sidebar id="sidebar" collapse-mode="mini" heading="Menu">
    <nav>...</nav>
  </eun-sidebar>
  <main>Page content</main>
</div>
import { mediaBelow } from "@eunomia/elements/breakpoints.js";

const sidebar = document.querySelector("#sidebar");
const mobileQuery = window.matchMedia(mediaBelow("laptop"));

sidebar.collapsed = mobileQuery.matches;
mobileQuery.addEventListener("change", (event) => {
  sidebar.collapsed = event.matches;
});

Custom header and footer

header and footer are real slots, not fixed content, so put a logo, search field, or a user menu in them. footer is omitted entirely when nothing is slotted into it.

Acme Inc.
Jamie Doe
Dashboard
<eun-sidebar collapsible resizable>
  <div slot="header" style="display: flex; align-items: center; gap: 8px;">
    <eun-icon name="domain" size="20"></eun-icon>
    <strong>Acme Inc.</strong>
  </div>
  <nav>...</nav>
  <div slot="footer" style="display: flex; align-items: center; gap: 8px;">
    <eun-icon name="account_circle" size="20"></eun-icon>
    <span>Jamie Doe</span>
  </div>
</eun-sidebar>

Collapsing

Collapse mode

collapse-mode picks between two shapes for the collapsed state: "mini" (default) keeps a fixed minimum width, an icon rail that stays fully interactive, while "hidden" shrinks the panel away entirely, leaving only the toggle button visible at the edge to reopen it.

collapse-mode="mini"
Content
collapse-mode="hidden"
Content
<!-- Collapses to a fixed-width icon rail — items stay reachable. -->
<eun-sidebar collapsible resizable collapse-mode="mini"> ... </eun-sidebar>

<!-- Collapses away entirely instead — only the toggle button stays
     visible once collapsed. -->
<eun-sidebar collapsible resizable collapse-mode="hidden"> ... </eun-sidebar>

Toggle placement

toggle-placement decides where the built-in toggle button itself renders, independent of collapse-mode: "edge" (default) floats it on the border, vertically centered, while "inline" sits it inside the panel instead, pinned below the body/footer, with a visible label next to the icon. That's a better fit once the sidebar already has its own bottom content (a user menu, an app version) that a floating edge button would otherwise sit awkwardly next to. Leave out collapsible to drop the built-in button entirely and rely purely on dragging the resize border past the mini floor to collapse, with no toggle control at all.

toggle-placement="edge" (default)
Content
toggle-placement="inline"
Content
<!-- Floats on the border, vertically centered (default). -->
<eun-sidebar collapsible resizable toggle-placement="edge"> ... </eun-sidebar>

<!-- Sits inside the panel, pinned below the body/footer, icon + label. -->
<eun-sidebar collapsible resizable toggle-placement="inline"> ... </eun-sidebar>

<!-- No built-in toggle at all, collapse only by dragging the resize
     border past the mini floor. -->
<eun-sidebar resizable> ... </eun-sidebar>

External toggle

Without collapsible, there's no built-in edge button at all, which is useful once your own page chrome (a header menu button, a mobile toolbar) already owns opening and closing it, which is exactly what a hand-rolled off-canvas nav needs. Nothing on the sidebar's own border is clickable here; expand()/collapse()/toggleCollapse() and the collapsed property are the only way in, from wherever you call them.

Toggle menu
Page content
<eun-button id="menu-button" icon="menu">Toggle menu</eun-button>

<div style="display: flex; height: 100vh;">
  <eun-sidebar id="sidebar" collapse-mode="hidden" heading="Menu">
    <nav>...</nav>
  </eun-sidebar>
  <main>Page content</main>
</div>
const sidebar = document.querySelector("#sidebar");
const menuButton = document.querySelector("#menu-button");

menuButton.addEventListener("click", () => sidebar.toggleCollapse());

Server-side rendering

display: flex and the sidebar's own width only ever apply through eun-sidebar's own Shadow DOM, which doesn't exist yet in server-rendered HTML. An unregistered custom element defaults to plain inline layout, so the sidebar renders as an unstyled run of header/body/footer content first, then snaps into a proper flex column the instant the element upgrades. A sidebar meant to start collapsed makes this worse : it renders at full width first, then shrinks down on upgrade instead of the other way around.

Link sidebar-ssr.css in the page's <head> to fix this. It mirrors eun-sidebar's own resting layout and surface (width, background, text and border colors, header, body and footer stacked in a column, padding, and theme="dark") in a plain stylesheet the browser applies from first paint, using :not(:defined) so it stops matching, and hands off cleanly to eun-sidebar's own styles the moment the element upgrades:

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/sidebar-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/sidebar-ssr.css";

This only works for a sidebar whose collapsed/collapse-mode/theme/hide-below are written explicitly in the server-rendered markup, same requirement animate-ssr.css documents for animation:

<eun-sidebar collapsible resizable collapsed collapse-mode="mini"
  >...</eun-sidebar
>

Custom

Override the --sidebar-* CSS variables, listed in full in the API tab. For a sidebar on a dark background, set theme="dark": it switches the surface, text, border and toggle button to the inverse surface in one go. Otherwise, pair --sidebar-background with --sidebar-color whenever the background is dark or saturated enough to need it, since the default text color doesn't adapt to it automatically, and adjust the toggle button with --sidebar-toggle-color and --sidebar-toggle-background-hover.

Use an opaque color for --sidebar-background. The sidebar always paints its own background, so a translucent color adds up with whatever is painted behind it and comes out darker than intended. To show the background behind the sidebar instead, set it to transparent.

Custom styled sidebar.

Page content
<eun-sidebar collapsible resizable class="custom-sidebar" heading="Custom"
  >...</eun-sidebar
>

<!-- The inverse surface, for a sidebar on a dark background. -->
<eun-sidebar collapsible resizable theme="dark" heading="Menu">...</eun-sidebar>
.custom-sidebar {
  --sidebar-width: 240px;
  --sidebar-background: #4c1d95;
  --sidebar-color: #fff;
}

The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.

Variable Sets
--sidebar-background Sets the background color of the sidebar
--sidebar-color Sets the text color of the sidebar
--sidebar-border-color Sets the color of the sidebar's side border
--sidebar-toggle-color Sets the color of the toggle button
--sidebar-toggle-background-hover Sets the background color of the toggle button on hover
--sidebar-focus-outline-color Sets the keyboard focus color of the toggle button's outline and of the resize handle

Keyboard and focus

Keyboard interactions

No special keyboard handling beyond native tab order for the sidebar itself, since it's not modal, so there's nothing to trap or intercept Escape for. The collapse toggle button is a plain, focusable <button>, so Enter/Space activate it natively. The resize handle (when resizable) follows the WAI-ARIA window splitter pattern instead, as a focusable role="separator":

Key Action
ArrowLeft/ArrowRight Adjusts the width by 16px (direction flipped for position="end")
Home Jumps to the mini floor (--sidebar-mini-width)
End Jumps to the max ceiling (--sidebar-max-width)

Keyboard resizing never fully hides the panel (collapse-mode="hidden"'s zero-width state), since that stays the discrete toggle button's job, so Home/End always leave the panel at least at its mini width and fully operable. With collapse-mode="mini", Home collapses it to the icon rail.

Focus management

Collapsing to collapse-mode="hidden" moves focus to the toggle button if focus was inside the sidebar at that moment (mirrors eun-collapse's identical behavior for its own collapsed region), since the collapsed content becomes inert (unreachable by Tab or assistive technology), so focus can't be left stranded inside it. collapse-mode="mini" never does this, because its content stays fully interactive (an icon rail still needs to be operable), so there's nothing to move focus away from.

Aria attributes

role="navigation", with aria-label wired to heading when set. If heading isn't set, set a plain aria-label attribute directly on eun-sidebar, and it's read and forwarded the same way eun-drawer does for its own shell.

The collapse toggle button carries aria-expanded (reflecting the sidebar's expanded/collapsed state) and an aria-label from collapse-label/expand-label.

The resize handle carries role="separator", aria-orientation="vertical", aria-valuenow/aria-valuemin/aria-valuemax (the current/mini/max width in px, kept in sync as it's dragged or keyboard-adjusted), and an aria-label from resize-label.

Reduced motion

The width transition between expanded/collapsed states is removed outright under prefers-reduced-motion: reduce, rather than merely shortened, since unlike eun-drawer's popover-based entrance/exit, nothing here depends on an active transition to hook a display/ overlay change into, so there's no reason to keep even a near-instant one.

Reference links

WAI-ARIA Authoring Practices: Landmark Regions (navigation)
WAI-ARIA Authoring Practices: Window Splitter Pattern
WCAG 2.1: Animation from Interactions