Sidebar 001
Animated vertical sidebar with spring hover highlight, animated active bar, collapsible groups, and drag-to-resize.
Installation
File Structure
Usage
A full example showing sections, collapsible groups, the isNew badge, and an effects toggle:
"use client";
import { usePathname } from "next/navigation";
import {
Sidebar001,
Sidebar001Content,
Sidebar001Header,
Sidebar001Footer,
Sidebar001Section,
Sidebar001Group,
Sidebar001Item,
useSidebar001Effects,
} from "@/components/unlumen-ui/sidebar-001";
import { BookOpen, Layers } from "lucide-react";
const nav = [
{
label: "Getting Started",
items: [
{ href: "/docs/introduction", label: "Introduction" },
{ href: "/docs/installation", label: "Installation" },
{ href: "/docs/theming", label: "Theming", isNew: true },
],
},
];
const advanced = [
{ href: "/docs/animations", label: "Animations" },
{ href: "/docs/accessibility", label: "Accessibility" },
];
function EffectsToggle() {
const { enabled, toggle } = useSidebar001Effects();
return (
<button
onClick={toggle}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Effects: {enabled ? "on" : "off"}
</button>
);
}
export function AppSidebar() {
const pathname = usePathname();
return (
<Sidebar001 defaultWidth={260}>
<Sidebar001Header>
<span className="text-sm font-semibold">Docs</span>
</Sidebar001Header>
<Sidebar001Content>
{/* Flat section with a heading */}
{nav.map((section) => (
<Sidebar001Section key={section.label} label={section.label}>
{section.items.map((item) => (
<Sidebar001Item
key={item.href}
href={item.href}
label={item.label}
isActive={pathname === item.href}
isNew={item.isNew}
/>
))}
</Sidebar001Section>
))}
{/* Collapsible group */}
<Sidebar001Group label="Advanced" icon={<Layers />} defaultOpen>
{advanced.map((item) => (
<Sidebar001Item
key={item.href}
href={item.href}
label={item.label}
isActive={pathname === item.href}
/>
))}
</Sidebar001Group>
</Sidebar001Content>
<Sidebar001Footer>
<EffectsToggle />
</Sidebar001Footer>
</Sidebar001>
);
}Composition
The sidebar is built from composable pieces:
| Component | Role |
|---|---|
Sidebar001 | Root — provides context, sets width, renders resize handle |
Sidebar001Header | Fixed top slot |
Sidebar001Content | Scrollable body — place sections and groups here |
Sidebar001Section | Flat list of items with an optional heading |
Sidebar001Group | Collapsible group with animated open/close |
Sidebar001Item | Individual navigation link |
Sidebar001Footer | Fixed bottom slot, above a border |
Collapsible groups
Sidebar001Group wraps any number of items in an animated accordion. Use it when you want to collapse an entire category.
<Sidebar001Group label="Components" icon={<Layers />} defaultOpen>
<Sidebar001Item href="/docs/button" label="Button" isActive={false} />
<Sidebar001Item href="/docs/card" label="Card" isActive={false} />
</Sidebar001Group>Pass icon to show a leading icon next to the label. Omit it for a compact chevron-only style.
New badge
Pass isNew to any item to show a small accent dot after the label:
<Sidebar001Item href="/docs/theming" label="Theming" isActive={false} isNew />Effects toggle
Motion effects are stored in localStorage and opt-out by default. Use useSidebar001Effects to let users control it:
import { useSidebar001Effects } from "@/components/unlumen-ui/sidebar-001";
function EffectsToggle() {
const { enabled, toggle } = useSidebar001Effects();
return <button onClick={toggle}>Effects: {enabled ? "on" : "off"}</button>;
}The hook must be called inside a <Sidebar001> tree.
Resize handle
The sidebar is draggable by default. Constrain or disable it via minWidth / maxWidth:
<Sidebar001 defaultWidth={240} minWidth={180} maxWidth={360}>
...
</Sidebar001>API Reference
Sidebar001
childrenReact.ReactNode—Sidebar contents — Header, Content, Footer.
className?string—Additional classes on the root aside element.
defaultEffectsEnabled?booleantrueInitial value for the motion effects toggle (persisted in localStorage).
defaultWidth?number240Initial sidebar width in px.
minWidth?number160Minimum drag width in px.
maxWidth?number400Maximum drag width in px.
Sidebar001Header
children?React.ReactNode—Content rendered at the top of the sidebar.
className?string—Additional classes.
Sidebar001Content
childrenReact.ReactNode—Scrollable body — typically Sidebar001Section and Sidebar001Group elements.
className?string—Additional classes on the scroll container.
Sidebar001Section
label?React.ReactNode—Section heading shown above the group of items.
childrenReact.ReactNode—Sidebar001Item elements.
className?string—Additional classes.
Sidebar001Group
labelReact.ReactNode—Group heading shown in the toggle button.
childrenReact.ReactNode—Sidebar001Item elements shown when the group is open.
defaultOpen?booleanfalseWhether the group starts expanded.
icon?React.ReactNode—Optional leading icon next to the label. Omit for compact chevron-only style.
className?string—Additional classes.
Sidebar001Item
hrefstring—Navigation target.
labelReact.ReactNode—Text or node shown as the link label.
isActiveboolean—Marks this item as the current page.
isNew?boolean—Shows a small accent dot after the label.
className?string—Additional classes on the anchor element.
onClick?React.MouseEventHandler<HTMLAnchorElement>—Click handler for the anchor.
Sidebar001Footer
children?React.ReactNode—Content rendered at the bottom of the sidebar, above a top border.
className?string—Additional classes.
Notes
- The hover highlight is a single shared
motion.divthat tracks the hovered item's bounding rect — no per-item background, no re-layout on hover. - The active bar uses
layoutId="sb001-active-bar"so it animates smoothly between items as the active route changes. useScrollToActivefires once per active item mount viarequestIdleCallback, centering the item in the scroll viewport without blocking the initial render.- Resize is pointer-capture based — dragging works even if the cursor moves off the handle.
Credits
Built by leo.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
"use client";
import * as React from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { motion, AnimatePresence } from "motion/react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
const MotionChevron = motion.create(ChevronRight);
const EFFECTS_KEY = "sidebar-001-effects";
const EffectsContext = createContext<{ enabled: boolean; toggle: () => void }>({
enabled: true,
toggle: () => {},
});
function EffectsProvider({
children,
defaultEnabled = true,
}: {
children: React.ReactNode;
defaultEnabled?: boolean;
}) {
const [enabled, setEnabled] = useState(() => {
if (typeof window === "undefined") return defaultEnabled;
const stored = localStorage.getItem(EFFECTS_KEY);
return stored !== null ? stored === "true" : defaultEnabled;
});
const toggle = useCallback(() => {
setEnabled((prev) => {
const next = !prev;
localStorage.setItem(EFFECTS_KEY, String(next));
return next;
});
}, []);
const value = useMemo(() => ({ enabled, toggle }), [enabled, toggle]);
return (
<EffectsContext.Provider value={value}>{children}</EffectsContext.Provider>
);
}
export function useSidebar001Effects() {
return useContext(EffectsContext);
}
// ─── Hover context ────────────────────────────────────────────────────────────
interface HoverRect {
top: number;
height: number;
left: number;
}
const HoverContext = createContext<{
hovered: string | null;
hoverRect: HoverRect | null;
containerRef: React.RefObject<HTMLDivElement | null>;
setHovered: (id: string | null, rect?: HoverRect | null) => void;
}>({
hovered: null,
hoverRect: null,
containerRef: { current: null },
setHovered: () => {},
});
function HoverProvider({
children,
containerRef,
}: {
children: React.ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const [hovered, setHoveredId] = useState<string | null>(null);
const [hoverRect, setHoverRect] = useState<HoverRect | null>(null);
const setHovered = useCallback(
(id: string | null, rect?: HoverRect | null) => {
setHoveredId(id);
setHoverRect(rect ?? null);
},
[],
);
const value = useMemo(
() => ({ hovered, hoverRect, containerRef, setHovered }),
[hovered, hoverRect, containerRef, setHovered],
);
return (
<HoverContext.Provider value={value}>{children}</HoverContext.Provider>
);
}
// ─── Scroll to active ─────────────────────────────────────────────────────────
function useScrollToActive(active: boolean) {
const ref = useRef<HTMLDivElement>(null);
const scrolled = useRef(false);
useEffect(() => {
if (!active || scrolled.current || !ref.current) return;
scrolled.current = true;
const el = ref.current;
const schedule =
typeof requestIdleCallback !== "undefined"
? (cb: () => void) => requestIdleCallback(cb)
: (cb: () => void) => setTimeout(cb, 100);
const cancel =
typeof cancelIdleCallback !== "undefined"
? cancelIdleCallback
: clearTimeout;
const id = schedule(() => {
const viewport = el.closest("[data-scroll-viewport]");
if (!(viewport instanceof HTMLElement)) return;
const vpRect = viewport.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const offset =
elRect.top - vpRect.top - vpRect.height / 2 + elRect.height / 2;
if (Math.abs(offset) > 40)
viewport.scrollBy({ top: offset, behavior: "smooth" });
});
return () => cancel(id as number);
}, [active]);
useEffect(() => {
if (!active) scrolled.current = false;
}, [active]);
return ref;
}
// ─── HoverHighlight ───────────────────────────────────────────────────────────
function HoverHighlight() {
const { hoverRect, hovered } = useContext(HoverContext);
const { enabled } = useContext(EffectsContext);
return (
<AnimatePresence>
{enabled && hovered && hoverRect && (
<motion.div
key="sb001-hover-bg"
className="pointer-events-none absolute z-0 rounded-md bg-accent/50"
style={{ right: 0 }}
initial={false}
animate={{
top: hoverRect.top + 2,
height: hoverRect.height - 4,
left: hoverRect.left,
opacity: 1,
}}
exit={{ opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</AnimatePresence>
);
}
// ─── Sidebar001Item ───────────────────────────────────────────────────────────
export interface Sidebar001ItemProps {
href: string;
label: React.ReactNode;
isActive: boolean;
isNew?: boolean;
className?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
}
export const Sidebar001Item = memo(function Sidebar001Item({
href,
label,
isActive,
isNew,
className,
onClick,
}: Sidebar001ItemProps) {
const { hovered, setHovered, containerRef } = useContext(HoverContext);
const isHovered = hovered === href;
const itemRef = useScrollToActive(isActive);
const opacity = isActive
? 1
: hovered !== null
? isHovered
? 1
: 0.3
: 0.55;
const x = isActive ? 8 : isHovered ? 6 : 0;
return (
<div className="relative">
{isActive && (
<motion.span
layoutId="sb001-active-bar"
className="pointer-events-none absolute z-10 left-[4px] top-1/2 h-[1.8px] -translate-y-1/2 rounded-full bg-accent-pro"
animate={{ width: 23 }}
transition={{ type: "spring", stiffness: 800, damping: 40 }}
/>
)}
<motion.span
className="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 h-px bg-foreground/50"
animate={{ width: isActive ? 0 : isHovered ? 26 : 18 }}
transition={{ type: "spring", stiffness: 600, damping: 30 }}
/>
<motion.span className="pointer-events-none absolute w-[13px] left-0 top-1/4 h-px bg-foreground/30" />
<motion.span className="pointer-events-none absolute w-[16px] left-0 top-0 h-px bg-foreground/30" />
<motion.span className="pointer-events-none absolute w-[13px] left-0 top-3/4 h-px bg-foreground/30" />
<motion.div
ref={itemRef}
animate={{ opacity, x }}
transition={{ type: "spring", stiffness: 700, damping: 30 }}
style={{ transformOrigin: "left center" }}
>
<a
href={href}
onClick={onClick}
onMouseEnter={() => {
const el = itemRef.current;
const container = containerRef.current;
if (el && container) {
const elRect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
setHovered(href, {
top: elRect.top - containerRect.top,
height: elRect.height,
left: 25,
});
} else {
setHovered(href);
}
}}
onMouseLeave={() => setHovered(null)}
className={cn(
"relative flex items-center gap-2 ml-2 pl-4 py-1.5 text-sm select-none",
className,
)}
>
<span className="relative z-1 truncate">{label}</span>
{isNew && (
<span className="size-1.5 rounded-full bg-accent-pro shrink-0" />
)}
</a>
</motion.div>
</div>
);
});
// ─── Sidebar001Separator ──────────────────────────────────────────────────────
export function Sidebar001Separator({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"px-0 py-3.5 mt-2 text-sm font-medium text-foreground/40",
className,
)}
>
{children}
</div>
);
}
// ─── Sidebar001Group ──────────────────────────────────────────────────────────
export interface Sidebar001GroupProps {
label: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
icon?: React.ReactNode;
className?: string;
}
export function Sidebar001Group({
label,
children,
defaultOpen = false,
icon,
className,
}: Sidebar001GroupProps) {
const [isOpen, setIsOpen] = useState(false);
const id = useId();
const { setHovered, containerRef } = useContext(HoverContext);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
setIsOpen(defaultOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleMouseEnter = useCallback(() => {
const el = buttonRef.current;
const container = containerRef.current;
if (el && container) {
const elRect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
setHovered(id, {
top: elRect.top - containerRect.top,
height: elRect.height,
left: 0,
});
} else {
setHovered(id);
}
}, [id, setHovered, containerRef]);
const handleMouseLeave = useCallback(() => {
setHovered(null);
}, [setHovered]);
return (
<div className={cn("flex flex-col", className)}>
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen((v) => !v)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="relative z-1 flex items-center gap-1.5 py-1.5 pr-2 select-none text-left w-full group"
>
{icon ? (
<>
<span className="shrink-0 text-foreground/35 [&_svg]:size-3.5">
{icon}
</span>
<span className="text-sm text-foreground/45 group-hover:text-foreground/70 transition-colors duration-150 flex-1">
{label}
</span>
<MotionChevron
size={14}
strokeWidth={2.5}
className="shrink-0 text-foreground/25 mr-1"
animate={{ rotate: isOpen ? 90 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</>
) : (
<>
<MotionChevron
size={11}
strokeWidth={2.5}
className="shrink-0 text-foreground/35"
animate={{ rotate: isOpen ? 90 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
<span className="text-sm text-foreground/45 group-hover:text-foreground/70 transition-colors duration-150">
{label}
</span>
</>
)}
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 34 }}
style={{ overflow: "hidden" }}
>
<div className="flex flex-col pl-3">{children}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ─── Sidebar001Section ────────────────────────────────────────────────────────
export function Sidebar001Section({
label,
children,
className,
}: {
label?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col", className)}>
{label && <Sidebar001Separator>{label}</Sidebar001Separator>}
{children}
</div>
);
}
// ─── Sidebar001Content ────────────────────────────────────────────────────────
export function Sidebar001Content({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const containerRef = useContext(HoverContext).containerRef;
return (
<div
className={cn("flex-1 overflow-y-auto py-4 no-scrollbar", className)}
data-scroll-viewport
>
<div ref={containerRef} className="relative px-1">
<HoverHighlight />
{children}
</div>
</div>
);
}
// ─── Sidebar001 (with resize) ─────────────────────────────────────────────────
export interface Sidebar001Props {
children: React.ReactNode;
className?: string;
defaultEffectsEnabled?: boolean;
/** Initial width in px. Default: 240 */
defaultWidth?: number;
/** Min resize width in px. Default: 160 */
minWidth?: number;
/** Max resize width in px. Default: 400 */
maxWidth?: number;
}
export function Sidebar001({
children,
className,
defaultEffectsEnabled = true,
defaultWidth = 240,
minWidth = 160,
maxWidth = 400,
}: Sidebar001Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(defaultWidth);
const dragging = useRef(false);
const startX = useRef(0);
const startW = useRef(0);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startW.current = width;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[width],
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragging.current) return;
const next = Math.min(
maxWidth,
Math.max(minWidth, startW.current + e.clientX - startX.current),
);
setWidth(next);
},
[minWidth, maxWidth],
);
const onPointerUp = useCallback(() => {
dragging.current = false;
}, []);
return (
<EffectsProvider defaultEnabled={defaultEffectsEnabled}>
<HoverProvider containerRef={containerRef}>
<aside
className={cn(
"relative flex flex-col h-full shrink-0 bg-background",
className,
)}
style={{ width }}
>
{children}
{/* Resize handle */}
<div
className="absolute top-0 right-0 h-full w-1 cursor-col-resize group/handle z-20"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div className="absolute right-0 top-0 h-full w-px bg-border/50 group-hover/handle:bg-border transition-colors duration-150" />
</div>
</aside>
</HoverProvider>
</EffectsProvider>
);
}
// ─── Sidebar001Header ─────────────────────────────────────────────────────────
export function Sidebar001Header({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("shrink-0 px-3 pt-4 pb-2", className)}>{children}</div>
);
}
// ─── Sidebar001Footer ─────────────────────────────────────────────────────────
export function Sidebar001Footer({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"shrink-0 px-3 pb-4 pt-2 border-t border-border/50",
className,
)}
>
{children}
</div>
);
}