navigationclient
sidebar
grouped navigation in a panel. a dark pill slides to whatever's active.
blurrow
personal workspace
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add sidebaror by hand: drop this file into components/ui and import from there.
components/ui/Sidebar.tsxtsx
1"use client";2
3import React, { useState, useRef, useEffect, useLayoutEffect } from "react";4
5interface SidebarItem {6 key: string;7 label: string;8 icon?: React.ReactNode;9 badge?: string | number;10 disabled?: boolean;11}12
13interface SidebarSection {14 title?: string;15 items: SidebarItem[];16}17
18interface SidebarHeader {19 logo?: React.ReactNode;20 title: string;21 subtitle?: string;22}23
24interface SidebarProps {25 sections: SidebarSection[];26 header?: SidebarHeader;27 defaultActive?: string;28 active?: string;29 onSelect?: (key: string) => void;30 className?: string;31}32
33export function Sidebar({34 sections,35 header,36 defaultActive = "",37 active,38 onSelect,39 className = "",40}: SidebarProps) {41 const [internal, setInternal] = useState(defaultActive);42 const current = active !== undefined ? active : internal;43 const [pill, setPill] = useState({ top: 0, height: 0, ready: false });44 const navRef = useRef<HTMLElement>(null);45 const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});46
47 const measurePill = (key: string) => {48 const btn = btnRefs.current[key];49 const nav = navRef.current;50 if (!btn || !nav) return;51 const nr = nav.getBoundingClientRect();52 const br = btn.getBoundingClientRect();53 setPill({ top: br.top - nr.top, height: br.height, ready: true });54 };55
56 useLayoutEffect(() => { measurePill(current); }, []);57 useEffect(() => { measurePill(current); }, [current]);58
59 useEffect(() => {60 const ro = new ResizeObserver(() => measurePill(current));61 if (navRef.current) ro.observe(navRef.current);62 return () => ro.disconnect();63 }, [current]);64
65 const handleSelect = (key: string, disabled?: boolean) => {66 if (disabled) return;67 setInternal(key);68 onSelect?.(key);69 };70
71 return (72 <div73 className={["rounded-[20px] p-1.5", className].join(" ")}74 style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}75 >76 <div className="bg-white rounded-[15px] overflow-hidden" style={{ boxShadow: "var(--panel-face-shadow)" }}>77 {header && (78 <div className="px-3 py-3 border-b border-neutral-100 flex items-center gap-2.5">79 {header.logo && (80 <div81 className="w-7 h-7 rounded-[8px] flex items-center justify-center shrink-0 text-white"82 style={{ background: "var(--face-dark)", boxShadow: "var(--face-dark-shadow)" }}83 >84 {header.logo}85 </div>86 )}87 <div className="flex-1 min-w-0">88 <p className="text-[13px] font-semibold text-neutral-900 leading-none truncate">{header.title}</p>89 {header.subtitle && (90 <p className="text-[11px] text-neutral-400 mt-0.5 leading-none truncate">{header.subtitle}</p>91 )}92 </div>93 <button94 type="button"95 aria-label="Switch workspace"96 className="shrink-0 w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-900 transition-colors cursor-pointer rounded-[7px] hover:bg-neutral-100"97 >98 <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">99 <path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />100 </svg>101 </button>102 </div>103 )}104
105 <nav ref={navRef} aria-label={header?.title ?? "Sidebar"} className="relative p-2 flex flex-col gap-4">106 {pill.ready && (107 <span108 aria-hidden109 className="absolute left-2 right-2 rounded-[11px] pointer-events-none z-0"110 style={{111 top: pill.top,112 height: pill.height,113 background: "var(--face-dark)",114 boxShadow: "var(--face-dark-shadow)",115 transition: "top 300ms var(--ease-spring), height 300ms var(--ease-spring)",116 }}117 />118 )}119
120 {sections.map((section, si) => (121 <div key={si} className="flex flex-col gap-0.5">122 {section.title && (123 <p className="relative z-10 px-2.5 pt-1 pb-1.5 text-[11.5px] font-medium text-neutral-400 lowercase">124 {section.title}125 </p>126 )}127 {section.items.map((item) => {128 const isActive = current === item.key;129 return (130 <button131 key={item.key}132 ref={(el) => { btnRefs.current[item.key] = el; }}133 type="button"134 onClick={() => handleSelect(item.key, item.disabled)}135 disabled={item.disabled}136 aria-current={isActive ? "page" : undefined}137 className={[138 "relative z-10 w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[11px] text-[13px] font-medium text-left",139 "transition-colors duration-200 cursor-pointer",140 "disabled:opacity-35 disabled:pointer-events-none",141 isActive142 ? "text-white"143 : "text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100/80",144 ].join(" ")}145 >146 {item.icon && (147 <span148 className={[149 "shrink-0 w-[22px] h-[22px] rounded-md flex items-center justify-center transition-colors duration-200",150 isActive151 ? "bg-white/12 shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]"152 : "text-neutral-500 [background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",153 ].join(" ")}154 >155 {item.icon}156 </span>157 )}158
159 <span className="flex-1 truncate">{item.label}</span>160
161 {item.badge !== undefined && (162 <span163 className={[164 "shrink-0 text-[11px] font-medium px-1.5 py-0.5 rounded-md tabular-nums leading-none transition-colors duration-200",165 isActive166 ? "bg-white/20 text-white/80"167 : "bg-neutral-100 text-neutral-500",168 ].join(" ")}169 >170 {item.badge}171 </span>172 )}173 </button>174 );175 })}176 </div>177 ))}178 </nav>179 </div>180 </div>181 );182}usage
tsx
import { Sidebar } from "@blurrow/ui";tsx
<Sidebar sections={sections} active={active} onSelect={setActive} />props
straight from the source, so they're always up to date.
propsts
interface SidebarSection { title?: string; items: SidebarItem[];}
interface SidebarHeader { logo?: React.ReactNode; title: string; subtitle?: string;}
interface SidebarProps { sections: SidebarSection[]; header?: SidebarHeader; defaultActive?: string; active?: string; onSelect?: (key: string) => void; className?: string;}