Sidebar
Collapsible sidebar navigation layout.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.com/r/sidebar.jsonbash
Code
"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "../../lib/utils";
import { useSidebar } from "../sidebar-provider";
export type SidebarItem = {
href: string;
title: string;
};
export type SidebarSection = {
collapsible?: boolean;
defaultOpen?: boolean;
items: SidebarItem[];
title?: string;
};
type SidebarProps = {
sections: SidebarSection[];
};
function useMobile(setOpen: (open: boolean) => void) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 1024;
setIsMobile(mobile);
if (mobile) {
setOpen(false);
} else {
setOpen(true);
}
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, [setOpen]);
return isMobile;
}
function useScrollFade(
containerReference: React.RefObject<HTMLDivElement | null>,
) {
const [showTopFade, setShowTopFade] = useState(false);
const [showBottomFade, setShowBottomFade] = useState(false);
useEffect(() => {
const container = containerReference.current;
if (!container) return;
const checkScroll = () => {
const { clientHeight, scrollHeight, scrollTop } = container;
setShowTopFade(scrollTop > 0);
setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
};
checkScroll();
container.addEventListener("scroll", checkScroll);
return () => {
container.removeEventListener("scroll", checkScroll);
};
}, [containerReference]);
return { showBottomFade, showTopFade };
}
type CollapsibleSectionProps = {
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
title: string;
};
function CollapsibleSection({
children,
collapsible = false,
defaultOpen = true,
title,
}: CollapsibleSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
if (!collapsible) {
return (
<>
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{title}
</div>
{children}
</>
);
}
return (
<>
<button
className="flex w-full items-center justify-between px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
onClick={() => {
setIsOpen(!isOpen);
}}
type="button"
>
<span>{title}</span>
<ChevronDown
className={cn(
"h-3 w-3 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200 ease-in-out",
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<div className="overflow-hidden">{children}</div>
</div>
</>
);
}
// eslint-disable-next-line max-lines-per-function
export function Sidebar({ sections }: SidebarProps) {
const pathname = usePathname();
const { open, setOpen } = useSidebar();
const isMobile = useMobile(setOpen);
const scrollContainerReference = useRef<HTMLDivElement>(null);
const { showBottomFade, showTopFade } = useScrollFade(
scrollContainerReference,
);
return (
<>
{/* Mobile overlay */}
{isMobile && open ? (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => {
setOpen(false);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setOpen(false);
}
}}
role="button"
tabIndex={0}
/>
) : null}
{/* Sidebar */}
<aside
className={cn(
"fixed lg:relative top-16 lg:top-0 bottom-0 lg:bottom-auto left-0 z-40 lg:h-full border-r bg-background transition-transform duration-300 ease-in-out",
"flex flex-col",
"overflow-hidden",
"shrink-0",
isMobile ? "w-full" : "w-64",
isMobile && !open && "-translate-x-full",
isMobile && open && "translate-x-0",
!isMobile && !open && "-translate-x-full lg:translate-x-0",
!isMobile && "lg:translate-x-0",
)}
>
<div className="relative flex-1 overflow-hidden">
{/* Top fade */}
{showTopFade ? (
<div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent pointer-events-none z-20" />
) : null}
{/* Bottom fade */}
{showBottomFade ? (
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent pointer-events-none z-20" />
) : null}
<nav
className="flex-1 p-4 overflow-y-auto h-full"
ref={scrollContainerReference}
>
<div className="space-y-4">
{sections.map((section, sectionIndex) => {
const sectionItems = (
<div className={section.title ? "space-y-0.5" : "space-y-1"}>
{section.items.map((item) => (
<Link
className={cn(
section.title
? "block px-3 py-1.5 rounded-md text-sm transition-colors"
: "flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors",
pathname === item.href ||
(item.href === "/" && pathname === "/")
? "bg-accent text-accent-foreground"
: section.title
? "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "hover:bg-accent hover:text-accent-foreground",
section.title &&
pathname === item.href &&
"font-medium",
)}
href={item.href}
key={item.href}
onClick={() => {
if (isMobile) {
setOpen(false);
}
}}
>
{item.title}
</Link>
))}
</div>
);
return (
<div
className="space-y-1"
key={section.title || sectionIndex}
>
{section.title ? (
<CollapsibleSection
collapsible={section.collapsible}
defaultOpen={section.defaultOpen ?? true}
title={section.title}
>
{sectionItems}
</CollapsibleSection>
) : (
sectionItems
)}
</div>
);
})}
</div>
</nav>
</div>
</aside>
</>
);
}
typescript