sidebar

A composable, themeable and customizable sidebar component.

PreviousNext
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import {
  lucideCalendar,
  lucideChevronDown,
  lucideChevronsUpDown,
  lucideGalleryVerticalEnd,
  lucideHouse,
  lucideInbox,
  lucideLogOut,
  lucideSearch,
  lucideSettings,
  lucideUser,
} from '@ng-icons/lucide';

import { ZardAvatarComponent } from '@/shared/components/avatar/avatar.component';
import { ZardBreadcrumbImports } from '@/shared/components/breadcrumb/breadcrumb.imports';
import { ZardDropdownImports } from '@/shared/components/dropdown/dropdown.imports';
import { ZardSeparatorComponent } from '@/shared/components/separator/separator.component';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';
import { ZardSkeletonComponent } from '@/shared/components/skeleton/skeleton.component';

interface NavItem {
  readonly title: string;
  readonly icon: string;
}

@Component({
  selector: 'z-demo-sidebar-preview',
  imports: [
    ZardSidebarImports,
    ZardDropdownImports,
    ZardBreadcrumbImports,
    ZardSeparatorComponent,
    ZardSkeletonComponent,
    ZardAvatarComponent,
    NgIcon,
  ],
  template: `
    <!--
      In a real app the provider is the page shell and needs no extra classes. Here transform-gpu
      turns it into the containing block for the sidebar's fixed panel, so the demo stays inside the
      documentation page instead of covering the viewport.
    -->
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-128 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar zCollapsible="icon" class="h-full">
        <div z-sidebar-header>
          <ul z-sidebar-menu>
            <li z-sidebar-menu-item>
              <button z-sidebar-menu-button zSize="lg" z-dropdown [zDropdownMenu]="workspaces">
                <div
                  class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
                >
                  <ng-icon name="lucideGalleryVerticalEnd" class="size-4" />
                </div>

                <div class="grid flex-1 text-left text-sm/tight">
                  <span class="truncate font-medium">{{ workspace() }}</span>
                  <span class="text-muted-foreground truncate text-xs">Enterprise</span>
                </div>

                <ng-icon name="lucideChevronDown" class="ml-auto" />
              </button>

              <z-dropdown-menu-content
                #workspaces="zDropdownMenuContent"
                class="w-(--z-dropdown-menu-trigger-width) min-w-56 rounded-lg"
                zAlign="start"
              >
                @for (name of workspaceNames; track name) {
                  <z-dropdown-menu-item (click)="workspace.set(name)">{{ name }}</z-dropdown-menu-item>
                }
              </z-dropdown-menu-content>
            </li>
          </ul>
        </div>

        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Platform</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                @for (item of navItems; track item.title) {
                  <li z-sidebar-menu-item>
                    <button
                      z-sidebar-menu-button
                      [zActive]="item.title === active()"
                      [zTooltip]="item.title"
                      (click)="active.set(item.title)"
                    >
                      <ng-icon [name]="item.icon" />
                      <span>{{ item.title }}</span>
                    </button>
                  </li>
                }
              </ul>
            </div>
          </div>
        </z-sidebar-content>

        <div z-sidebar-footer>
          <ul z-sidebar-menu>
            <li z-sidebar-menu-item>
              <button z-sidebar-menu-button zSize="lg" z-dropdown [zDropdownMenu]="account">
                <z-avatar
                  class="size-8 rounded-lg"
                  zSrc="https://github.com/zard-ui.png"
                  zAlt="zard ui"
                  zFallback="ZU"
                />

                <div class="grid flex-1 text-left text-sm/tight">
                  <span class="truncate font-medium">zard ui</span>
                  <span class="text-muted-foreground truncate text-xs">m&#64;example.com</span>
                </div>

                <ng-icon name="lucideChevronsUpDown" class="ml-auto" />
              </button>

              <z-dropdown-menu-content
                #account="zDropdownMenuContent"
                class="w-(--z-dropdown-menu-trigger-width) min-w-56 rounded-lg"
                zSide="right"
                zAlign="end"
              >
                <z-dropdown-menu-item>
                  <ng-icon name="lucideUser" />
                  Profile
                </z-dropdown-menu-item>
                <z-dropdown-menu-item>
                  <ng-icon name="lucideSettings" />
                  Settings
                </z-dropdown-menu-item>
                <z-dropdown-menu-separator />
                <z-dropdown-menu-item>
                  <ng-icon name="lucideLogOut" />
                  Log out
                </z-dropdown-menu-item>
              </z-dropdown-menu-content>
            </li>
          </ul>
        </div>

        <button z-sidebar-rail aria-label="Toggle Sidebar"></button>
      </z-sidebar>

      <main z-sidebar-inset class="overflow-auto">
        <header class="bg-background sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b px-4">
          <button z-sidebar-trigger class="-ml-1" aria-label="Toggle Sidebar"></button>

          <z-separator
            zOrientation="vertical"
            class="mr-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center"
          />

          <z-breadcrumb zLabel="Breadcrumb">
            <z-breadcrumb-item>
              <span z-breadcrumb-page class="text-muted-foreground">Platform</span>
            </z-breadcrumb-item>
            <z-breadcrumb-item>
              <span z-breadcrumb-page>{{ active() }}</span>
            </z-breadcrumb-item>
          </z-breadcrumb>
        </header>

        <div class="flex flex-1 flex-col gap-4 p-4">
          <div class="grid auto-rows-min grid-cols-2 gap-4">
            @for (tile of tiles; track tile) {
              <z-skeleton class="bg-muted/50 aspect-video rounded-xl" />
            }
          </div>

          <z-skeleton class="bg-muted/50 min-h-64 flex-1 rounded-xl" />
        </div>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [
    provideIcons({
      lucideCalendar,
      lucideChevronDown,
      lucideChevronsUpDown,
      lucideGalleryVerticalEnd,
      lucideHouse,
      lucideInbox,
      lucideLogOut,
      lucideSearch,
      lucideSettings,
      lucideUser,
    }),
  ],
})
export class ZardDemoSidebarPreviewComponent {
  readonly workspaceNames = ['Acme Inc', 'Acme Corp.', 'Evil Corp.'] as const;
  readonly workspace = signal<string>(this.workspaceNames[0]);
  readonly active = signal('Home');
  readonly tiles = [1, 2];

  readonly navItems: readonly NavItem[] = [
    { title: 'Home', icon: 'lucideHouse' },
    { title: 'Inbox', icon: 'lucideInbox' },
    { title: 'Calendar', icon: 'lucideCalendar' },
    { title: 'Search', icon: 'lucideSearch' },
    { title: 'Settings', icon: 'lucideSettings' },
  ];
}

About

Sidebars are one of the most complex components to build. They are central to any application and often contain a lot of moving parts. This is a solid foundation to build on top of — composable, themeable, customizable. Browse the Blocks Library

Installation

Copy
npx zard-cli@latest add sidebar

Usage

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';
Copy
<z-sidebar-provider>
  <z-sidebar>
    <div z-sidebar-header></div>
    <z-sidebar-content>
      <div z-sidebar-group></div>
    </z-sidebar-content>
    <div z-sidebar-footer></div>
  </z-sidebar>
  <main z-sidebar-inset>
    <button z-sidebar-trigger></button>
  </main>
</z-sidebar-provider>
Copy

Composition

Use the following composition to build a sidebar:

z-sidebar-provider
├── z-sidebar
│   ├── [z-sidebar-header]
│   ├── z-sidebar-content
│   │   ├── [z-sidebar-group]
│   │   │   ├── [z-sidebar-group-label]
│   │   │   ├── button[z-sidebar-group-action]
│   │   │   ├── [z-sidebar-group-content]
│   │   │   └── ul[z-sidebar-menu]
│   │   │       ├── li[z-sidebar-menu-item]
│   │   │       │   ├── button[z-sidebar-menu-button]
│   │   │       │   ├── button[z-sidebar-menu-action]
│   │   │       │   └── [z-sidebar-menu-badge]
│   │   │       └── li[z-sidebar-menu-item]
│   │   │           ├── button[z-sidebar-menu-button]
│   │   │           └── ul[z-sidebar-menu-sub]
│   │   │               ├── li[z-sidebar-menu-sub-item]
│   │   │               └── li[z-sidebar-menu-sub-item]
│   │   └── [z-sidebar-group]
│   │       └── ul[z-sidebar-menu]
│   │           ├── li[z-sidebar-menu-item]
│   │           └── li[z-sidebar-menu-item]
│   ├── [z-sidebar-footer]
│   └── button[z-sidebar-rail]
├── main[z-sidebar-inset]
└── button[z-sidebar-trigger]
Copy

Examples

structure

The regions a sidebar is made of. Every one of them is optional, and they can be composed in any order.
SidebarHeader
SidebarGroup
SidebarGroup
SidebarFooter
SidebarInset
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-structure',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider class="relative h-104 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none" class="border-r">
        <div z-sidebar-header class="border-b border-dashed">
          <span class="text-muted-foreground text-xs font-medium">SidebarHeader</span>
        </div>

        <z-sidebar-content>
          <div z-sidebar-group class="border-b border-dashed">
            <div z-sidebar-group-label>SidebarGroup</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>SidebarMenuItem</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>SidebarMenuItem</button>
                </li>
              </ul>
            </div>
          </div>

          <div z-sidebar-group class="border-b border-dashed">
            <div z-sidebar-group-label>SidebarGroup</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>SidebarMenuItem</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>

        <div z-sidebar-footer class="border-t border-dashed">
          <span class="text-muted-foreground text-xs font-medium">SidebarFooter</span>
        </div>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <div class="flex h-full items-center justify-center rounded-xl border border-dashed">
          <span class="text-muted-foreground text-xs font-medium">SidebarInset</span>
        </div>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarStructureComponent {}

custom width

The provider writes --sidebar-width and --sidebar-width-icon inline on its own host. Pass style to override them for a single provider, without touching the constants.
Default
16rem wide
Uses the default --sidebar-width
Wider
20rem wide
Overrides it inline, without touching the constants
import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-custom-width',
  imports: [ZardSidebarImports],
  template: `
    <div class="flex w-full flex-col gap-4">
      <z-sidebar-provider class="relative h-40 min-h-0 transform-gpu overflow-hidden rounded-xl border">
        <z-sidebar zCollapsible="none">
          <div z-sidebar-header class="font-medium">Default</div>

          <z-sidebar-content>
            <div z-sidebar-group>
              <div z-sidebar-group-label>16rem wide</div>
            </div>
          </z-sidebar-content>
        </z-sidebar>

        <main z-sidebar-inset class="p-4 text-sm">Uses the default --sidebar-width</main>
      </z-sidebar-provider>

      <z-sidebar-provider
        class="relative h-40 min-h-0 transform-gpu overflow-hidden rounded-xl border"
        style="--sidebar-width: 20rem; --sidebar-width-icon: 4rem"
      >
        <z-sidebar zCollapsible="none">
          <div z-sidebar-header class="font-medium">Wider</div>

          <z-sidebar-content>
            <div z-sidebar-group>
              <div z-sidebar-group-label>20rem wide</div>
            </div>
          </z-sidebar-content>
        </z-sidebar>

        <main z-sidebar-inset class="p-4 text-sm">Overrides it inline, without touching the constants</main>
      </z-sidebar-provider>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarCustomWidthComponent {}

The defaults

sidebar.constants.ts Copy
// The defaults the provider writes onto its own host. Override them per provider through the
// `style` input (see the custom-width example) rather than editing these — the docs site itself
// declares a global `--sidebar-width` for its navigation, and the inline values are what keep the
// two from clashing.
export const ZARD_SIDEBAR_COOKIE_NAME = 'sidebar_state';
export const ZARD_SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const ZARD_SIDEBAR_WIDTH = '16rem';
export const ZARD_SIDEBAR_WIDTH_MOBILE = '18rem';
export const ZARD_SIDEBAR_WIDTH_ICON = '3rem';
export const ZARD_SIDEBAR_KEYBOARD_SHORTCUT = 'b';
export const ZARD_SIDEBAR_MOBILE_BREAKPOINT = '(max-width: 767.98px)';

keyboard shortcut

The provider registers ⌘/Ctrl + B on the document while it is alive.

Press +B on macOS or Ctrl+B elsewhere to toggle the sidebar.

Current state: expanded

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardKbdGroupComponent } from '@/shared/components/kbd/kbd-group.component';
import { ZardKbdComponent } from '@/shared/components/kbd/kbd.component';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-keyboard-shortcut',
  imports: [ZardSidebarImports, ZardKbdComponent, ZardKbdGroupComponent],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      #provider="zSidebarProvider"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar zCollapsible="icon" class="h-full">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Navigation</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Dashboard">Dashboard</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Reports">Reports</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>

        <button z-sidebar-rail aria-label="Toggle Sidebar"></button>
      </z-sidebar>

      <main z-sidebar-inset class="flex flex-col gap-4 p-4">
        <button z-sidebar-trigger class="self-start" aria-label="Toggle Sidebar"></button>

        <p class="flex flex-wrap items-center gap-2 text-sm">
          Press
          <z-kbd-group>
            <z-kbd>⌘</z-kbd>
            <span>+</span>
            <z-kbd>B</z-kbd>
          </z-kbd-group>
          on macOS or
          <z-kbd-group>
            <z-kbd>Ctrl</z-kbd>
            <span>+</span>
            <z-kbd>B</z-kbd>
          </z-kbd-group>
          elsewhere to toggle the sidebar.
        </p>

        <p class="text-muted-foreground text-sm">
          Current state:
          <span class="text-foreground font-medium">{{ provider.sidebarService.state() }}</span>
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarKeyboardShortcutComponent {}

side right

Use zSide="right" and declare the inset before the sidebar, so the gap the sidebar reserves lands on the correct side.

The inset comes first, so the sidebar docks on the right.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-side-right',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <main z-sidebar-inset class="flex flex-col gap-4 p-4">
        <button z-sidebar-trigger class="self-start" aria-label="Toggle Sidebar"></button>
        <p class="text-muted-foreground text-sm">The inset comes first, so the sidebar docks on the right.</p>
      </main>

      <z-sidebar zSide="right" class="h-full">
        <div z-sidebar-header class="font-medium">Inspector</div>

        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Properties</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Appearance</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Layout</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Typography</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>

        <button z-sidebar-rail aria-label="Toggle Sidebar"></button>
      </z-sidebar>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarSideRightComponent {}

variant floating

Use zVariant="floating" to detach the panel from the viewport edge.

The panel is inset by 2 and gets its own border and shadow.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-variant-floating',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar zVariant="floating" zCollapsible="icon" class="h-full">
        <div z-sidebar-header class="font-medium">Floating</div>

        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Overview">Overview</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Activity">Activity</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="flex flex-col gap-4 p-4">
        <button z-sidebar-trigger class="self-start" aria-label="Toggle Sidebar"></button>
        <p class="text-muted-foreground text-sm">The panel is inset by 2 and gets its own border and shadow.</p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarVariantFloatingComponent {}

variant inset

Use zVariant="inset" together with main[z-sidebar-inset]. The inset wrapper is what paints the sidebar background behind the floating page, so the variant does nothing without it.

The wrapper paints itself with the sidebar colour and the inset floats above it.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-variant-inset',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar zVariant="inset" zCollapsible="icon" class="h-full">
        <div z-sidebar-header class="font-medium">Inset</div>

        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Projects">Projects</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Members">Members</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="flex flex-col gap-4 p-4">
        <button z-sidebar-trigger class="self-start" aria-label="Toggle Sidebar"></button>
        <p class="text-muted-foreground text-sm">
          The wrapper paints itself with the sidebar colour and the inset floats above it.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarVariantInsetComponent {}

collapsible icon

Use zCollapsible="icon" to shrink the sidebar down to its icons. Pass zTooltip on the menu buttons — the tooltip only shows while collapsed on desktop.

Collapse the sidebar and hover an icon — the label comes back as a tooltip.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideCalendar, lucideHouse, lucideInbox, lucideSettings } from '@ng-icons/lucide';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-collapsible-icon',
  imports: [ZardSidebarImports, NgIcon],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar zCollapsible="icon" class="h-full">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Platform</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                @for (item of navItems; track item.title) {
                  <li z-sidebar-menu-item>
                    <button z-sidebar-menu-button [zTooltip]="item.title">
                      <ng-icon [name]="item.icon" />
                      <span>{{ item.title }}</span>
                    </button>
                  </li>
                }
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="flex flex-col gap-4 p-4">
        <button z-sidebar-trigger class="self-start" aria-label="Toggle Sidebar"></button>
        <p class="text-muted-foreground text-sm">
          Collapse the sidebar and hover an icon — the label comes back as a tooltip.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideCalendar, lucideHouse, lucideInbox, lucideSettings })],
})
export class ZardDemoSidebarCollapsibleIconComponent {
  readonly navItems = [
    { title: 'Home', icon: 'lucideHouse' },
    { title: 'Inbox', icon: 'lucideInbox' },
    { title: 'Calendar', icon: 'lucideCalendar' },
    { title: 'Settings', icon: 'lucideSettings' },
  ];
}

