Skip to content
Ndop UI

Sidebar

A collapsible side panel for an app's navigation, which becomes a dialog on small screens.

Home
Your workspace.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
  Sidebar,
  SidebarContainer,
  SidebarContent,
  SidebarFooter,
  SidebarGap,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInner,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
} from '@/ui/sidebar/sidebar';

/**
 * An app shell: a sidebar of grouped links that collapses to its icons
 * with the trigger, the rail or Ctrl+B (⌘B), and opens as a dialog on
 * small screens. In an app, drop the classes that keep it in this box,
 * and put `ndSidebarInset` on the page's `main`.
 */
@Component({
  selector: 'example-sidebar-demo',
  imports: [
    NdIcon,
    Sidebar,
    SidebarContainer,
    SidebarContent,
    SidebarFooter,
    SidebarGap,
    SidebarGroup,
    SidebarGroupContent,
    SidebarGroupLabel,
    SidebarHeader,
    SidebarInner,
    SidebarInset,
    SidebarMenu,
    SidebarMenuButton,
    SidebarMenuItem,
    SidebarProvider,
    SidebarRail,
    SidebarTrigger,
  ],
  template: `
    <div
      ndSidebarProvider
      [shortcut]="null"
      class="relative min-h-[420px] w-[640px] max-w-full overflow-hidden rounded-lg border"
    >
      <div ndSidebar collapsible="icon">
        <div ndSidebarGap></div>
        <div ndSidebarContainer aria-label="Main" class="absolute h-full">
          <div ndSidebarInner>
            <div ndSidebarHeader>
              <span class="px-2 text-sm font-semibold">Acme Inc</span>
            </div>
            <div ndSidebarContent>
              <nav ndSidebarGroup aria-labelledby="sidebar-demo-app">
                <div ndSidebarGroupLabel id="sidebar-demo-app">Application</div>
                <div ndSidebarGroupContent>
                  <ul ndSidebarMenu>
                    @for (link of links; track link.href) {
                      <li ndSidebarMenuItem>
                        <a
                          ndSidebarMenuButton
                          [href]="link.href"
                          [active]="link.href === '#home'"
                        >
                          <svg [ndIcon]="link.icon"></svg>
                          <span>{{ link.label }}</span>
                        </a>
                      </li>
                    }
                  </ul>
                </div>
              </nav>
            </div>
            <div ndSidebarFooter>
              <a ndSidebarMenuButton href="#settings">
                <svg ndIcon="settings"></svg>
                <span>Settings</span>
              </a>
            </div>
          </div>
          <button ndSidebarRail title="Toggle sidebar"></button>
        </div>
      </div>
      <div ndSidebarInset>
        <header class="flex h-12 items-center gap-2 border-b px-4">
          <button ndSidebarTrigger>
            <svg ndIcon="panel-left"></svg>
            <span class="sr-only">Toggle sidebar</span>
          </button>
          <span class="text-sm font-medium">Home</span>
        </header>
        <div class="p-4 text-sm text-muted-foreground">Your workspace.</div>
      </div>
    </div>
  `,
})
export class SidebarDemoExample {
  protected readonly links = [
    { href: '#home', label: 'Home', icon: 'house' },
    { href: '#inbox', label: 'Inbox', icon: 'inbox' },
    { href: '#calendar', label: 'Calendar', icon: 'calendar' },
    { href: '#search', label: 'Search', icon: 'search' },
  ];
}

A sidebar holds an app's main navigation beside its content. On desktop it collapses off-canvas or down to its icons, with its trigger, a rail along its edge, or Ctrl+B (⌘B on a Mac); collapsed off-canvas, it's inert, so its links leave the tab order. Below the md breakpoint it opens as a modal dialog with a focus trap, closed by Escape, a backdrop click or following a link.

Installation

npx @ndop/cli add sidebar

Usage

import {
  Sidebar,
  SidebarContainer,
  SidebarContent,
  SidebarFooter,
  SidebarGap,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInner,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
} from '@/ui/sidebar/sidebar';
<div
  ndSidebarProvider
  [shortcut]="null"
  class="relative min-h-[420px] w-[640px] max-w-full overflow-hidden rounded-lg border"
