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 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.
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 collapsible resizable 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 collapsible resizable 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 collapsible resizable 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 collapsible resizable 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 collapsible resizable heading="Menu">
<nav>...</nav>
</eun-sidebar>
<main><ng-content></ng-content></main>
</div>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppShellComponent {}
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-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 |
|---|---|---|---|
| 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-label | string | — | The accessible label of the toggle button while collapsed. Defaults to the localized string for the current locale (English: "Expand sidebar") |
| collapse-label | string | — | The accessible label of the toggle button while expanded. Defaults to the localized string for the current locale (English: "Collapse sidebar") |
| resize-label | string | — | 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 |
| heading | string | — | An optional heading, rendered as the header slot's fallback content, and used as the accessible name when set |
| collapsible | boolean | false | 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. Leave it off to rely on resizable alone |
| collapsed | boolean | false | Whether the sidebar is currently collapsed |
| resizable | boolean | false | 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
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| 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
| 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 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
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. Use an opaque color, or transparent to show the background behind 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-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --sidebar-padding is set |
| --sidebar-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --sidebar-padding is set |
| --sidebar-gap | Sets the gap inside the header and footer rows |
| --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 |
| --sidebar-resize-handle-delay | Sets how long the pointer must rest on the border before the resize handle shows. Set to 0 to show it right away |
| --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. Releasing a drag below the floor collapses the panel into the shapecollapse-modesets, so withminiit 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 withresizable, 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
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.
<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.
<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.
<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.
<!-- 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.
<!-- 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.
<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.
<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