collapsible offcanvas

The default. The panel slides fully out of view and the rail brings it back.

The default. The whole panel slides out of view, and the rail stays behind as a thin handle to bring it back.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-collapsible-offcanvas',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar zCollapsible="offcanvas" class="h-full">
        <div z-sidebar-header class="font-medium">Offcanvas</div>

        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Documents</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Shared with me</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Trash</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>

        <button z-sidebar-rail aria-label="Toggle Sidebar"></button>
      </z-sidebar>

      <main z-sidebar-inset class="flex flex-col gap-4 p-4">
        <button z-sidebar-trigger class="self-start" aria-label="Toggle Sidebar"></button>
        <p class="text-muted-foreground text-sm">
          The default. The whole panel slides out of view, and the rail stays behind as a thin handle to bring it back.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarCollapsibleOffcanvasComponent {}

collapsible none

Use zCollapsible="none" for a static column: no gap, no rail, no collapsing.
Always open

No trigger, no rail, no gap — the sidebar is a plain column that never collapses.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-collapsible-none',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none" class="border-r">
        <div z-sidebar-header class="font-medium">Always open</div>

        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zActive>General</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Billing</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Notifications</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          No trigger, no rail, no gap — the sidebar is a plain column that never collapses.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarCollapsibleNoneComponent {}

