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 shadcn@latest add https://uipkge.dev/r/lit/sidebar-05.json$npx shadcn@latest add https://uipkge.dev/r/lit/sidebar-05.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/sidebar-05.json$bunx shadcn@latest add https://uipkge.dev/r/lit/sidebar-05.jsonManifest:
https://uipkge.dev/r/lit/sidebar-05.jsonInstalls to:src/uip/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.
HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-sidebar-05': UipSidebar05
}
}Files installed (1)
src/uip/blocks/sidebar-05/sidebar-05.ts25.8 kB
import { LitElement, css, html, nothing } from 'lit' import { Bell, Briefcase, Calendar, ChartLine, ChevronsUpDown, ClipboardList, CreditCard, FileText, Folder, Gauge, Headphones, HelpCircle, Home, Inbox, LayoutDashboard, Lock, Mail, MessageSquare, Moon, Search, Send, Settings as SettingsIcon, Star, Sun, Users, } from 'lucide' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/avatar/avatar' import '../../components/dropdown-menu/dropdown-menu' import '../../components/input/input' import type { UipInput } from '../../components/input/input' import '../../components/sidebar/sidebar' import type { UipSidebarProvider } from '../../components/sidebar/sidebar' type RailId = 'home' | 'projects' | 'inbox' | 'messages' | 'support' | 'settings' const rail = [ { id: 'home' as const, icon: Home, iconName: 'house', label: 'Home' }, { id: 'projects' as const, icon: Folder, iconName: 'folder', label: 'Projects' }, { id: 'inbox' as const, icon: Bell, iconName: 'bell', label: 'Inbox' }, { id: 'messages' as const, icon: MessageSquare, iconName: 'message-square', label: 'Messages' }, ] const railLower = [ { id: 'support' as const, icon: Headphones, iconName: 'headphones', label: 'Support' }, { id: 'settings' as const, icon: SettingsIcon, iconName: 'settings', label: 'Settings' }, ] const titles: Record<RailId, string> = { home: 'Workspace', projects: 'Projects', inbox: 'Inbox', messages: 'Messages', support: 'Support', settings: 'Settings', } function isDarkActive(): boolean { return document.documentElement.classList.contains('dark') } function setDark(dark: boolean) { document.documentElement.classList.toggle('dark', dark) try { localStorage.setItem('uipkge-theme', dark ? 'dark' : 'light') } catch { // ignore } } function brandMark(classes: string) { return html`<svg viewBox="0 0 24 24" class=${classes} 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>` } // Dual-rail sidebar with master/detail behavior. The thin rail picks the // section; the main panel renders the rows for the active section. One // <uip-sidebar collapsible="icon"> contains rail + panel as inline flex siblings. // // Desktop: when collapsed (toggle clicked), the kit shrinks the sidebar to // --sidebar-width-icon. The panel hides via group-data-[collapsible=icon]; // the mobile sheet always shows rail+panel. // // IMPORTANT: the parent <uip-sidebar-provider> must set BOTH custom widths, // e.g. style="--sidebar-width: 19.5rem; --sidebar-width-icon: 3.5rem". // To wire to your router, replace the state-based switch with route matches // (e.g. set active = 'projects' on /projects/*). /** * <uip-sidebar-05> — the registry Sidebar05 block as a web component. * * Class strings are React's verbatim. Rail clicks use the surrounding * <uip-sidebar-provider> (state / setOpen / isMobile — React's useSidebar); * the block re-renders when the provider's `open-change` fires. The search * field is <uip-input> with `prefix`/`suffix` slots (React's SidebarInput * prefix/suffix props). The theme toggle writes the `dark` class on * documentElement directly (the `next-themes` dependency is not ported). * No props, no events: edit the markup in place. */ export class UipSidebar05 extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { active: { state: true }, isDark: { state: true }, query: { state: true }, } private active: RailId = 'home' private isDark = false private query = '' private media?: MediaQueryList constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'sidebar-05') this.isDark = isDarkActive() // Re-render when the provider opens/closes or the viewport crosses the // mobile breakpoint (React's useSidebar re-render). this.addEventListener('open-change', this.rerender) this.media = window.matchMedia('(max-width: 768px)') this.media.addEventListener('change', this.rerender) } disconnectedCallback() { super.disconnectedCallback() this.removeEventListener('open-change', this.rerender) this.media?.removeEventListener('change', this.rerender) } private rerender = () => this.requestUpdate() private get provider(): UipSidebarProvider | null { const p = this.closest('uip-sidebar-provider') return (p as UipSidebarProvider | null) ?? null } // 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 we just swap active. private selectRail(id: RailId) { const provider = this.provider if (provider?.isMobile) { this.active = id return } if (provider && provider.state === 'expanded' && this.active === id) { provider.setOpen(false) return } this.active = id provider?.setOpen(true) } private railButton(item: { id: RailId; icon: typeof Home; iconName: string; label: string }) { return html` <button type="button" aria-label=${item.label} aria-current=${this.active === item.id ? 'page' : nothing} class=${[ 'relative flex aspect-square size-9 items-center justify-center rounded-lg transition-colors', this.active === item.id ? 'bg-sidebar-accent text-foreground' : 'text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground', ].join(' ')} @click=${() => this.selectRail(item.id)} > ${this.active === item.id ? html`<span class="bg-sidebar-primary absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-r-full"></span>` : ''} ${icon(item.icon, item.iconName, 'size-4')} </button> ` } render() { const isMobile = this.provider?.isMobile ?? false return html` <uip-sidebar collapsible="icon"> <div class="flex h-full w-full"> <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" > ${brandMark('size-4')} </button> ${rail.map((item) => this.railButton(item))} <div class="bg-sidebar-border/70 my-2 h-px w-7"></div> ${railLower.map((item) => this.railButton(item))} <div class="mt-auto flex flex-col items-center gap-1.5"> <button type="button" aria-label=${this.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=${() => { this.isDark = !this.isDark setDark(this.isDark) }} > ${this.isDark ? icon(Sun, 'sun', 'size-4') : icon(Moon, 'moon', 'size-4')} </button> <uip-dropdown-menu class="[&::part(content)]:min-w-56 [&::part(content)]:rounded-lg"> <button slot="trigger" type="button" 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" > <uip-avatar class="size-8"> <uip-avatar-fallback class="bg-sidebar-accent text-sidebar-accent-foreground text-xs">JC</uip-avatar-fallback> </uip-avatar> </button> <uip-dropdown-menu-content side=${isMobile ? 'top' : 'right'} align="end" side-offset="8"> <uip-dropdown-menu-label> <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <uip-avatar class="size-8 rounded-lg"> <uip-avatar-fallback class="bg-sidebar-accent text-sidebar-accent-foreground rounded-lg text-xs">JC</uip-avatar-fallback> </uip-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> </uip-dropdown-menu-label> <uip-dropdown-menu-separator></uip-dropdown-menu-separator> <uip-dropdown-menu-item>${icon(Send, 'send', 'size-4')} Invite teammates</uip-dropdown-menu-item> <uip-dropdown-menu-item>${icon(SettingsIcon, 'settings', 'size-4')} Workspace settings</uip-dropdown-menu-item> </uip-dropdown-menu-content> </uip-dropdown-menu> </div> </aside> <div class="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden"> <uip-sidebar-header> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-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"> ${brandMark('size-4')} </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[this.active]}</span> </div> ${icon(ChevronsUpDown, 'chevrons-up-down', 'ml-auto size-4')} </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> <div class="px-1 pt-1"> <uip-input type="text" placeholder="Search..." allow-clear class="[&::part(control)]:bg-background [&::part(control)]:h-8 [&::part(control)]:shadow-none" .value=${this.query} @input=${(e: Event) => { this.query = (e.target as UipInput).value }} > <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span> <kbd slot="suffix" 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> </uip-input> </div> </uip-sidebar-header> <uip-sidebar-content> ${this.active === 'home' ? html` <uip-sidebar-group> <uip-sidebar-group-content> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-sidebar-menu-button is-active> ${icon(LayoutDashboard, 'layout-dashboard', 'size-4')} <span>Dashboard</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Users, 'users', 'size-4')} <span>Employees</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Briefcase, 'briefcase', 'size-4')} <span>Clients</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Folder, 'folder', 'size-4')} <span>Projects</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> <uip-sidebar-group> <uip-sidebar-group-label>Workforce</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Calendar, 'calendar', 'size-4')} <span>Calendar</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(ClipboardList, 'clipboard-list', 'size-4')} <span>Attendance</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Mail, 'mail', 'size-4')} <span>Mail</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(ChartLine, 'chart-line', 'size-4')} <span>Analytics</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> ` : ''} ${this.active === 'projects' ? html` <uip-sidebar-group> <uip-sidebar-group-label>Pinned</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> ${[ { tile: 'bg-chart-1', name: 'Shift Planner' }, { tile: 'bg-chart-2', name: 'Training Portal' }, { tile: 'bg-chart-3', name: 'Performance Hub' }, { tile: 'bg-chart-4', name: 'Expense Claims' }, ].map( (p) => html` <uip-sidebar-menu-item> <uip-sidebar-menu-button> <div class=${`${p.tile} flex aspect-square size-5 shrink-0 items-center justify-center rounded-md text-white`}> ${icon(Folder, 'folder', 'size-3')} </div> <span>${p.name}</span> ${icon(Star, 'star', 'text-warning ml-auto size-3.5 fill-current')} </uip-sidebar-menu-button> </uip-sidebar-menu-item> `, )} </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> <uip-sidebar-group> <uip-sidebar-group-label>Recent</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> ${['Onboarding 2026', 'Q1 hiring plan', 'Compliance refresh'].map( (name) => html` <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Folder, 'folder', 'size-4')} <span>${name}</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> `, )} </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> ` : ''} ${this.active === 'inbox' ? html` <uip-sidebar-group> <uip-sidebar-group-label>Today</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Inbox, 'inbox', 'size-4')} <span>3 PTO requests pending</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Bell, 'bell', 'size-4')} <span>Payroll cycle closes today</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> <uip-sidebar-group> <uip-sidebar-group-label>This week</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(FileText, 'file-text', 'size-4')} <span>5 contracts awaiting sign-off</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Users, 'users', 'size-4')} <span>2 new hires start Monday</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> ` : ''} ${this.active === 'messages' ? html` <uip-sidebar-group> <uip-sidebar-group-label>Direct messages</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> ${[ { initials: 'OB', tile: 'bg-chart-1/15', name: 'Olivia Bennett' }, { initials: 'DM', tile: 'bg-chart-2/15', name: 'Daniel Morgan' }, { initials: 'ER', tile: 'bg-chart-3/15', name: 'Ethan Reynolds' }, ].map( (person) => html` <uip-sidebar-menu-item> <uip-sidebar-menu-button> <uip-avatar class="size-5"> <uip-avatar-fallback class=${`${person.tile} text-foreground text-xs`}>${person.initials}</uip-avatar-fallback> </uip-avatar> <span>${person.name}</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> `, )} </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> <uip-sidebar-group> <uip-sidebar-group-label>Channels</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(MessageSquare, 'message-square', 'size-4')} <span># announcements</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(MessageSquare, 'message-square', 'size-4')} <span># hiring</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> ` : ''} ${this.active === 'support' ? html` <uip-sidebar-group> <uip-sidebar-group-label>Help center</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(HelpCircle, 'circle-question-mark', 'size-4')} <span>Getting started</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(FileText, 'file-text', 'size-4')} <span>Onboarding guide</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Send, 'send', 'size-4')} <span>Contact support</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> ` : ''} ${this.active === 'settings' ? html` <uip-sidebar-group> <uip-sidebar-group-label>Workspace</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(SettingsIcon, 'settings', 'size-4')} <span>General</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Users, 'users', 'size-4')} <span>Members</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(CreditCard, 'credit-card', 'size-4')} <span>Billing</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Gauge, 'gauge', 'size-4')} <span>Limits</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> <uip-sidebar-group> <uip-sidebar-group-label>Account</uip-sidebar-group-label> <uip-sidebar-group-content> <uip-sidebar-menu> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Lock, 'lock', 'size-4')} <span>Security</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> <uip-sidebar-menu-item> <uip-sidebar-menu-button> ${icon(Bell, 'bell', 'size-4')} <span>Notifications</span> </uip-sidebar-menu-button> </uip-sidebar-menu-item> </uip-sidebar-menu> </uip-sidebar-group-content> </uip-sidebar-group> ` : ''} </uip-sidebar-content> </div> </div> </uip-sidebar> ` } } customElements.get('uip-sidebar-05') || customElements.define('uip-sidebar-05', UipSidebar05) declare global { interface HTMLElementTagNameMap { 'uip-sidebar-05': UipSidebar05 } }
Raw manifest:https://uipkge.dev/r/lit/sidebar-05.json