GitHub

Sidebar Nav

Collapsible side navigation with grouped sections, accordion children, badges, and a collapse-to-rail toggle. A Lit web component, because the collapse and accordion states are real runtime behavior.

Stable wc import '@esa/ecology/esa-sidebar-nav'; Angular usage

Preview

Use the chevrons button at the top to collapse to an icon rail; click Reports to expand its children.

<esa-sidebar-nav id="demo"></esa-sidebar-nav>

<script>
  const el = document.getElementById('demo');
  el.items = [
    { label: 'Dashboard', href: '/dashboard', active: true, group: 'Main' },
    { label: 'Projects', href: '/projects', badge: 12, group: 'Main' },
    { label: 'Reports', group: 'Main', children: [
      { label: 'Monthly', href: '/reports/monthly' },
      { label: 'Quarterly', href: '/reports/quarterly' },
    ] },
    { label: 'Team', href: '/team', group: 'Admin' },
    { label: 'Billing', href: '/billing', group: 'Admin', disabled: true },
    { label: 'Settings', href: '/settings', group: 'Admin' },
  ];
</script>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-sidebar-nav';

@Component({
  selector: 'app-sidebar-nav-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-sidebar-nav id="demo" [items]="items"></esa-sidebar-nav>
  `,
})
export class SidebarNavDemo {
  items = [
    { label: 'Dashboard', href: '/dashboard', active: true, group: 'Main' },
    { label: 'Projects', href: '/projects', badge: 12, group: 'Main' },
    { label: 'Reports', group: 'Main', children: [
      { label: 'Monthly', href: '/reports/monthly' },
      { label: 'Quarterly', href: '/reports/quarterly' },
    ] },
    { label: 'Team', href: '/team', group: 'Admin' },
    { label: 'Billing', href: '/billing', group: 'Admin', disabled: true },
    { label: 'Settings', href: '/settings', group: 'Admin' },
  ];
}

States

Non-collapsible — set collapsible="false" to hide the toggle and lock the sidebar at full width.

<esa-sidebar-nav id="demo2" collapsible="false"></esa-sidebar-nav>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-sidebar-nav';

@Component({
  selector: 'app-sidebar-nav-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-sidebar-nav id="demo2" collapsible="false"></esa-sidebar-nav>
  `,
})
export class SidebarNavDemo {}

API

PropTypeDefaultDescription
items EsaSidebarNavItem[] [] Nav items (property, set via JS). Each is { label; href?; icon?; badge?; children?; disabled?; group?; active? }. Consecutive items sharing a group render under a group heading.
label string 'Sidebar' Accessible name for the navigation landmark. Do not include the word "navigation" — the role is announced with it. Give two rails on one page distinct labels so the landmark list can tell them apart.
collapsed boolean false Collapses to an icon rail. Reflected as an attribute.
collapsible boolean true Shows the collapse toggle. Set false to lock the sidebar open. On by default. Write `="false"` to turn it off — unusually for a boolean attribute, this one reads the value.
drawer boolean false Drawer mode: the rail becomes a slide-out overlay instead of an in-flow column, for narrow viewports. esa-app-shell sets this from a matchMedia. The <nav> landmark stays on the page in both modes — only its contents hide — and the toggle stays inside the landmark, so navigating to it while shut lands on the control that opens it.
drawer-open boolean false Whether the drawer is shown. Only meaningful with drawer. While open, the drawer emits drawerchange so the host can make the rest of the page inert. Property: `drawerOpen`.

Events

EventTypeDefaultDescription
collapsedchange CustomEvent — Fired when the collapse toggle is clicked. detail: { collapsed: boolean }. Bubbles and composed.
Binding these events from Angular

Every one of these is a CustomEvent, so the payload is on $event.detail — $event.target.value reads empty, with no error. The names are lowercase and Angular passes them to addEventListener verbatim, so camel-casing one means it never fires.

  • (collapsedchange)="onCollapsedchange($event.detail)"
  • (drawerchange)="onDrawerchange($event.detail)"

items is an array property — set it in JS, not as an attribute. Each EsaSidebarNavItem.icon is a raw inline SVG string.

Slots

PropTypeDefaultDescription
header slot — Logo / brand area pinned above the nav list.

Accessibility

  • Renders a <nav aria-label="Sidebar navigation"> over a semantic list.
  • Active leaf links carry aria-current="page"; parent accordions expose aria-expanded.
  • The collapse toggle's aria-label flips between Collapse sidebar / Expand sidebar.
  • Disabled items are removed from the tab order (tabindex="-1", pointer-events: none).

Theming surface