use sidebar

Inject ZardSidebarService from any component inside the provider — the Angular counterpart of shadcn's useSidebar() hook.
state
expanded
open
true
isMobile
false
openMobile
false
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';

import { ZardBadgeComponent } from '@/shared/components/badge/badge.component';
import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';
import { ZardSidebarService } from '@/shared/components/sidebar/sidebar.service';

/**
 * Any component rendered inside z-sidebar-provider can inject the service — this is the Angular
 * counterpart of shadcn's useSidebar() hook.
 */
@Component({
  selector: 'z-demo-sidebar-debug-panel',
  imports: [ZardButtonComponent, ZardBadgeComponent],
  template: `
    <div class="flex flex-col items-start gap-3">
      <button z-button zType="outline" zSize="sm" (click)="sidebar.toggleSidebar()">toggleSidebar()</button>

      <dl class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 text-sm">
        <dt class="text-muted-foreground">state</dt>
        <dd>
          <z-badge zType="secondary">{{ sidebar.state() }}</z-badge>
        </dd>

        <dt class="text-muted-foreground">open</dt>
        <dd>
          <z-badge zType="secondary">{{ sidebar.open() }}</z-badge>
        </dd>

        <dt class="text-muted-foreground">isMobile</dt>
        <dd>
          <z-badge zType="secondary">{{ sidebar.isMobile() }}</z-badge>
        </dd>

        <dt class="text-muted-foreground">openMobile</dt>
        <dd>
          <z-badge zType="secondary">{{ sidebar.openMobile() }}</z-badge>
        </dd>
      </dl>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarDebugPanelComponent {
  protected readonly sidebar = inject(ZardSidebarService);
}

@Component({
  selector: 'z-demo-sidebar-use-sidebar',
  imports: [ZardSidebarImports, ZardDemoSidebarDebugPanelComponent],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar zCollapsible="icon" class="h-full">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Overview">Overview</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Insights">Insights</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <z-demo-sidebar-debug-panel />
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarUseSidebarComponent {}

header

A workspace switcher in [z-sidebar-header], built with z-dropdown.
Switching updates the label above

Active workspace: Acme Inc

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown, lucideGalleryVerticalEnd } from '@ng-icons/lucide';

import { ZardDropdownImports } from '@/shared/components/dropdown/dropdown.imports';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-header',
  imports: [ZardSidebarImports, ZardDropdownImports, NgIcon],
  template: `
    <z-sidebar-provider class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none">
        <div z-sidebar-header>
          <ul z-sidebar-menu>
            <li z-sidebar-menu-item>
              <button z-sidebar-menu-button zSize="lg" z-dropdown [zDropdownMenu]="workspaces">
                <div
                  class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
                >
                  <ng-icon name="lucideGalleryVerticalEnd" class="size-4" />
                </div>

                <div class="grid flex-1 text-left text-sm/tight">
                  <span class="truncate font-medium">{{ workspace().name }}</span>
                  <span class="text-muted-foreground truncate text-xs">{{ workspace().plan }}</span>
                </div>

                <ng-icon name="lucideChevronDown" class="ml-auto" />
              </button>

              <z-dropdown-menu-content
                #workspaces="zDropdownMenuContent"
                class="w-(--z-dropdown-menu-trigger-width) min-w-56 rounded-lg"
                zAlign="start"
              >
                <z-dropdown-menu-label>Workspaces</z-dropdown-menu-label>
                @for (option of workspaces_; track option.name) {
                  <z-dropdown-menu-item (click)="workspace.set(option)">{{ option.name }}</z-dropdown-menu-item>
                }
              </z-dropdown-menu-content>
            </li>
          </ul>
        </div>

        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Switching updates the label above</div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          Active workspace:
          <span class="text-foreground font-medium">{{ workspace().name }}</span>
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideChevronDown, lucideGalleryVerticalEnd })],
})
export class ZardDemoSidebarHeaderComponent {
  readonly workspaces_ = [
    { name: 'Acme Inc', plan: 'Enterprise' },
    { name: 'Acme Corp.', plan: 'Startup' },
    { name: 'Evil Corp.', plan: 'Free' },
  ];

