Guide

How to build a collapsible animated sidebar in Next.js

An application sidebar has more moving parts than it looks: desktop and mobile states, a collapsed icon rail, a toggle that works from anywhere, and focus handling when it becomes a sheet on small screens. This guide walks through each part using the beUI Animated Sidebar, which follows the same provider and composition model as the shadcn sidebar and adds Framer Motion transitions.

Install the beUI Animated Sidebar

Add the component with the shadcn CLI. It installs components/motion/animated-sidebar.tsx and the files it imports, including the shared layout highlight used for the active row.

ShellTerminal
npx shadcn@latest add @beui/animated-sidebar

How is the sidebar composed?

AnimatedSidebarProvider owns the state: whether the desktop sidebar is open, whether the mobile sheet is open, and whether the viewport is mobile. AnimatedSidebar renders the panel, and AnimatedSidebarInset is the main content area next to it. Inside the panel you compose AnimatedSidebarHeader, AnimatedSidebarContent, AnimatedSidebarGroup with its label and content, AnimatedSidebarMenu, AnimatedSidebarMenuItem and AnimatedSidebarMenuButton, then an optional AnimatedSidebarFooter.

Any component below the provider can call useAnimatedSidebar to read state, open, isMobile or call toggleSidebar, which is how AnimatedSidebarTrigger works.

TSXcomponents/app-shell.tsx
"use client";

import { Home, Inbox, PanelLeft, Settings } from "lucide-react";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import {
  AnimatedSidebar,
  AnimatedSidebarContent,
  AnimatedSidebarGroup,
  AnimatedSidebarGroupContent,
  AnimatedSidebarGroupLabel,
  AnimatedSidebarHeader,
  AnimatedSidebarInset,
  AnimatedSidebarMenu,
  AnimatedSidebarMenuButton,
  AnimatedSidebarMenuItem,
  AnimatedSidebarProvider,
  AnimatedSidebarRail,
  AnimatedSidebarTrigger,
} from "@/components/motion/animated-sidebar";

const links = [
  { href: "/", label: "Home", icon: Home },
  { href: "/inbox", label: "Inbox", icon: Inbox },
  { href: "/settings", label: "Settings", icon: Settings },
];

export function AppShell({ children }: { children: ReactNode }) {
  const pathname = usePathname();

  return (
    <AnimatedSidebarProvider>
      <AnimatedSidebar ariaLabel="Main navigation" collapsible="icon">
        <AnimatedSidebarHeader>
          <span className="px-2 text-sm font-semibold">Acme</span>
        </AnimatedSidebarHeader>
        <AnimatedSidebarContent className="px-2">
          <AnimatedSidebarGroup>
            <AnimatedSidebarGroupLabel>Workspace</AnimatedSidebarGroupLabel>
            <AnimatedSidebarGroupContent>
              <AnimatedSidebarMenu>
                {links.map(({ href, label, icon: Icon }) => (
                  <AnimatedSidebarMenuItem key={href}>
                    <AnimatedSidebarMenuButton
                      href={href}
                      isActive={pathname === href}
                      icon={<Icon className="size-4" />}
                    >
                      {label}
                    </AnimatedSidebarMenuButton>
                  </AnimatedSidebarMenuItem>
                ))}
              </AnimatedSidebarMenu>
            </AnimatedSidebarGroupContent>
          </AnimatedSidebarGroup>
        </AnimatedSidebarContent>
        <AnimatedSidebarRail />
      </AnimatedSidebar>

      <AnimatedSidebarInset>
        <header className="flex h-14 items-center gap-2 border-b px-4">
          <AnimatedSidebarTrigger>
            <PanelLeft aria-hidden="true" className="size-4" />
          </AnimatedSidebarTrigger>
        </header>
        {children}
      </AnimatedSidebarInset>
    </AnimatedSidebarProvider>
  );
}

Add the sidebar to a Next.js App Router layout

The shell above is a client component because it reads usePathname to mark the active link and passes icons and handlers to the menu. Your layout can stay a Server Component and simply wrap its children in the shell, so the pages inside still render on the server.

AnimatedSidebarMenuButton renders a plain anchor when you pass href. For client-side navigation without a full page load, omit href and call router.push from onSelect instead, keeping in mind that a button does not support opening in a new tab.

