Eunomia v1.0.0-beta.2
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 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
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
aria-label-expandstring"Expand sidebar"The accessible label of the toggle button while collapsed
aria-label-collapsestring"Collapse sidebar"The accessible label of the toggle button while expanded
aria-label-resizestring"Resize sidebar"The accessible label of the drag-to-resize border
position'start' | 'end''start'Which edge of the layout the sidebar is pinned against
headingstringAn optional heading, rendered as the header slot's fallback content, and used as the accessible name when set
collapsiblebooleantrueWhether 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. Set to false to rely on resizable alone
collapsedbooleanfalseWhether the sidebar is currently collapsed
resizablebooleantrueWhether 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.

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 past the collapse threshold. Dragging the border all the way to a full hide is reachable regardless of collapseMode, though keyboard resizing never goes past the mini floor

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
--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-gapSets the gap inside the header and footer rows
--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. Dragging well past the floor and releasing fully hides the panel regardless of collapse-mode, since keyboard resizing intentionally stops at the floor instead, so a keyboard-only user can't accidentally arrow their way into making the panel unreachable. Set resizable="false" to remove the handle entirely, 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
    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-transparent, #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);
});

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 position="end" heading="Filters"> ... </eun-sidebar>
</div>

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 collapse-mode="mini"> ... </eun-sidebar>

<!-- Collapses away entirely instead — only the toggle button stays
     visible once collapsed. -->
<eun-sidebar 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. Set collapsible="false" 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 toggle-placement="edge"> ... </eun-sidebar>

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

<!-- No built-in toggle at all — collapse only by dragging the resize
     border past the mini floor. `collapsible` is a boolean property:
     HTML boolean attributes are presence-based, so collapsible="false"
     would still read as true — set the property itself instead. -->
<eun-sidebar id="sidebar"> ... </eun-sidebar>
document.querySelector("#sidebar").collapsible = false;

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

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. Pair it with collapsible/resizable set to false (as properties, in JS — see below) 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.

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) — 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>
const sidebar = document.querySelector("#sidebar");
const mobileQuery = window.matchMedia("(max-width: 1023px)");

// collapsible/resizable are boolean properties, not attributes — HTML
// boolean attributes are presence-based, so collapsible="false" in the
// markup above would still read as true.
sidebar.collapsible = false;
sidebar.resizable = false;

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

External toggle

collapsible set false (as a property, in JS — see below) drops the built-in edge button entirely, which is useful once your own page chrome (a header menu button, a mobile toolbar) already owns opening and closing it — a hand-rolled off-canvas nav reaches for exactly this. 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");

// collapsible/resizable are boolean properties, not attributes — HTML
// boolean attributes are presence-based, so collapsible="false" in the
// markup above would still read as true.
sidebar.collapsible = false;
sidebar.resizable = false;

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

Custom

Override the --sidebar-* CSS variables, listed in full in the API tab. 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.

Custom styled sidebar.

Page content
<eun-sidebar class="custom-sidebar" heading="Custom">...</eun-sidebar>
.custom-sidebar {
  --sidebar-width: 240px;
  --sidebar-background: #4c1d95;
  --sidebar-color: #fff;
}

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.

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 aria-label-collapse/aria-label-expand.

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 aria-label-resize.

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