  readonly workspace = signal(this.workspaces_[0]);
}

footer

A user menu in [z-sidebar-footer], built with z-avatar and z-dropdown.
The user menu lives in the footer

Last action: none

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronsUpDown, lucideLogOut, lucideSettings, lucideUser } from '@ng-icons/lucide';

import { ZardAvatarComponent } from '@/shared/components/avatar/avatar.component';
import { ZardDropdownImports } from '@/shared/components/dropdown/dropdown.imports';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-footer',
  imports: [ZardSidebarImports, ZardDropdownImports, ZardAvatarComponent, NgIcon],
  template: `
    <z-sidebar-provider class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>The user menu lives in the footer</div>
          </div>
        </z-sidebar-content>

        <div z-sidebar-footer>
          <ul z-sidebar-menu>
            <li z-sidebar-menu-item>
              <button z-sidebar-menu-button zSize="lg" z-dropdown [zDropdownMenu]="account">
                <z-avatar
                  class="size-8 rounded-lg"
                  zSrc="https://github.com/zard-ui.png"
                  zAlt="zard ui"
                  zFallback="ZU"
                />

                <div class="grid flex-1 text-left text-sm/tight">
                  <span class="truncate font-medium">zard ui</span>
                  <span class="text-muted-foreground truncate text-xs">m&#64;example.com</span>
                </div>

                <ng-icon name="lucideChevronsUpDown" class="ml-auto" />
              </button>

              <z-dropdown-menu-content
                #account="zDropdownMenuContent"
                class="w-(--z-dropdown-menu-trigger-width) min-w-56 rounded-lg"
                zSide="right"
                zAlign="end"
              >
                <z-dropdown-menu-item (click)="lastAction.set('Profile')">
                  <ng-icon name="lucideUser" />
                  Profile
                </z-dropdown-menu-item>
                <z-dropdown-menu-item (click)="lastAction.set('Settings')">
                  <ng-icon name="lucideSettings" />
                  Settings
                </z-dropdown-menu-item>
                <z-dropdown-menu-separator />
                <z-dropdown-menu-item (click)="lastAction.set('Logout')">
                  <ng-icon name="lucideLogOut" />
                  Logout
                </z-dropdown-menu-item>
              </z-dropdown-menu-content>
            </li>
          </ul>
        </div>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          Last action:
          <span class="text-foreground font-medium">{{ lastAction() }}</span>
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideChevronsUpDown, lucideLogOut, lucideSettings, lucideUser })],
})
export class ZardDemoSidebarFooterComponent {
  readonly lastAction = signal('none');
}

group collapsible

Wrap [z-sidebar-group] in z-collapsible and use the group label as the trigger. The chevron rotates through group-data-[state=open]/collapsible:rotate-180.

A z-sidebar-group wrapped in z-collapsible, using the group label as the trigger.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown } from '@ng-icons/lucide';

import { ZardCollapsibleImports } from '@/shared/components/collapsible/collapsible.imports';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-group-collapsible',
  imports: [ZardSidebarImports, ZardCollapsibleImports, NgIcon],
  template: `
    <z-sidebar-provider class="relative h-80 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none">
        <z-sidebar-content>
          @for (group of groups; track group.label) {
            <z-collapsible class="group/collapsible" [zOpen]="group.defaultOpen">
              <div z-sidebar-group>
                <button z-collapsible-trigger z-sidebar-group-label class="w-full">
                  {{ group.label }}
                  <ng-icon
                    name="lucideChevronDown"
                    class="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180"
                  />
                </button>

                <z-collapsible-content>
                  <div z-sidebar-group-content>
                    <ul z-sidebar-menu>
                      @for (item of group.items; track item) {
                        <li z-sidebar-menu-item>
                          <button z-sidebar-menu-button>{{ item }}</button>
                        </li>
                      }
                    </ul>
                  </div>
                </z-collapsible-content>
              </div>
            </z-collapsible>
          }
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          A z-sidebar-group wrapped in z-collapsible, using the group label as the trigger.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideChevronDown })],
})
export class ZardDemoSidebarGroupCollapsibleComponent {
  readonly groups = [
    { label: 'Getting Started', defaultOpen: true, items: ['Installation', 'Project Structure'] },
    { label: 'Building Your Application', defaultOpen: false, items: ['Routing', 'Data Fetching', 'Rendering'] },
    { label: 'API Reference', defaultOpen: false, items: ['Components', 'File Conventions'] },
  ];
}