Every public token this component reads, extracted from its source at build time, split by blast radius: the hooks that belong to this component, then the shared and system tokens it consumes alongside everything else. The Resolves to column walks each token's real lineage — component → semantic → primitive → raw value — so you can see exactly where re-pointing it lands. A spoke re-skins by overriding these in its theme-<slug>.css; component internals are never edited. No hook for what you need? File it with /request-lego.

Type roles it names

This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.

RoleRe-pointReach
.typography-eyebrow-md
  • --typography-eyebrow-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-eyebrow-md-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-eyebrow-md-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-eyebrow-md-letter-spacing 0.03em via --letter-spacing-wide
  • --typography-eyebrow-md-line-height 1.6 via --line-height-normal
  • --typography-eyebrow-md-text-transform uppercase via --text-transform-uppercase
+3 more components esa-command-palette, esa-entity-search, esa-search-panel
.typography-label-md
  • --typography-label-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-label-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-md-line-height 1.6 via --line-height-normal
+33 more components esa-app-bar, esa-breadcrumbs, esa-button-group, esa-button-toggle, esa-card, esa-checkbox-group, esa-color-picker, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-danger-zone, esa-date-picker, esa-dialog, esa-empty-state, esa-entity-search, esa-file-upload, esa-filter-container, esa-form-field, esa-header-nav, esa-input-tag, esa-link-column, esa-page-header, esa-pillbox, esa-popover, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-side-dialog, esa-stat, esa-text-field, esa-textarea, esa-tooltip
.typography-label-sm
  • --typography-label-sm-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-sm-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-label-sm-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-sm-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-sm-line-height 1.6 via --line-height-normal
+3 more components esa-breadcrumbs, esa-progress-bar, esa-search-panel
.typography-microcopy-md
  • --typography-microcopy-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-microcopy-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-md-line-height 1 via --line-height-none
+4 more components esa-button, esa-button-toggle, esa-pill, esa-range-slider
.typography-microcopy-sm
  • --typography-microcopy-sm-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-sm-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-microcopy-sm-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-sm-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-sm-line-height 1 via --line-height-none
+3 more components esa-filter-clear-button, esa-filter-dropdown, esa-pill
.typography-microcopy-xs-strong
  • --typography-microcopy-xs-strong-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-xs-strong-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-microcopy-xs-strong-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-microcopy-xs-strong-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-xs-strong-line-height 1 via --line-height-none
+5 more components esa-badge, esa-button-toggle, esa-chip-group, esa-filter-dropdown, esa-tab-layout

Wired to this component

No other component reads these — re-point them and esa-sidebar-nav is the only thing that moves.

Token Tier Resolves to
--sidenav-active-border-width component 0
--sidenav-icon-size component 18px
--sidenav-icon-size-collapsed component 18px
--sidenav-item-gap component 0
--sidenav-item-height component 40px
--sidenav-link-bg component transparent
--sidenav-nested-gap component 0
--sidenav-section-margin-top component 0
--sidenav-width component 280px
--sidenav-width-collapsed component 72px

Shared tokens it reads

Owned by a family or by the semantic layer. Still overridable — but scope the override (esa-foo { --form-border-color: … }) unless you mean to move everything that reads it. Reach says what else that is.

Token Reach Resolves to
--focus-ring-color component focus ring group · +34 more #3e9b4f via --color-border-default-focus → --color-grass-10
--focus-ring-offset component focus ring group · +31 more 2px
--focus-ring-width component focus ring group · +35 more 2px via --border-width-focus → --border-width-200
--animation-overlay-enter semantic every component 250ms via --duration-250
--border-width-default semantic every component 1px via --border-width-100
--color-background-brand semantic every component #46a758 via --color-grass-9
--color-background-brand-subtle semantic every component #fbfefb via --color-grass-1
--color-background-elevation-sunken semantic every component #f0f0f0 via --color-gray-3
--color-background-overlay-backdrop semantic every component rgba(0, 0, 0, 0.5) via --color-black-a-7
--color-border-default semantic every component one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half. * * This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus
--color-border-default-subtle semantic every component #d9d9d9 via --color-gray-6
--color-content-brand semantic every component #2a7e3b via --color-grass-11
--color-content-default semantic every component #202020 via --color-gray-12
--color-content-default-knockout semantic every component #fcfcfc via --color-gray-1
--color-content-default-secondary semantic every component #646464 via --color-gray-11
--radius-md semantic every component 0.5rem via --radius-200
--radius-pill semantic every component 9999px via --radius-full
--transition-base semantic every component 200ms via --duration-200
--typography-font-weight-semibold semantic every component 550 via --font-weight-550
--z-modal semantic every component 400
--spacing-100 primitive every component 0.25rem
--spacing-200 primitive every component 0.5rem
--spacing-300 primitive every component 0.75rem
--spacing-400 primitive every component 1rem