>
  <div ndSidebar collapsible="icon">
    <div ndSidebarGap></div>
    <div ndSidebarContainer aria-label="Main" class="absolute h-full">
      <div ndSidebarInner>
        <div ndSidebarHeader>
          <span class="px-2 text-sm font-semibold">Acme Inc</span>
        </div>
        <div ndSidebarContent>
          <nav ndSidebarGroup aria-labelledby="sidebar-demo-app">
            <div ndSidebarGroupLabel id="sidebar-demo-app">Application</div>
            <div ndSidebarGroupContent>
              <ul ndSidebarMenu>
                @for (link of links; track link.href) {
                  <li ndSidebarMenuItem>
                    <a
                      ndSidebarMenuButton
                      [href]="link.href"
                      [active]="link.href === '#home'"
                    >
                      <svg [ndIcon]="link.icon"></svg>
                      <span>{{ link.label }}</span>
                    </a>
                  </li>
                }
              </ul>
            </div>
          </nav>
        </div>
        <div ndSidebarFooter>
          <a ndSidebarMenuButton href="#settings">
            <svg ndIcon="settings"></svg>
            <span>Settings</span>
          </a>
        </div>
      </div>
      <button ndSidebarRail title="Toggle sidebar"></button>
    </div>
  </div>
  <div ndSidebarInset>
    <header class="flex h-12 items-center gap-2 border-b px-4">
      <button ndSidebarTrigger>
        <svg ndIcon="panel-left"></svg>
        <span class="sr-only">Toggle sidebar</span>
      </button>
      <span class="text-sm font-medium">Home</span>
    </header>
    <div class="p-4 text-sm text-muted-foreground">Your workspace.</div>
  </div>
</div>

Examples

A floating sidebar that slides off-canvas, with a badge and nested links.

import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
  Sidebar,
  SidebarContainer,
  SidebarContent,
  SidebarGap,
  SidebarGroup,
  SidebarGroupContent,
  SidebarInner,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSub,
  SidebarMenuSubButton,
  SidebarMenuSubItem,
  SidebarProvider,
  SidebarTrigger,
} from '@/ui/sidebar/sidebar';

/**
 * A floating sidebar that slides off-canvas, with an unread count and
 * nested links. Collapsed, it's inert, so its links leave the tab order.
 */
@Component({
  selector: 'example-sidebar-floating',
  imports: [
    NdIcon,
    Sidebar,
    SidebarContainer,
    SidebarContent,
    SidebarGap,
    SidebarGroup,
    SidebarGroupContent,
    SidebarInner,
    SidebarInset,
    SidebarMenu,
    SidebarMenuBadge,
    SidebarMenuButton,
    SidebarMenuItem,
    SidebarMenuSub,
    SidebarMenuSubButton,
    SidebarMenuSubItem,
    SidebarProvider,
    SidebarTrigger,
  ],
  template: `
    <div
      ndSidebarProvider
      [shortcut]="null"
      class="relative min-h-[420px] w-[640px] max-w-full overflow-hidden rounded-lg border"
    >
      <div ndSidebar variant="floating">
        <div ndSidebarGap></div>
        <div ndSidebarContainer aria-label="Workspace" class="absolute h-full">
          <div ndSidebarInner>
            <div ndSidebarContent>
              <nav ndSidebarGroup aria-label="Workspace pages">
                <div ndSidebarGroupContent>
                  <ul ndSidebarMenu>
                    <li ndSidebarMenuItem>
                      <a ndSidebarMenuButton href="#inbox" active>
                        <svg ndIcon="inbox"></svg>
                        <span>Inbox</span>
                      </a>
                      <div ndSidebarMenuBadge>24</div>
                    </li>
                    <li ndSidebarMenuItem>
                      <a ndSidebarMenuButton href="#guides">
                        <svg ndIcon="folder-open"></svg>
                        <span>Guides</span>
                      </a>
                      <ul ndSidebarMenuSub>
                        <li ndSidebarMenuSubItem>
                          <a ndSidebarMenuSubButton href="#installation">
                            Installation
                          </a>
                        </li>
                        <li ndSidebarMenuSubItem>
                          <a ndSidebarMenuSubButton href="#theming">Theming</a>
                        </li>
                      </ul>
                    </li>
                  </ul>
                </div>
              </nav>
            </div>
          </div>
        </div>
      </div>
      <div ndSidebarInset>
        <header class="flex h-12 items-center gap-2 px-4">
          <button ndSidebarTrigger>
            <svg ndIcon="panel-left"></svg>
            <span class="sr-only">Toggle sidebar</span>
          </button>
        </header>
        <div class="p-4 text-sm text-muted-foreground">Inbox</div>
      </div>
    </div>
  `,
})
export class SidebarFloatingExample {}

Accessibility

Implements the Dialog (modal) pattern of the WAI-ARIA Authoring Practices.

Keyboard

Keys the component handles.

Key Action
Ctrl+bToggles the sidebar (⌘B on a Mac), unless shortcut is null.
EnterOn the trigger, toggles the sidebar.
EscapeCloses the mobile sidebar and returns focus.
TabMoves through the sidebar's content; on mobile, it stays inside.