TSXapp/(app)/layout.tsx
import type { ReactNode } from "react";
import { AppShell } from "@/components/app-shell";

export default function AppLayout({ children }: { children: ReactNode }) {
  return <AppShell>{children}</AppShell>;
}

Icon rail vs offcanvas: choosing a collapse mode

The collapsible prop on AnimatedSidebar decides what closing does on desktop. With icon, the default, the panel springs down to a narrow rail: labels fade out, icons stay, and each button gets the label as its accessible name and title. With offcanvas, the panel slides fully out of view and the inset content takes the whole width. With none, the sidebar stays open.

Widths come from CSS variables on the provider: --sidebar-width (16rem), --sidebar-width-icon (4.25rem) and --sidebar-width-mobile (18rem). Override them through the provider's style prop. AnimatedSidebar also takes side (left or right) and variant (sidebar, floating or inset).

  • Pass open, defaultOpen and onOpenChange to the provider to control or persist the desktop state, for example in a cookie.
  • Use AnimatedSidebarMenuSub with AnimatedSidebarMenuSubItem and AnimatedSidebarMenuSubButton for nested items. Selecting a group from the icon rail expands the panel so its children are reachable.
  • AnimatedSidebarRail adds a thin edge you can click to toggle the panel.
TSXnested-item.tsx
<AnimatedSidebarMenuItem>
  <AnimatedSidebarMenuButton
    icon={<Target className="size-4" />}
    ariaExpanded={open}
    onSelect={() => setOpen((value) => !value)}
  >
    Opportunities
  </AnimatedSidebarMenuButton>
  <AnimatedSidebarMenuSub open={open}>
    <AnimatedSidebarMenuSubItem>
      <AnimatedSidebarMenuSubButton href="/pipeline" isActive={pathname === "/pipeline"}>
        Pipeline
      </AnimatedSidebarMenuSubButton>
    </AnimatedSidebarMenuSubItem>
  </AnimatedSidebarMenuSub>
</AnimatedSidebarMenuItem>

Mobile sheet and keyboard shortcut

Below 768px the same AnimatedSidebar renders as a sheet in a portal on document.body: a dialog with aria-modal that slides in over a dimmed backdrop. Opening it locks page scroll and moves focus to the first focusable item; Tab and Shift+Tab stay inside the sheet, Escape or the backdrop closes it, and focus returns to the trigger. Selecting a regular menu item closes the sheet, while items with ariaExpanded keep it open so the submenu can unfold.

Cmd+B on macOS or Ctrl+B elsewhere calls toggleSidebar, which toggles the desktop panel or the mobile sheet depending on the viewport. Use AnimatedSidebarClose inside the panel for an explicit close button on mobile.

Accessibility and reduced motion for an animated sidebar

The desktop panel is an aside with an accessible name from ariaLabel, and AnimatedSidebarTrigger is a button with aria-expanded that reflects the current state. Active items get aria-current="page", and expandable items expose aria-expanded. The rail is skipped in the tab order because the trigger and the shortcut already cover keyboard users.

With reduced motion enabled, the panel width changes without the spring, labels switch to a short fade, the mobile sheet fades in place instead of sliding, the active highlight stops gliding between rows and press feedback is removed.

Components in this guide

FAQ

How do I make a collapsible sidebar in Next.js?
Wrap your app layout in a client component that renders AnimatedSidebarProvider, AnimatedSidebar and AnimatedSidebarInset, add an AnimatedSidebarTrigger in your header, and pick collapsible="icon" or "offcanvas".
Is the beUI Animated Sidebar compatible with shadcn/ui?
It installs through the shadcn CLI and follows the same composition model as the shadcn sidebar, with a provider, a panel, an inset and menu parts. It is its own component, so the part names start with AnimatedSidebar.
What keyboard shortcut toggles the sidebar?
Cmd+B on macOS and Ctrl+B on other platforms. It toggles the desktop panel, or the mobile sheet on small screens.
How do I remember whether the sidebar was collapsed?
Control the provider with open and onOpenChange, save the value in a cookie or local storage, and read it back as the initial value. Reading a cookie on the server avoids a flash of the wrong state.
Can the sidebar sit on the right side?
Yes. Pass side="right" to AnimatedSidebar. The mobile sheet then slides in from the right as well.

Updated