Dual-Rail Sidebar
blocklayoutDual-rail sidebar -- a thin always-visible icon column on the far left holds quick shortcuts (a subset of the full nav), paired with a wider main panel for the brand, search, sections, and footer. Uses collapsible="icon": when the user collapses, only the rail stays. On mobile the kit Sheet shows the full dual rail. Requires the parent SidebarProvider to set both widths: style="--sidebar-width: 19.5rem; --sidebar-width-icon: 3.5rem". Modeled on the GR8R HRMS admin shell.
Installation
$pnpm dlx uipkge-ng@latest add sidebar-05$npx uipkge-ng@latest add sidebar-05$yarn dlx uipkge-ng@latest add sidebar-05$bunx uipkge-ng@latest add sidebar-05Manifest:
https://uipkge.dev/r/angular/sidebar-05.jsonInstalls to:src/app/components/blocks/sidebar-05/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
RailIteminterface RailItem {
id: RailId
icon: LucideIconData
label: string
}npm dependencies
Files installed (2)
src/app/components/blocks/sidebar-05/sidebar-05.component.ts29.4 kB
// Dual-rail sidebar with master/detail behavior. The thin rail picks the section; the // main panel renders the rows for the active section. One <ui-sidebar collapsible="icon"> // contains rail + panel as inline flex siblings. // // Desktop: when collapsed (trigger clicked), the kit shrinks the sidebar to // --sidebar-width-icon. Set that to 3.5rem so only the rail stays. The panel is hidden // via group-data-[collapsible=icon] which only fires on the desktop branch of <ui-sidebar> // (the mobile sheet has no `group` ancestor with that attr, so mobile always shows rail+panel). // // IMPORTANT: the parent <ui-sidebar-provider> must set BOTH custom widths, e.g. // <ui-sidebar-provider style="--sidebar-width: 19.5rem; --sidebar-width-icon: 3.5rem"> // The vars set on <ui-sidebar> itself are too deep -- the layout-allocating wrapper sits // above it and reads the provider's vars. // // To wire to your router, replace the signal-based switch with route matches // (e.g. set active = 'projects' on /projects/*). // // Port of the React block 1:1. import { AfterViewInit, ChangeDetectionStrategy, Component, signal } from '@angular/core' import { Bell, Briefcase, Calendar, ChartLine, ChevronsUpDown, ClipboardList, CreditCard, FileText, Folder, Gauge, Headphones, HelpCircle, Home, Inbox, LayoutDashboard, Lock, LucideAngularModule, Mail, MessageSquare, Moon, Search, Send, Settings as SettingsIcon, Star, Sun, Users, type LucideIconData, } from 'lucide-angular' import { injectTheme } from '@/lib/use-theme' import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component' import { UiDropdownMenuComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuLabelComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuTriggerComponent, } from '@/ui/dropdown-menu/dropdown-menu.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSidebarComponent, UiSidebarContentComponent, UiSidebarGroupComponent, UiSidebarGroupContentComponent, UiSidebarGroupLabelComponent, UiSidebarHeaderComponent, UiSidebarMenuButtonComponent, UiSidebarMenuComponent, UiSidebarMenuItemComponent, injectSidebar, } from '@/ui/sidebar/sidebar.component' type RailId = 'home' | 'projects' | 'inbox' | 'messages' | 'support' | 'settings' interface RailItem { id: RailId icon: LucideIconData label: string } const rail: RailItem[] = [ { id: 'home', icon: Home, label: 'Home' }, { id: 'projects', icon: Folder, label: 'Projects' }, { id: 'inbox', icon: Bell, label: 'Inbox' }, { id: 'messages', icon: MessageSquare, label: 'Messages' }, ] const railLower: RailItem[] = [ { id: 'support', icon: Headphones, label: 'Support' }, { id: 'settings', icon: SettingsIcon, label: 'Settings' }, ] const titles: Record<RailId, string> = { home: 'Workspace', projects: 'Projects', inbox: 'Inbox', messages: 'Messages', support: 'Support', settings: 'Settings', } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-sidebar-05, [ui-sidebar-05]', standalone: true, // React renders <Sidebar> itself: keep this wrapper out of the provider's flex layout. host: { class: 'contents' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiDropdownMenuComponent, UiDropdownMenuTriggerComponent, UiDropdownMenuContentComponent, UiDropdownMenuLabelComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuItemComponent, UiInputComponent, UiSidebarComponent, UiSidebarHeaderComponent, UiSidebarContentComponent, UiSidebarGroupComponent, UiSidebarGroupLabelComponent, UiSidebarGroupContentComponent, UiSidebarMenuComponent, UiSidebarMenuItemComponent, UiSidebarMenuButtonComponent, ], template: ` <!-- Single Sidebar primitive containing BOTH rails inline. On desktop the rail (56px) stays visible when collapsed thanks to collapsible="icon" + --sidebar-width-icon: 3.5rem; the panel hides only when state=collapsed. On mobile the kit sheet renders the entire dual rail inside a single sheet pane. --> <ui-sidebar collapsible="icon"> <div class="flex h-full w-full"> <!-- Thin icon rail -- 56px wide. Clicking an icon updates "active" which swaps the main panel content below. --> <aside class="border-sidebar-border flex w-14 shrink-0 flex-col items-center gap-0.5 border-r py-2"> <button type="button" aria-label="uipkge" class="bg-sidebar-primary text-sidebar-primary-foreground mb-1 flex aspect-square size-9 items-center justify-center rounded-lg shadow-sm ring-1 ring-white/10 transition-transform ring-inset hover:scale-105" > <svg viewBox="0 0 24 24" class="size-4" fill="currentColor" aria-hidden="true"> <rect x="2" y="2" width="9" height="9" rx="1.5" /> <rect x="13" y="2" width="9" height="9" rx="1.5" opacity="0.55" /> <rect x="2" y="13" width="9" height="9" rx="1.5" opacity="0.55" /> <rect x="13" y="13" width="9" height="9" rx="1.5" /> </svg> </button> @for (item of rail; track item.id) { <button type="button" [attr.aria-label]="item.label" [attr.aria-current]="active() === item.id ? 'page' : null" [class]="railButtonClass(item.id)" (click)="selectRail(item.id)" > @if (active() === item.id) { <span class="bg-sidebar-primary absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-r-full"></span> } <lucide-icon [img]="item.icon" class="size-4" /> </button> } <div class="bg-sidebar-border/70 my-2 h-px w-7"></div> @for (item of railLower; track item.id) { <button type="button" [attr.aria-label]="item.label" [attr.aria-current]="active() === item.id ? 'page' : null" [class]="railButtonClass(item.id)" (click)="selectRail(item.id)" > @if (active() === item.id) { <span class="bg-sidebar-primary absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-r-full"></span> } <lucide-icon [img]="item.icon" class="size-4" /> </button> } <div class="mt-auto flex flex-col items-center gap-1.5"> <button type="button" [attr.aria-label]="isDark ? 'Switch to light theme' : 'Switch to dark theme'" class="text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground flex aspect-square size-9 items-center justify-center rounded-lg transition-colors" (click)="toggleTheme()" > @if (isDark) { <lucide-icon [img]="Sun" class="size-4" /> } @else { <lucide-icon [img]="Moon" class="size-4" /> } </button> <ui-dropdown-menu> <button type="button" ui-dropdown-menu-trigger aria-label="James Carter" class="ring-offset-sidebar focus-visible:ring-ring data-[state=open]:ring-ring rounded-full transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none data-[state=open]:ring-2" > <ui-avatar class="size-8"> <ui-avatar-fallback class="bg-sidebar-accent text-sidebar-accent-foreground text-xs" >JC</ui-avatar-fallback > </ui-avatar> </button> <ui-dropdown-menu-content class="min-w-56 rounded-lg" [side]="sidebar.isMobile ? 'top' : 'right'" align="end" [sideOffset]="8" > <ui-dropdown-menu-label class="p-0 font-normal"> <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <ui-avatar class="size-8 rounded-lg"> <ui-avatar-fallback class="bg-sidebar-accent text-sidebar-accent-foreground rounded-lg text-xs" >JC</ui-avatar-fallback > </ui-avatar> <div class="grid flex-1 leading-tight"> <span class="truncate font-medium">James Carter</span> <span class="text-muted-foreground truncate text-xs">Admin</span> </div> </div> </ui-dropdown-menu-label> <ui-dropdown-menu-separator /> <ui-dropdown-menu-item> <lucide-icon [img]="Send" class="size-4" /> Invite teammates </ui-dropdown-menu-item> <ui-dropdown-menu-item> <lucide-icon [img]="SettingsIcon" class="size-4" /> Workspace settings </ui-dropdown-menu-item> </ui-dropdown-menu-content> </ui-dropdown-menu> </div> </aside> <!-- Main panel -- header is the same for every section; the content area swaps based on "active". Each section's rows are spelled out inline so consumers can edit them in place. Hidden on desktop when state=collapsed (rail stays). --> <div class="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden"> <ui-sidebar-header> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button size="lg"> <div class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg" > <svg viewBox="0 0 24 24" class="size-4" fill="currentColor" aria-hidden="true"> <rect x="2" y="2" width="9" height="9" rx="1.5" /> <rect x="13" y="2" width="9" height="9" rx="1.5" opacity="0.55" /> <rect x="2" y="13" width="9" height="9" rx="1.5" opacity="0.55" /> <rect x="13" y="13" width="9" height="9" rx="1.5" /> </svg> </div> <div class="grid flex-1 text-left text-sm leading-tight"> <span class="font-display truncate font-bold">uipkge</span> <span class="text-muted-foreground truncate text-xs">{{ titles[active()] }}</span> </div> <lucide-icon [img]="ChevronsUpDown" class="ml-auto size-4" /> </button> </li> </ul> <div class="px-1 pt-1"> <ui-input type="text" placeholder="Search..." allowClear [prefix]="searchPrefix" [suffix]="searchSuffix" class="bg-background h-8 w-full shadow-none" /> <ng-template #searchPrefix> <lucide-icon [img]="Search" class="text-muted-foreground size-3.5" /> </ng-template> <ng-template #searchSuffix> <kbd class="bg-muted text-muted-foreground pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border px-1 font-mono text-xs font-medium select-none" > <span>⌘</span>F </kbd> </ng-template> </div> </ui-sidebar-header> <ui-sidebar-content> <!-- HOME: full workspace nav --> @if (active() === 'home') { <ui-sidebar-group> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button isActive> <lucide-icon [img]="LayoutDashboard" class="size-4" /> <span>Dashboard</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Users" class="size-4" /> <span>Employees</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Briefcase" class="size-4" /> <span>Clients</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Folder" class="size-4" /> <span>Projects</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> <ui-sidebar-group> <ui-sidebar-group-label>Workforce</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Calendar" class="size-4" /> <span>Calendar</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="ClipboardList" class="size-4" /> <span>Attendance</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Mail" class="size-4" /> <span>Mail</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="ChartLine" class="size-4" /> <span>Analytics</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> } @else if (active() === 'projects') { <!-- PROJECTS: pinned and recent projects --> <ui-sidebar-group> <ui-sidebar-group-label>Pinned</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <div class="bg-chart-1 flex aspect-square size-5 shrink-0 items-center justify-center rounded-md text-white" > <lucide-icon [img]="Folder" class="size-3" /> </div> <span>Shift Planner</span> <lucide-icon [img]="Star" class="text-warning ml-auto size-3.5 fill-current" /> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <div class="bg-chart-2 flex aspect-square size-5 shrink-0 items-center justify-center rounded-md text-white" > <lucide-icon [img]="Folder" class="size-3" /> </div> <span>Training Portal</span> <lucide-icon [img]="Star" class="text-warning ml-auto size-3.5 fill-current" /> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <div class="bg-chart-3 flex aspect-square size-5 shrink-0 items-center justify-center rounded-md text-white" > <lucide-icon [img]="Folder" class="size-3" /> </div> <span>Performance Hub</span> <lucide-icon [img]="Star" class="text-warning ml-auto size-3.5 fill-current" /> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <div class="bg-chart-4 flex aspect-square size-5 shrink-0 items-center justify-center rounded-md text-white" > <lucide-icon [img]="Folder" class="size-3" /> </div> <span>Expense Claims</span> <lucide-icon [img]="Star" class="text-warning ml-auto size-3.5 fill-current" /> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> <ui-sidebar-group> <ui-sidebar-group-label>Recent</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Folder" class="size-4" /> <span>Onboarding 2026</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Folder" class="size-4" /> <span>Q1 hiring plan</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Folder" class="size-4" /> <span>Compliance refresh</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> } @else if (active() === 'inbox') { <!-- INBOX: notifications list --> <ui-sidebar-group> <ui-sidebar-group-label>Today</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Inbox" class="size-4" /> <span>3 PTO requests pending</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Bell" class="size-4" /> <span>Payroll cycle closes today</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> <ui-sidebar-group> <ui-sidebar-group-label>This week</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="FileText" class="size-4" /> <span>5 contracts awaiting sign-off</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Users" class="size-4" /> <span>2 new hires start Monday</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> } @else if (active() === 'messages') { <!-- MESSAGES: conversations --> <ui-sidebar-group> <ui-sidebar-group-label>Direct messages</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <ui-avatar class="size-5"> <ui-avatar-fallback class="bg-chart-1/15 text-foreground text-xs">OB</ui-avatar-fallback> </ui-avatar> <span>Olivia Bennett</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <ui-avatar class="size-5"> <ui-avatar-fallback class="bg-chart-2/15 text-foreground text-xs">DM</ui-avatar-fallback> </ui-avatar> <span>Daniel Morgan</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <ui-avatar class="size-5"> <ui-avatar-fallback class="bg-chart-3/15 text-foreground text-xs">ER</ui-avatar-fallback> </ui-avatar> <span>Ethan Reynolds</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> <ui-sidebar-group> <ui-sidebar-group-label>Channels</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="MessageSquare" class="size-4" /> <span># announcements</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="MessageSquare" class="size-4" /> <span># hiring</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> } @else if (active() === 'support') { <!-- SUPPORT: help links --> <ui-sidebar-group> <ui-sidebar-group-label>Help center</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="HelpCircle" class="size-4" /> <span>Getting started</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="FileText" class="size-4" /> <span>Onboarding guide</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Send" class="size-4" /> <span>Contact support</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> } @else if (active() === 'settings') { <!-- SETTINGS: workspace settings --> <ui-sidebar-group> <ui-sidebar-group-label>Workspace</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="SettingsIcon" class="size-4" /> <span>General</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Users" class="size-4" /> <span>Members</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="CreditCard" class="size-4" /> <span>Billing</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Gauge" class="size-4" /> <span>Limits</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> <ui-sidebar-group> <ui-sidebar-group-label>Account</ui-sidebar-group-label> <ui-sidebar-group-content> <ul ui-sidebar-menu> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Lock" class="size-4" /> <span>Security</span> </button> </li> <li ui-sidebar-menu-item> <button type="button" ui-sidebar-menu-button> <lucide-icon [img]="Bell" class="size-4" /> <span>Notifications</span> </button> </li> </ul> </ui-sidebar-group-content> </ui-sidebar-group> } </ui-sidebar-content> </div> </div> </ui-sidebar> `, }) export class UiSidebar05Component implements AfterViewInit { readonly sidebar = injectSidebar() readonly theme = injectTheme() readonly rail = rail readonly railLower = railLower readonly titles = titles readonly active = signal<RailId>('home') // `mounted` stays false during SSR + the client's first synchronous render, so the // template's isDark is also false on both sides -- no hydration mismatch on the // Sun/Moon icon. ngAfterViewInit only runs after a real browser render (never in plain // instantiation), like Vue's onMounted / React's useEffect. private readonly mounted = signal(false) ngAfterViewInit(): void { this.mounted.set(true) } protected readonly Search = Search protected readonly Sun = Sun protected readonly Moon = Moon protected readonly Send = Send protected readonly SettingsIcon = SettingsIcon protected readonly ChevronsUpDown = ChevronsUpDown protected readonly LayoutDashboard = LayoutDashboard protected readonly Users = Users protected readonly Briefcase = Briefcase protected readonly Folder = Folder protected readonly Calendar = Calendar protected readonly ClipboardList = ClipboardList protected readonly Mail = Mail protected readonly ChartLine = ChartLine protected readonly Inbox = Inbox protected readonly Bell = Bell protected readonly FileText = FileText protected readonly MessageSquare = MessageSquare protected readonly HelpCircle = HelpCircle protected readonly CreditCard = CreditCard protected readonly Gauge = Gauge protected readonly Lock = Lock protected readonly Star = Star get isDark(): boolean { // Template icon: stays Moon until the browser render settles (mounted guard). if (!this.mounted()) return false return this.resolvedDark() } private resolvedDark(): boolean { const t = this.theme.theme() if (t === 'dark') return true if (t === 'light') return false return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches } railButtonClass(id: RailId): string { const base = 'relative flex aspect-square size-9 items-center justify-center rounded-lg transition-colors' return this.active() === id ? `${base} bg-sidebar-accent text-foreground` : `${base} text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground` } // Slack-style rail click: expand the panel if collapsed, or collapse if the user clicks // the already-active icon while expanded. On mobile the sheet open/close is owned by the // inset trigger, so just swap active. selectRail(id: RailId): void { if (this.sidebar.isMobile) { this.active.set(id) return } if (this.sidebar.state === 'expanded' && this.active() === id) { this.sidebar.setOpen(false) return } this.active.set(id) this.sidebar.setOpen(true) } toggleTheme(): void { this.theme.setTheme(this.resolvedDark() ? 'light' : 'dark') } }src/app/components/blocks/sidebar-05/index.ts0.1 kB
export { UiSidebar05Component } from './sidebar-05.component'
Raw manifest:https://uipkge.dev/r/angular/sidebar-05.json