Focus

  • Opening the mobile sidebar focuses its first focusable element; closing it returns focus to the trigger, or wherever focus was before.
  • Collapsing the desktop sidebar off-canvas while focus is inside moves focus to the trigger that collapsed it.
  • Unchanged from the native input. Focusing it and leaving it marks the bound field touched, through the forms library.
  • The separator never receives focus and never changes focus.

Right-to-left

start and end are logical: the start sidebar is on the right in right-to-left layouts, on desktop and on mobile. Text direction follows the document or the element's dir; Ndop changes nothing. Nothing mirrors: orientation is the same in both directions.

API reference

SidebarProvider

[ndSidebarProvider]

The page around a sidebar (@ndop/primitives/sidebar): it holds the sidebar's state, sets its widths, and lays the sidebar beside the page's main content. Ctrl+B (⌘B on a Mac) toggles the sidebar.

SidebarProvider binding Type Default
[class]
Extra classes, merged last so they win.
string''
[defaultOpen]
Whether the desktop sidebar starts expanded, when open isn't bound.
booleantrue
[mobileQuery]
The CSS media query below which the sidebar is a mobile dialog.
string'(max-width: 767.98px)'
[shortcut]
The letter that toggles the sidebar with Ctrl or ⌘, or null.
string | null'b'
[(open)]
Whether the desktop sidebar is expanded. Two-way bindable.
booleantrue
[(openMobile)]
Whether the mobile sidebar is open. Two-way bindable.
booleanfalse
Data attribute Values
data-slotsidebar-wrapper
data-state —
data-mobile —

Sidebar

[ndSidebar]

The sidebar: on desktop, it collapses off-canvas, to its icons, or not at all; below the md breakpoint, it opens as a dialog. Put a SidebarGap and a SidebarContainer in it, unless collapsible is none: then put the content in it directly.

Sidebar binding Type Default
[variant]
How it looks: flush with the page, floating or inset.
'sidebar' | 'floating' | 'inset''sidebar'
[class]
Extra classes, merged last so they win.
string''
[side]
The side it sits on: start or end.
'start' | 'end''start'
[collapsible]
How it collapses on desktop.
'icon' | 'none' | 'offcanvas''offcanvas'
Data attribute Values
data-slotsidebar
data-variant —
data-state —
data-collapsible —
data-side —
data-mobile —

SidebarGap

[ndSidebarGap]

Keeps the sidebar's room in the page beside it, widening and narrowing as the sidebar does.

Manages aria-hidden.

SidebarGap binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-gap

SidebarContainer

[ndSidebarContainer]role region, dialog on mobile

The fixed panel holding the sidebar's content: off-canvas or narrowed while collapsed, and the dialog panel on mobile. Name it with aria-label.

Manages id, aria-modal, inert, hidden.

SidebarContainer binding Type Default
[class]
Extra classes, merged last so they win.
string''
[backdropClass]
Classes of the mobile overlay's backdrop.
string'cdk-overlay-dark-backdrop'
Data attribute Values
data-slotsidebar-container
data-mobile —
data-state —

SidebarInner

[ndSidebarInner]

The sidebar's surface, inside its container: a column for its header, content and footer.

SidebarInner binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-inner
data-sidebarsidebar

SidebarTrigger

button[ndSidebarTrigger]

The button that opens and closes the sidebar. Name it with sr-only text, and put the panel icon in it.

Manages aria-expanded, aria-controls.

SidebarTrigger binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-trigger
data-sidebartrigger
data-state —

SidebarRail

button[ndSidebarRail]

A thin strip along the sidebar's inner edge that toggles it with the pointer, showing a line on hover.

Manages aria-hidden, tabindex.

SidebarRail binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-rail
data-sidebarrail

SidebarInset

[ndSidebarInset]

The page's main content beside the sidebar; with the inset variant, it's a rounded card.

SidebarInset binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-inset

SidebarInput

input[ndSidebarInput]role native

A search field in the sidebar, styled like an Input.

Manages aria-invalid, aria-describedby.

SidebarInput binding Type Default
[class]
Extra classes, merged last so they win.
string''
[id]
Element id; also the key a label[ndLabel] uses to find this input.
string | undefined—
[invalid]
Marks the input invalid when no forms library is bound; with one, the field's state decides.
booleanfalse
Data attribute Values
data-slotsidebar-input
data-sidebarinput
data-disabled —
data-invalid —

SidebarHeader

[ndSidebarHeader]

