How to build a Sidebar Like ShadCN (Without Using ShadCN)

In this blog we will try to understand how to build a sidebar like shadcn.

User Avatar

Faisal Husain

The other day at work, I was using the ShadCN sidebar. After digging into its code, I realized this is how frontend components should be built. I became a fan and even went on Twitter/X to praise it.

That made me wonder: if someone asked me to build a sidebar without using ShadCN, would I be able to do it? So, just for the sake of learning, I decided to build a sidebar like ShadCN from scratch.

This is purely for learning. If I need a sidebar in a real project, I’d still use ShadCN’s. But it’s important to understand what goes into engineering a component like this.

Let's get started

So before starting I would recommend reading this by Shadcn to understand the basic of sidebar.

Let’s break down the components we need

BlogImage

So primarily, we need:

  1. Sidebar Providers : The provider component that will have state of sidebar.
  2. Sidebar : The main component that will be used to display the sidebar.

These are the main components we need. There will be more subcomponents for each, which we’ll explore in each section.

1. Sidebar Providers

We'll use a React context to manage the sidebar's state. Specifically, we'll store whether the sidebar is collapsed or expanded in this context.

Here's what the code will look like:

sidebar-context.tsx
import { createContext, useContext, useState } from 'react';
 
type SidebarContextType = {
  collapsed: boolean;
  toggle: () => void;
  expand: () => void;
  collapse: () => void;
};
 
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
 
export const SidebarProvider = ({ children }: { children: React.ReactNode }) => {
  const [collapsed, setCollapsed] = useState(false);
  const toggle = () => setCollapsed((prev) => !prev);
  const expand = () => setCollapsed(false);
  const collapse = () => setCollapsed(true);
 
  return (
    <SidebarContext.Provider value={{ collapsed, toggle, expand, collapse }}>
      {children}
    </SidebarContext.Provider>
  );
};
 
export const useSidebar = () => {
  const context = useContext(SidebarContext);
  if (!context) throw new Error('useSidebar must be used within SidebarProvider');
  return context;
};

2. Sidebar

This will be the main component that will be used to display the sidebar.

It will have three main components:

  1. Wrapper : The main content of the sidebar.
  2. Toggle : The component that will be used to trigger the sidebar.
  3. Navigation & Items : The component that will be used to display the navigation items.

Lets Start with wrapper

2.1 Wrapper

sidebar-wrapper.tsx
import { cn } from '@/lib/utils';
import { useSidebar } from './sidebar-context';
 
export const Wrapper = ({ children }: { children: React.ReactNode }) => {
  const { collapsed } = useSidebar();
 
  return (
    <aside
      className={cn(
        'fixed left-0 top-0 h-full z-50 border-r bg-background transition-all duration-200',
        collapsed ? 'w-[70px]' : 'w-60'
      )}
    >
      {children}
    </aside>
  );
};

2.2 Toggle

sidebar-toggle.tsx
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSidebar } from './sidebar-context';
 
const Toggle = () => {
  const { collapsed, expand, collapse } = useSidebar();
 
  return collapsed ? (
    <div className="hidden lg:flex justify-center pt-4 mb-4">
      <Button onClick={expand} variant="ghost" className="p-2">
        <Menu className="h-4 w-4" />
      </Button>
    </div>
  ) : (
    <div className="hidden lg:flex items-center justify-between px-4 py-3 mb-2">
      <p className="text-primary font-semibold">Dashboard</p>
      <Button onClick={collapse} variant="ghost" className="p-2">
        <Menu className="h-4 w-4" />
      </Button>
    </div>
  );
};
 
export default Toggle;

2.3 Navigation & Items

sidebar-navigation.tsx
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Search, TrendingUp } from 'lucide-react';
import { NavItem } from './nav-items';
 
export const Navigation = () => {
  const pathname = usePathname();
 
  const routes = [
    { label: "Search", href: "#1", icon: Search },
    { label: "Dashboard", href: "#2", icon: LayoutDashboard },
    { label: "Trending", href: "#3", icon: TrendingUp },
  ];
 
  return (
    <ul className="space-y-2 px-2 pt-4 lg:pt-0">
      {routes.map((route) => (
        <NavItem
          key={route.href}
          {...route}
          isActive={pathname === route.href}
        />
      ))}
    </ul>
  );
};
sidebar-nav-items.tsx
import Link from 'next/link';
import { LucideIcon } from 'lucide-react';
import { useSidebar } from './sidebar-context';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
 
interface NavItemProps {
  icon: LucideIcon;
  label: string;
  href: string;
  isActive: boolean;
}
 
export const NavItem = ({ icon: Icon, label, href, isActive }: NavItemProps) => {
  const { collapsed } = useSidebar();
 
  return (
    <Button
      asChild
      variant="ghost"
      className={cn(
        'w-full h-12',
        collapsed ? 'justify-center' : 'justify-start',
        isActive && 'bg-accent'
      )}
    >
      <Link href={href}>
        <div className="flex items-center gap-x-4">
          <Icon className="h-4 w-4" />
          {!collapsed && <span>{label}</span>}
        </div>
      </Link>
    </Button>
  );
};

Final Sidebar

Now we take all this and curate a final sidebar component

sidebar.tsx
import { Wrapper } from './wrapper';
import Toggle from './toggle';
import { Navigation } from './navigation';
 
const Sidebar = () => {
  return (
    <Wrapper>
      <Toggle />
      <Navigation />
    </Wrapper>
  );
};
 
export default Sidebar;

Now the last component we will wrap children within a container which adjust padding as per collapsed state

So here is container code

sidebar-container.tsx
"use client";
import { cn } from "@/lib/utils";
import { useSidebar } from "./sidebar-context";
 
interface ContainerProps {
    children: React.ReactNode;
};
 
export const Container = ({
    children,
}: ContainerProps) => {
    const {
        collapsed,
 
    } = useSidebar();
 
 
    return (
        <div className={cn(
            "flex-1 overflow-hidden transition-all duration-200 ease-in-out  z-[99999]",
            collapsed ? "ml-[70px]" : "ml-[70px] lg:ml-60"
        )}>
            {children}
        </div>
    );
};
sidebar.tsx
import { Container } from './_components/container';
import Sidebar from './_components/Sidebar';
import { SidebarProvider } from './_components/sidebar-context';
 
export default function SidebarLayout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <Sidebar />
      <Container>{children}</Container> 
    </SidebarProvider>
  );
} 
INFO

So this is not a perfect implementation but it will give you a good idea of how to build a sidebar. I will list down below what can be improved or what features should be added. To make it fully functional.

What can be improved
  1. We can track whether it's mobile so we can collapse it automatically.
  2. We can give tool tip to each nav item when collapsed.

These are the points currently coming to my mind.

But you can see the live implementation here.

Conclusion

So this is how you can build a sidebar like shadcn. I will definitely use shadcn's sidebar but just for the learning purpose I built this.

This is how we can break down a component and build it from scratch.

I hope you found this blog helpful.

Want to hire me as a freelancer? Let's discuss.
Drop a message and let's discuss
HomeAboutProjectsBlogsHire MeCrafts