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
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
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.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/sidebar.js";
import "@eunomia/elements/icon.js";
</script>
<div style="display: flex; height: 100vh;">
<eun-sidebar heading="Menu">
<nav>...</nav>
</eun-sidebar>
<main>Page content</main>
</div>
npm install @eunomia/elements
import "@eunomia/elements/sidebar.js";
import "@eunomia/elements/icon.js";
function AppShell({ children }) {
return (
<div style={{ display: "flex", height: "100vh" }}>
<eun-sidebar heading="Menu">
<nav>...</nav>
</eun-sidebar>
<main>{children}</main>
</div>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/sidebar.js";
import "@eunomia/elements/icon.js";
export function AppShell({ children }) {
return (
<div style={{ display: "flex", height: "100vh" }}>
<eun-sidebar heading="Menu">
<nav>...</nav>
</eun-sidebar>
<main>{children}</main>
</div>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/sidebar.js";
import "@eunomia/elements/icon.js";
</script>
<template>
<div style="display: flex; height: 100vh;">
<eun-sidebar heading="Menu">
<nav>...</nav>
</eun-sidebar>
<main><slot /></main>
</div>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/sidebar.js";
import "@eunomia/elements/icon.js";
@Component({
selector: "app-shell",
template: `
<div style="display: flex; height: 100vh;">
<eun-sidebar heading="Menu">
<nav>...</nav>
</eun-sidebar>
<main><ng-content></ng-content></main>
</div>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppShellComponent {}
Alternatives
eun-drawerGuidance
- Use
eun-sidebarfor 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
positionto 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/resizableand 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-sidebarfor a focused, single-task panel triggered by an action, since that's whateun-draweris 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
| Name | Type | Default | Description |
|---|---|---|---|
| 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-expand | string | "Expand sidebar" | The accessible label of the toggle button while collapsed |
| aria-label-collapse | string | "Collapse sidebar" | The accessible label of the toggle button while expanded |
| aria-label-resize | string | "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 |
| heading | string | — | An optional heading, rendered as the header slot's fallback content, and used as the accessible name when set |
| collapsible | boolean | true | Whether 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 |
| collapsed | boolean | false | Whether the sidebar is currently collapsed |
| resizable | boolean | true | Whether 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
Slots
| Name | Description |
|---|---|
| (default) | The sidebar's scrollable body content, typically a navigation element |
| header | Optional header content, such as a logo, title, or search, fixed above the scrollable body |
| footer | Optional footer content, such as a user menu, fixed below the scrollable body. Omitted entirely when nothing is slotted |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired 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
CSS custom properties
| Name | Description |
|---|---|
| --sidebar-width | Sets 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-width | Sets the width of the mini collapsed state, also the floor of the drag-resize range |
| --sidebar-max-width | Sets the ceiling of the drag-resize range |
| --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-padding | Sets the padding of the header, body, and footer |
| --sidebar-gap | Sets the gap inside the header and footer rows |
| --sidebar-transition-duration | Sets 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 (
collapsibleto hide it, or drive it entirely from outside viaexpand()/collapse()/toggleCollapse()andeunchange, as the buttons above the demo do). - Drag-to-resize: the border itself, freely between
--sidebar-mini-widthand--sidebar-max-width, or focused and adjusted withArrowLeft/ArrowRight/Home/End. Dragging well past the floor and releasing fully hides the panel regardless ofcollapse-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. Setresizable="false"to remove the handle entirely, independently ofcollapsible. - 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.
<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.
<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.
<!-- 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.
<!-- 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.
<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.
<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.
<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.
<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