group action

Use button[z-sidebar-group-action] for an action pinned to the group heading.
Projects

The action sits in the top-right corner of the group and hides when the sidebar collapses to icons.

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-group-action',
  imports: [ZardSidebarImports, NgIcon],
  template: `
    <z-sidebar-provider class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Projects</div>

            <button z-sidebar-group-action title="Add Project" (click)="addProject()">
              <ng-icon name="lucidePlus" />
              <span class="sr-only">Add Project</span>
            </button>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                @for (project of projects(); track project) {
                  <li z-sidebar-menu-item>
                    <button z-sidebar-menu-button>{{ project }}</button>
                  </li>
                }
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          The action sits in the top-right corner of the group and hides when the sidebar collapses to icons.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucidePlus })],
})
export class ZardDemoSidebarGroupActionComponent {
  readonly projects = signal(['Design Engineering', 'Sales & Marketing']);

  addProject(): void {
    this.projects.update(projects => [...projects, 'Project ' + (projects.length + 1)]);
  }
}

menu action

Use button[z-sidebar-menu-action] with zShowOnHover for a per-row action.
Projects

With zShowOnHover the action only appears on hover or keyboard focus. Last action: none

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMoreHorizontal } from '@ng-icons/lucide';

import { ZardDropdownImports } from '@/shared/components/dropdown/dropdown.imports';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-menu-action',
  imports: [ZardSidebarImports, ZardDropdownImports, NgIcon],
  template: `
    <z-sidebar-provider class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Projects</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                @for (project of projects; track project) {
                  <li z-sidebar-menu-item>
                    <button z-sidebar-menu-button>{{ project }}</button>

                    <button z-sidebar-menu-action zShowOnHover z-dropdown [zDropdownMenu]="projectMenu">
                      <ng-icon name="lucideMoreHorizontal" />
                      <span class="sr-only">More</span>
                    </button>
                  </li>
                }
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <z-dropdown-menu-content #projectMenu="zDropdownMenuContent" class="w-40 rounded-lg" zSide="right" zAlign="start">
        <z-dropdown-menu-item (click)="lastAction.set('Rename')">Rename</z-dropdown-menu-item>
        <z-dropdown-menu-item (click)="lastAction.set('Duplicate')">Duplicate</z-dropdown-menu-item>
        <z-dropdown-menu-item (click)="lastAction.set('Delete')">Delete</z-dropdown-menu-item>
      </z-dropdown-menu-content>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          With zShowOnHover the action only appears on hover or keyboard focus. Last action:
          <span class="text-foreground font-medium">{{ lastAction() }}</span>
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideMoreHorizontal })],
})
export class ZardDemoSidebarMenuActionComponent {
  readonly projects = ['Design Engineering', 'Sales & Marketing', 'Travel'];
  readonly lastAction = signal('none');
}