The top of the sidebar, such as a team switcher.

SidebarHeader binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-header
data-sidebarheader

SidebarFooter

[ndSidebarFooter]

The bottom of the sidebar, such as the user's account.

SidebarFooter binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-footer
data-sidebarfooter

SidebarSeparator

[ndSidebarSeparator]role separator or none

A line between parts of the sidebar.

Manages aria-orientation.

SidebarSeparator binding Type Default
[class]
Extra classes, merged last so they win.
string''
[orientation]
Layout direction of the separator.
'horizontal' | 'vertical''horizontal'
[decorative]
Whether the separator is purely visual. Decorative separators are hidden from assistive technology.
booleanfalse
Data attribute Values
data-slotsidebar-separator
data-sidebarseparator
data-orientation —

SidebarContent

[ndSidebarContent]

The scrolling middle of the sidebar, holding its groups.

SidebarContent binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-content
data-sidebarcontent

SidebarGroup

[ndSidebarGroup]

A group of the sidebar's menu, with a label.

SidebarGroup binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-group
data-sidebargroup

SidebarGroupLabel

[ndSidebarGroupLabel]

A group's label, which fades out while the sidebar shows only icons.

SidebarGroupLabel binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-group-label
data-sidebargroup-label

SidebarGroupAction

[ndSidebarGroupAction]

A button at the end of a group's label, such as "Add project". Name it with sr-only text.

SidebarGroupAction binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-group-action
data-sidebargroup-action

SidebarGroupContent

[ndSidebarGroupContent]

The content of a group, usually a SidebarMenu.

SidebarGroupContent binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-group-content
data-sidebargroup-content

SidebarMenu

[ndSidebarMenu]

A list of menu items, on a ul.

SidebarMenu binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-menu
data-sidebarmenu

SidebarMenuItem

[ndSidebarMenuItem]

One menu item, on an li.

SidebarMenuItem binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-menu-item
data-sidebarmenu-item

SidebarMenuButton

[ndSidebarMenuButton]

A menu item's link or button: put an icon first and the label after; while the sidebar shows only icons, the label is clipped but still names it. active marks the current page.

Manages aria-current.

SidebarMenuButton binding Type Default
[variant]
Visual style.
'default' | 'outline''default'
[size]
Height.
'default' | 'sm' | 'lg''default'
[class]
Extra classes, merged last so they win.
string''
[active]
Marks the link to the current page.
booleanfalse
Data attribute Values
data-slotsidebar-menu-button
data-sidebarmenu-button
data-size —
data-active —

SidebarMenuAction

[ndSidebarMenuAction]

A button at the end of a menu item, such as its own menu. Name it with sr-only text; showOnHover shows it only while the item is hovered or focused, on wide screens.

SidebarMenuAction binding Type Default
[showOnHover]
Shows it only while its item is hovered or focused, on wide screens.
booleanfalse
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-menu-action
data-sidebarmenu-action

SidebarMenuBadge

[ndSidebarMenuBadge]

A count at the end of a menu item, such as unread messages.

SidebarMenuBadge binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-menu-badge
data-sidebarmenu-badge

SidebarMenuSkeleton

[ndSidebarMenuSkeleton]

A placeholder menu item while the menu loads, with a text bar of a random width. Put Skeletons in it.

SidebarMenuSkeleton binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-menu-skeleton
data-sidebarmenu-skeleton

SidebarMenuSub

[ndSidebarMenuSub]

A nested list of a menu item, hidden while the sidebar shows only icons.

SidebarMenuSub binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-menu-sub
data-sidebarmenu-sub

SidebarMenuSubItem

[ndSidebarMenuSubItem]

One item of a nested list, on an li.

SidebarMenuSubItem binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotsidebar-menu-sub-item
data-sidebarmenu-sub-item

SidebarMenuSubButton

[ndSidebarMenuSubButton]

A nested item's link: size is sm or md; active marks the current page.

Manages aria-current.

SidebarMenuSubButton binding Type Default
[size]
Text size: sm or md.
'sm' | 'md''md'
[class]
Extra classes, merged last so they win.
string''
[active]
Marks the link to the current page.
booleanfalse
Data attribute Values
data-slotsidebar-menu-sub-button
data-sidebarmenu-sub-button
data-size —
data-active —

Agent notes

Do

  • Put a nav with an aria-label in it, and name its container too.
  • Mark the current page's link with active.
  • Give each menu link an icon and a text label: collapsed to icons, the label is clipped but still names the link.

Don't

  • Don't put the page's only way to an action in a collapsed sidebar.
  • Don't nest menus more than one level; use separate groups.