menu sub

Use ul[z-sidebar-menu-sub] inside a collapsible menu item. Applying [z-collapsible] to the li is the idiomatic translation of shadcn's asChild.

li[z-sidebar-menu-item] doubles as the collapsible root — the idiomatic translation of shadcn's asChild.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronRight } from '@ng-icons/lucide';

import { ZardCollapsibleImports } from '@/shared/components/collapsible/collapsible.imports';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-menu-sub',
  imports: [ZardSidebarImports, ZardCollapsibleImports, NgIcon],
  template: `
    <z-sidebar-provider class="relative h-80 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Platform</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                @for (item of navItems; track item.title) {
                  <li z-sidebar-menu-item z-collapsible class="group/collapsible" [zOpen]="item.defaultOpen">
                    <button z-collapsible-trigger z-sidebar-menu-button>
                      <span>{{ item.title }}</span>
                      <ng-icon
                        name="lucideChevronRight"
                        class="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90"
                      />
                    </button>

                    <z-collapsible-content>
                      <ul z-sidebar-menu-sub>
                        @for (child of item.items; track child) {
                          <li z-sidebar-menu-sub-item>
                            <a z-sidebar-menu-sub-button href="#">{{ child }}</a>
                          </li>
                        }
                      </ul>
                    </z-collapsible-content>
                  </li>
                }
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          li[z-sidebar-menu-item] doubles as the collapsible root — the idiomatic translation of shadcn's asChild.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideChevronRight })],
})
export class ZardDemoSidebarMenuSubComponent {
  readonly navItems = [
    { title: 'Playground', defaultOpen: true, items: ['History', 'Starred', 'Settings'] },
    { title: 'Models', defaultOpen: false, items: ['Genesis', 'Explorer', 'Quantum'] },
    { title: 'Documentation', defaultOpen: false, items: ['Introduction', 'Get Started'] },
  ];
}

menu badge

Use [z-sidebar-menu-badge] for counters.
Mail
  • 24
  • 3
  • 128
  • 9

The badge is pointer-events-none and follows the button size through peer-data-[size=…]/menu-button.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-menu-badge',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Mail</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                @for (folder of folders; track folder.title) {
                  <li z-sidebar-menu-item>
                    <button z-sidebar-menu-button [zActive]="folder.title === 'Inbox'">{{ folder.title }}</button>
                    <div z-sidebar-menu-badge>{{ folder.count }}</div>
                  </li>
                }
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          The badge is pointer-events-none and follows the button size through peer-data-[size=…]/menu-button.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarMenuBadgeComponent {
  readonly folders = [
    { title: 'Inbox', count: 24 },
    { title: 'Drafts', count: 3 },
    { title: 'Sent', count: 128 },
    { title: 'Spam', count: 9 },
  ];
}

menu skeleton

Use z-sidebar-menu-skeleton while the menu is loading.
Loading projects

Each row picks its own width. Unlike shadcn, the width is derived from the element id rather than Math.random(), so the server and the client agree during hydration.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-menu-skeleton',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border">
      <z-sidebar zCollapsible="none">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-label>Loading projects</div>

            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                @for (row of rows; track row) {
                  <li z-sidebar-menu-item>
                    <z-sidebar-menu-skeleton zShowIcon />
                  </li>
                }
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          Each row picks its own width. Unlike shadcn, the width is derived from the element id rather than
          Math.random(), so the server and the client agree during hydration.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarMenuSkeletonComponent {
  readonly rows = [1, 2, 3, 4, 5];
}

custom trigger

Any element can toggle the sidebar — call toggleSidebar() on the injected service.

Any button can be a trigger — call toggleSidebar() on the injected service.

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronsLeft, lucideChevronsRight } from '@ng-icons/lucide';

import { ZardButtonComponent } from '@/shared/components/button/button.component';
import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';
import { ZardSidebarService } from '@/shared/components/sidebar/sidebar.service';

@Component({
  selector: 'z-demo-sidebar-custom-trigger-button',
  imports: [ZardButtonComponent, NgIcon],
  template: `
    <button z-button zType="outline" zSize="sm" (click)="sidebar.toggleSidebar()">
      <ng-icon [name]="sidebar.open() ? 'lucideChevronsLeft' : 'lucideChevronsRight'" />
      {{ sidebar.open() ? 'Collapse' : 'Expand' }}
    </button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideChevronsLeft, lucideChevronsRight })],
})
export class ZardDemoSidebarCustomTriggerButtonComponent {
  protected readonly sidebar = inject(ZardSidebarService);
}

@Component({
  selector: 'z-demo-sidebar-custom-trigger',
  imports: [ZardSidebarImports, ZardDemoSidebarCustomTriggerButtonComponent],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar zCollapsible="icon" class="h-full">
        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Library">Library</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button zTooltip="Downloads">Downloads</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>
      </z-sidebar>

      <main z-sidebar-inset class="flex flex-col items-start gap-4 p-4">
        <z-demo-sidebar-custom-trigger-button />

        <p class="text-muted-foreground text-sm">
          Any button can be a trigger — call toggleSidebar() on the injected service.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarCustomTriggerComponent {}

rail

Use button[z-sidebar-rail] for the draggable-looking edge handle.

The rail is the 4px strip on the sidebar's edge. It shows a resize cursor and toggles the sidebar on click — it is tabindex="-1", so it never steals keyboard focus.

import { ChangeDetectionStrategy, Component } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';

@Component({
  selector: 'z-demo-sidebar-rail',
  imports: [ZardSidebarImports],
  template: `
    <z-sidebar-provider
      zDefaultOpen="true"
      class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
    >
      <z-sidebar class="h-full">
        <div z-sidebar-header class="font-medium">Drag the edge</div>

        <z-sidebar-content>
          <div z-sidebar-group>
            <div z-sidebar-group-content>
              <ul z-sidebar-menu>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Overview</button>
                </li>
                <li z-sidebar-menu-item>
                  <button z-sidebar-menu-button>Analytics</button>
                </li>
              </ul>
            </div>
          </div>
        </z-sidebar-content>

        <button z-sidebar-rail aria-label="Toggle Sidebar"></button>
      </z-sidebar>

      <main z-sidebar-inset class="p-4">
        <p class="text-muted-foreground text-sm">
          The rail is the 4px strip on the sidebar's edge. It shows a resize cursor and toggles the sidebar on click —
          it is tabindex="-1", so it never steals keyboard focus.
        </p>
      </main>
    </z-sidebar-provider>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarRailComponent {}

controlled

Pass zOpen and listen to zOpenChange to own the state yourself.

The host owns the state: the trigger only reports through zOpenChange, and the switch stays in sync.

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { ZardSidebarImports } from '@/shared/components/sidebar/sidebar.imports';
import { ZardSwitchComponent } from '@/shared/components/switch/switch.component';

@Component({
  selector: 'z-demo-sidebar-controlled',
  imports: [ZardSidebarImports, ZardSwitchComponent],
  template: `
    <div class="flex w-full flex-col gap-4">
      <label class="flex items-center gap-2 text-sm">
        <z-switch [zChecked]="open()" (zCheckedChange)="open.set($event)" zId="sidebar-open" />
        Sidebar open
      </label>

      <z-sidebar-provider
        class="relative h-72 min-h-0 transform-gpu overflow-hidden rounded-xl border"
        [zOpen]="open()"
        (zOpenChange)="open.set($event)"
      >
        <z-sidebar zCollapsible="icon" class="h-full">
          <z-sidebar-content>
            <div z-sidebar-group>
              <div z-sidebar-group-content>
                <ul z-sidebar-menu>
                  <li z-sidebar-menu-item>
                    <button z-sidebar-menu-button zTooltip="Dashboard">Dashboard</button>
                  </li>
                  <li z-sidebar-menu-item>
                    <button z-sidebar-menu-button zTooltip="Team">Team</button>
                  </li>
                </ul>
              </div>
            </div>
          </z-sidebar-content>
        </z-sidebar>

        <main z-sidebar-inset class="flex flex-col gap-4 p-4">
          <button z-sidebar-trigger class="self-start" aria-label="Toggle Sidebar"></button>
          <p class="text-muted-foreground text-sm">
            The host owns the state: the trigger only reports through zOpenChange, and the switch stays in sync.
          </p>
        </main>
      </z-sidebar-provider>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoSidebarControlledComponent {
  readonly open = signal(true);
}

theming

The sidebar has its own colour scale so it can sit on a different background than the page it frames.
styles.css
/* The sidebar has its own colour scale, separate from the rest of the app, so it can sit on a
   different background than the page it frames. Every token is already declared by zard/ui —
   override them to theme the sidebar on its own. */
:root {
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.708 0 0);
}

.dark {
  --sidebar: oklch(0.205 0 0);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-primary: oklch(0.488 0.243 264.376);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.269 0 0);
  --sidebar-accent-foreground: oklch(0.985 0 0);
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.439 0 0);
}

/* The tokens are mapped in `@theme inline`, which is what turns them into the `bg-sidebar`,
   `text-sidebar-foreground`, `border-sidebar-border` and `ring-sidebar-ring` utilities. */
@theme inline {
  --color-sidebar: var(--sidebar);
  --color-sidebar-foreground: var(--sidebar-foreground);
  --color-sidebar-primary: var(--sidebar-primary);
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
  --color-sidebar-accent: var(--sidebar-accent);
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
  --color-sidebar-border: var(--sidebar-border);
  --color-sidebar-ring: var(--sidebar-ring);
}
Copy

styling

The sidebar publishes its state through data attributes, so anything inside it can react with plain Tailwind variants.
Styling by state
<!-- The sidebar publishes its state through data attributes, so anything inside it can react with
     plain Tailwind variants — no extra bindings needed. -->

<!-- 1. Hide an element once the sidebar has collapsed to icons.
        `group` lives on z-sidebar, together with data-collapsible. -->
<div z-sidebar-group class="group-data-[collapsible=icon]:hidden">
  <div z-sidebar-group-label>Projects</div>
</div>

<!-- 2. Style a sibling from the active state of its menu button.
        `peer/menu-button` lives on z-sidebar-menu-button, together with data-active. -->
<li z-sidebar-menu-item>
  <button z-sidebar-menu-button zActive>Inbox</button>
  <div z-sidebar-menu-badge class="opacity-50 peer-data-[active=true]/menu-button:opacity-100">24</div>
</li>
Copy

ssr cookie

New in the Angular port: the open state is persisted in the sidebar_state cookie and read back on the server, so there is no layout flash on hydration.
Reading sidebar_state on the server
// New in the Angular port. ZardSidebarService persists the open state in the `sidebar_state`
// cookie and — this is the part shadcn has no equivalent for — reads it back on the server from
// the incoming request, so the first painted frame already has the right layout and there is no
// flash on hydration. This happens automatically; the code below is what runs inside the service.
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, PLATFORM_ID, REQUEST } from '@angular/core';

const document = inject(DOCUMENT);
const request = inject(REQUEST, { optional: true });
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

// On the client the cookie comes from `document.cookie`; on the server, from the Cookie header.
const cookies = isBrowser ? document.cookie : request?.headers?.get('cookie');
const match = /(?:^|;\s*)sidebar_state=(true|false)/.exec(cookies ?? '');
const persistedOpen = match ? match[1] === 'true' : undefined;

// `undefined` means "nothing persisted yet", so the provider falls back to open.
// An explicit zDefaultOpen wins over this either way — shadcn feeds the cookie in through it.
Copy

API Reference

z-sidebar-providerComponent

Wraps the sidebar and the page, provides ZardSidebarService and registers the keyboard shortcut.

PropertyDescriptionTypeDefault
[zDefaultOpen] Initial open state. Left unset, the persisted sidebar_state cookie decides, falling back to true; set explicitly, it wins over the cookie boolean | undefined undefined
[zOpen] When set, the provider is controlled: the state is owned by the consumer boolean | undefined undefined
[style] Extra inline style, applied after --sidebar-width and --sidebar-width-icon so it can override them string ''
[class] Additional CSS classes ClassValue ''
(zOpenChange) Emits whenever the open state is requested, including in controlled mode boolean

z-sidebarComponent

The sidebar itself. Renders as a fixed panel on desktop, as a drawer on mobile.

PropertyDescriptionTypeDefault
[zSide] Which edge the sidebar docks to 'left' | 'right' 'left'
[zVariant] Visual treatment of the panel 'sidebar' | 'floating' | 'inset' 'sidebar'
[zCollapsible] How the sidebar collapses. "none" renders a plain, always-visible column 'offcanvas' | 'icon' | 'none' 'offcanvas'
[dir] Writing direction. Mirrors the rail and the trigger icon when set to rtl 'ltr' | 'rtl' | undefined undefined
[class] Additional CSS classes ClassValue ''

button[z-sidebar-trigger]Component

Ghost icon button that toggles the sidebar. Renders the panel icon and an sr-only label.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

button[z-sidebar-rail]Component

The thin strip on the sidebar edge. Toggles the sidebar, shows a resize cursor and stays out of the tab order.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

z-sidebar-inset, main[z-sidebar-inset]Component

The page area next to the sidebar. Required when zVariant is "inset".

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

input[z-sidebar-input]Component

Adds the sidebar treatment to a Zard input. Use as <input z-input z-sidebar-input />.

PropertyDescriptionTypeDefault

z-sidebar-header, [z-sidebar-header]Component

Sticky region at the top of the sidebar.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

z-sidebar-footer, [z-sidebar-footer]Component

Sticky region at the bottom of the sidebar.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

z-sidebar-separatorComponent

A separator inset to the sidebar padding, painted with --sidebar-border.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

z-sidebar-content, [z-sidebar-content]Component

Scrollable area between the header and the footer.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

z-sidebar-group, [z-sidebar-group]Component

A section inside the content. Wrap it in z-collapsible to make it collapsible.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

z-sidebar-group-label, [z-sidebar-group-label]Component

The group heading. Fades out when the sidebar collapses to icons.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

button[z-sidebar-group-action]Component

Action button pinned to the top-right corner of a group.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

z-sidebar-group-content, [z-sidebar-group-content]Component

Content wrapper inside a group.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

ul[z-sidebar-menu]Component

The list that holds the menu items.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

li[z-sidebar-menu-item]Component

A single menu row. Carries group/menu-item, which the action and badge react to.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

button[z-sidebar-menu-button], a[z-sidebar-menu-button]Component

The clickable menu row. Use the anchor form with routerLink instead of shadcn's asChild. Carries peer/menu-button.

PropertyDescriptionTypeDefault
[zType] Visual treatment 'default' | 'outline' 'default'
[zSize] Row height 'default' | 'sm' | 'lg' 'default'
[zActive] Marks the row as the current one boolean false
[zTooltip] Label shown as a tooltip, but only while the sidebar is collapsed on desktop. The object form overrides that rule: `{ content, hidden: false }` keeps the tooltip on an expanded sidebar string | TemplateRef<void> | { content: string | TemplateRef<void>; hidden?: boolean } | null null
[class] Additional CSS classes ClassValue ''

button[z-sidebar-menu-action], a[z-sidebar-menu-action]Component

Secondary action pinned to the right of a menu row.

PropertyDescriptionTypeDefault
[zShowOnHover] Reveal the action only on hover or keyboard focus boolean false
[class] Additional CSS classes ClassValue ''

z-sidebar-menu-badge, [z-sidebar-menu-badge]Component

A counter pinned to the right of a menu row. Not interactive.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

z-sidebar-menu-skeletonComponent

Placeholder row. The text width is derived from the element id rather than Math.random(), so the server and the client agree during hydration.

PropertyDescriptionTypeDefault
[zShowIcon] Also render a square icon placeholder boolean false
[class] Additional CSS classes ClassValue ''

ul[z-sidebar-menu-sub]Component

Nested list under a menu item. Hidden when the sidebar collapses to icons.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

li[z-sidebar-menu-sub-item]Component

A row inside a submenu.

PropertyDescriptionTypeDefault
[class] Additional CSS classes ClassValue ''

a[z-sidebar-menu-sub-button], button[z-sidebar-menu-sub-button]Component

The clickable row inside a submenu.

PropertyDescriptionTypeDefault
[zSize] Row text size 'sm' | 'md' 'md'
[zActive] Marks the row as the current one boolean false
[class] Additional CSS classes ClassValue ''

ZardSidebarServiceComponent

Injectable service that controls the sidebar. Provided by z-sidebar-provider.

PropertyDescriptionTypeDefault
state Current state of the sidebar Signal<'expanded' | 'collapsed'>
open Whether the sidebar is open Signal<boolean>
setOpen Sets the open state of the sidebar (open: boolean | ((open: boolean) => boolean)) => void
openMobile Whether the sidebar is open on mobile Signal<boolean>
setOpenMobile Sets the open state on mobile (open: boolean) => void
isMobile Whether the viewport is mobile Signal<boolean>
toggleSidebar Toggles the sidebar on desktop and mobile () => void
github iconwhatsapp icondiscord iconX icon

Made with in Brazil. Open source and available on GitHub .