shadcn-remix
GitHub

Sidebar

A composable, themeable and customizable sidebar component.

Platform
Projects
import { css, on, type Handle } from 'remix/component'

import { Avatar, AvatarFallback, AvatarImage } from '#app/ui/avatar.tsx'
import {
  Breadcrumb,
  BreadcrumbItem,
import { css, on, type Handle } from 'remix/component'

import { Avatar, AvatarFallback, AvatarImage } from '#app/ui/avatar.tsx'
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from '#app/ui/breadcrumb.tsx'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from '#app/ui/dropdown-menu.tsx'
import {
  AudioWaveformIcon,
  BadgeCheckIcon,
  BellIcon,
  BookOpenIcon,
  BotIcon,
  ChevronRightIcon,
  ChevronsUpDownIcon,
  CommandIcon,
  CreditCardIcon,
  FolderIcon,
  ForwardIcon,
  FrameIcon,
  GalleryVerticalEndIcon,
  LogOutIcon,
  MapIcon,
  MoreHorizontalIcon,
  PieChartIcon,
  PlusIcon,
  Settings2Icon,
  SparklesIcon,
  SquareTerminalIcon,
  Trash2Icon,
} from '#app/ui/icons.tsx'
import { Separator } from '#app/ui/separator.tsx'
import {
  getSidebar,
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSub,
  SidebarMenuSubButton,
  SidebarMenuSubItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  sidebarMenuActionVariants,
  sidebarMenuButtonVariants,
} from '#app/ui/sidebar.tsx'

type Icon = typeof FrameIcon

// This is sample data.
const data = {
  user: {
    name: 'shadcn',
    email: 'm@example.com',
    avatar: 'https://github.com/shadcn.png',
  },
  teams: [
    { name: 'Acme Inc', logo: GalleryVerticalEndIcon, plan: 'Enterprise' },
    { name: 'Acme Corp.', logo: AudioWaveformIcon, plan: 'Startup' },
    { name: 'Evil Corp.', logo: CommandIcon, plan: 'Free' },
  ],
  navMain: [
    {
      title: 'Playground',
      url: '#',
      icon: SquareTerminalIcon,
      isActive: true,
      items: [
        { title: 'History', url: '#' },
        { title: 'Starred', url: '#' },
        { title: 'Settings', url: '#' },
      ],
    },
    {
      title: 'Models',
      url: '#',
      icon: BotIcon,
      items: [
        { title: 'Genesis', url: '#' },
        { title: 'Explorer', url: '#' },
        { title: 'Quantum', url: '#' },
      ],
    },
    {
      title: 'Documentation',
      url: '#',
      icon: BookOpenIcon,
      items: [
        { title: 'Introduction', url: '#' },
        { title: 'Get Started', url: '#' },
        { title: 'Tutorials', url: '#' },
        { title: 'Changelog', url: '#' },
      ],
    },
    {
      title: 'Settings',
      url: '#',
      icon: Settings2Icon,
      items: [
        { title: 'General', url: '#' },
        { title: 'Team', url: '#' },
        { title: 'Billing', url: '#' },
        { title: 'Limits', url: '#' },
      ],
    },
  ],
  projects: [
    { name: 'Design Engineering', url: '#', icon: FrameIcon },
    { name: 'Sales & Marketing', url: '#', icon: PieChartIcon },
    { name: 'Travel', url: '#', icon: MapIcon },
  ],
}

// The block fills the viewport in shadcn's iframe preview. Here it is boxed to a fixed preview
// height: the provider is the positioning context and the sidebar container is absolute.
const providerStyles = css({
  position: 'relative',
  height: '40rem',
  minHeight: 0,
  overflow: 'hidden',
})
const sidebarContainerStyles = css({ position: 'absolute', height: '100%' })
const insetStyles = css({ overflowY: 'auto' })

const openMenuButton = css({
  '&[data-state="open"]': {
    backgroundColor: 'var(--sidebar-accent)',
    color: 'var(--sidebar-accent-foreground)',
  },
})
const teamLogoBox = css({
  display: 'flex',
  aspectRatio: '1 / 1',
  width: '2rem',
  height: '2rem',
  alignItems: 'center',
  justifyContent: 'center',
  borderRadius: 'var(--radius)',
  backgroundColor: 'var(--sidebar-primary)',
  color: 'var(--sidebar-primary-foreground)',
})
const size4 = css({ width: '1rem', height: '1rem' })
const size3_5 = css({ width: '0.875rem', height: '0.875rem', flexShrink: 0 })
const marginLeftAuto = css({ marginLeft: 'auto' })
const chevronUpDown = css({ marginLeft: 'auto', width: '1rem', height: '1rem' })
const twoLineText = css({
  display: 'grid',
  flex: '1 1 0%',
  textAlign: 'left',
  fontSize: '0.875rem',
  lineHeight: 1.25,
})
const truncateMedium = css({
  overflow: 'hidden',
  textOverflow: 'ellipsis',
  whiteSpace: 'nowrap',
  fontWeight: 500,
})
const truncateXs = css({
  overflow: 'hidden',
  textOverflow: 'ellipsis',
  whiteSpace: 'nowrap',
  fontSize: '0.75rem',
  lineHeight: 'calc(1 / 0.75)',
})
const triggerWidthContent = css({
  width: 'var(--radix-dropdown-menu-trigger-width)',
  minWidth: '14rem',
  borderRadius: 'var(--radius)',
})
const teamsLabel = css({
  fontSize: '0.75rem',
  lineHeight: 'calc(1 / 0.75)',
  color: 'var(--muted-foreground)',
})
const teamItem = css({ gap: '0.5rem', padding: '0.5rem' })
const teamItemLogo = css({
  display: 'flex',
  width: '1.5rem',
  height: '1.5rem',
  alignItems: 'center',
  justifyContent: 'center',
  borderRadius: 'calc(var(--radius) - 2px)',
  borderWidth: '1px',
})
const addTeamLogo = css({
  display: 'flex',
  width: '1.5rem',
  height: '1.5rem',
  alignItems: 'center',
  justifyContent: 'center',
  borderRadius: 'calc(var(--radius) - 2px)',
  borderWidth: '1px',
  backgroundColor: 'transparent',
})
const addTeamText = css({ fontWeight: 500, color: 'var(--muted-foreground)' })
const chevronRotate = css({
  marginLeft: 'auto',
  transitionProperty: 'transform, translate, scale, rotate',
  transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
  transitionDuration: '200ms',
  '[data-slot="collapsible"][data-state="open"] &': { rotate: '90deg' },
})
const hiddenWhenIcon = css({
  '[data-slot="sidebar"][data-collapsible="icon"] &': { display: 'none' },
})
const projectsContent = css({ width: '12rem', borderRadius: 'var(--radius)' })
const mutedIcon = css({ color: 'var(--muted-foreground)' })
const sidebarForeground70 = css({
  color: 'color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)',
})
const avatarRounded = css({ width: '2rem', height: '2rem', borderRadius: 'var(--radius)' })
const fallbackRounded = css({ borderRadius: 'var(--radius)' })
const userLabel = css({ padding: 0, fontWeight: 400 })
const userLabelRow = css({
  display: 'flex',
  alignItems: 'center',
  gap: '0.5rem',
  paddingInline: '0.25rem',
  paddingBlock: '0.375rem',
  textAlign: 'left',
  fontSize: '0.875rem',
  lineHeight: 'calc(1.25 / 0.875)',
})
const headerStyles = css({
  display: 'flex',
  height: '4rem',
  flexShrink: 0,
  alignItems: 'center',
  gap: '0.5rem',
  transitionProperty: 'width, height',
  transitionTimingFunction: 'linear',
  transitionDuration: '150ms',
  '[data-slot="sidebar-wrapper"]:has([data-collapsible="icon"]) &': { height: '3rem' },
})
const headerInner = css({
  display: 'flex',
  alignItems: 'center',
  gap: '0.5rem',
  paddingInline: '1rem',
})
const triggerOffset = css({ marginLeft: '-0.25rem' })
const verticalSeparator = css({
  marginRight: '0.5rem',
  '&[data-orientation="vertical"]': { height: '1rem' },
})
const hiddenUntilMd = css({
  display: 'none',
  '@media (min-width: 48rem)': { display: 'block' },
})
const pageBody = css({
  display: 'flex',
  flex: '1 1 0%',
  flexDirection: 'column',
  gap: '1rem',
  padding: '1rem',
  paddingTop: 0,
})
const cardGrid = css({
  display: 'grid',
  gridAutoRows: 'min-content',
  gap: '1rem',
  '@media (min-width: 48rem)': { gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' },
})
const card = css({
  aspectRatio: '16 / 9',
  borderRadius: 'calc(var(--radius) + 4px)',
  backgroundColor: 'color-mix(in oklab, var(--muted) 50%, transparent)',
})
const bigCard = css({
  minHeight: '100vh',
  flex: '1 1 0%',
  borderRadius: 'calc(var(--radius) + 4px)',
  backgroundColor: 'color-mix(in oklab, var(--muted) 50%, transparent)',
  '@media (min-width: 48rem)': { minHeight: 'min-content' },
})

function TeamSwitcher(handle: Handle<{ teams: typeof data.teams }>) {
  let sidebar = getSidebar(handle)
  let activeTeam = handle.props.teams[0]

  return () => {
    if (!activeTeam) return null
    let Logo = activeTeam.logo

    return (
      <SidebarMenu>
        <SidebarMenuItem>
          <DropdownMenu>
            <DropdownMenuTrigger
              data-slot="sidebar-menu-button"
              mix={[sidebarMenuButtonVariants({ size: 'lg' }), openMenuButton]}
            >
              <div mix={teamLogoBox}>
                <Logo mix={size4} />
              </div>
              <div mix={twoLineText}>
                <span mix={truncateMedium}>{activeTeam.name}</span>
                <span mix={truncateXs}>{activeTeam.plan}</span>
              </div>
              <ChevronsUpDownIcon mix={marginLeftAuto} />
            </DropdownMenuTrigger>
            <DropdownMenuContent
              mix={triggerWidthContent}
              align="start"
              side={sidebar.isMobile ? 'bottom' : 'right'}
              sideOffset={4}
            >
              <DropdownMenuLabel mix={teamsLabel}>Teams</DropdownMenuLabel>
              {handle.props.teams.map((team, index) => {
                let TeamLogo = team.logo
                return (
                  <DropdownMenuItem
                    key={team.name}
                    onSelect={() => {
                      activeTeam = team
                      void handle.update()
                    }}
                    mix={teamItem}
                  >
                    <div mix={teamItemLogo}>
                      <TeamLogo mix={size3_5} />
                    </div>
                    {team.name}
                    <DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
                  </DropdownMenuItem>
                )
              })}
              <DropdownMenuSeparator />
              <DropdownMenuItem mix={teamItem}>
                <div mix={addTeamLogo}>
                  <PlusIcon mix={size4} />
                </div>
                <div mix={addTeamText}>Add team</div>
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </SidebarMenuItem>
      </SidebarMenu>
    )
  }
}

interface NavMainEntry {
  title: string
  url: string
  icon?: Icon
  isActive?: boolean
  items?: Array<{ title: string; url: string }>
}

// shadcn wraps each item in a Radix `Collapsible`; this keeps the same open state and
// `data-state` attributes with local state.
function NavMainItem(handle: Handle<{ item: NavMainEntry }>) {
  let open = handle.props.item.isActive ?? false

  return () => {
    let { item } = handle.props
    let ItemIcon = item.icon
    let state = open ? 'open' : 'closed'

    return (
      <SidebarMenuItem data-slot="collapsible" data-state={state}>
        <SidebarMenuButton
          tooltip={item.title}
          aria-expanded={open}
          data-state={state}
          mix={on('click', () => {
            open = !open
            void handle.update()
          })}
        >
          {ItemIcon && <ItemIcon />}
          <span>{item.title}</span>
          <ChevronRightIcon mix={chevronRotate} />
        </SidebarMenuButton>
        {open && (
          <div data-slot="collapsible-content" data-state={state}>
            <SidebarMenuSub>
              {item.items?.map((subItem) => (
                <SidebarMenuSubItem key={subItem.title}>
                  <SidebarMenuSubButton href={subItem.url}>
                    <span>{subItem.title}</span>
                  </SidebarMenuSubButton>
                </SidebarMenuSubItem>
              ))}
            </SidebarMenuSub>
          </div>
        )}
      </SidebarMenuItem>
    )
  }
}

function NavMain(handle: Handle<{ items: NavMainEntry[] }>) {
  return () => (
    <SidebarGroup>
      <SidebarGroupLabel>Platform</SidebarGroupLabel>
      <SidebarMenu>
        {handle.props.items.map((item) => (
          <NavMainItem key={item.title} item={item} />
        ))}
      </SidebarMenu>
    </SidebarGroup>
  )
}

function NavProjects(handle: Handle<{ projects: typeof data.projects }>) {
  let sidebar = getSidebar(handle)

  return () => (
    <SidebarGroup mix={hiddenWhenIcon}>
      <SidebarGroupLabel>Projects</SidebarGroupLabel>
      <SidebarMenu>
        {handle.props.projects.map((item) => {
          let ItemIcon = item.icon
          return (
            <SidebarMenuItem key={item.name}>
              <SidebarMenuButton href={item.url}>
                <ItemIcon />
                <span>{item.name}</span>
              </SidebarMenuButton>
              <DropdownMenu>
                <DropdownMenuTrigger
                  data-slot="sidebar-menu-action"
                  mix={sidebarMenuActionVariants({ showOnHover: true })}
                >
                  <MoreHorizontalIcon />
                  <span class="sr-only">More</span>
                </DropdownMenuTrigger>
                <DropdownMenuContent
                  mix={projectsContent}
                  side={sidebar.isMobile ? 'bottom' : 'right'}
                  align={sidebar.isMobile ? 'end' : 'start'}
                >
                  <DropdownMenuItem>
                    <FolderIcon mix={mutedIcon} />
                    <span>View Project</span>
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <ForwardIcon mix={mutedIcon} />
                    <span>Share Project</span>
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem>
                    <Trash2Icon mix={mutedIcon} />
                    <span>Delete Project</span>
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </SidebarMenuItem>
          )
        })}
        <SidebarMenuItem>
          <SidebarMenuButton mix={sidebarForeground70}>
            <MoreHorizontalIcon mix={sidebarForeground70} />
            <span>More</span>
          </SidebarMenuButton>
        </SidebarMenuItem>
      </SidebarMenu>
    </SidebarGroup>
  )
}

function NavUser(handle: Handle<{ user: typeof data.user }>) {
  let sidebar = getSidebar(handle)

  return () => {
    let { user } = handle.props
    let identity = (
      <>
        <Avatar mix={avatarRounded}>
          <AvatarImage src={user.avatar} alt={user.name} />
          <AvatarFallback mix={fallbackRounded}>CN</AvatarFallback>
        </Avatar>
        <div mix={twoLineText}>
          <span mix={truncateMedium}>{user.name}</span>
          <span mix={truncateXs}>{user.email}</span>
        </div>
      </>
    )

    return (
      <SidebarMenu>
        <SidebarMenuItem>
          <DropdownMenu>
            <DropdownMenuTrigger
              data-slot="sidebar-menu-button"
              mix={[sidebarMenuButtonVariants({ size: 'lg' }), openMenuButton]}
            >
              {identity}
              <ChevronsUpDownIcon mix={chevronUpDown} />
            </DropdownMenuTrigger>
            <DropdownMenuContent
              mix={triggerWidthContent}
              side={sidebar.isMobile ? 'bottom' : 'right'}
              align="end"
              sideOffset={4}
            >
              <DropdownMenuLabel mix={userLabel}>
                <div mix={userLabelRow}>{identity}</div>
              </DropdownMenuLabel>
              <DropdownMenuSeparator />
              <DropdownMenuGroup>
                <DropdownMenuItem>
                  <SparklesIcon />
                  Upgrade to Pro
                </DropdownMenuItem>
              </DropdownMenuGroup>
              <DropdownMenuSeparator />
              <DropdownMenuGroup>
                <DropdownMenuItem>
                  <BadgeCheckIcon />
                  Account
                </DropdownMenuItem>
                <DropdownMenuItem>
                  <CreditCardIcon />
                  Billing
                </DropdownMenuItem>
                <DropdownMenuItem>
                  <BellIcon />
                  Notifications
                </DropdownMenuItem>
              </DropdownMenuGroup>
              <DropdownMenuSeparator />
              <DropdownMenuItem>
                <LogOutIcon />
                Log out
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </SidebarMenuItem>
      </SidebarMenu>
    )
  }
}

function AppSidebar() {
  return () => (
    <Sidebar collapsible="icon" mix={sidebarContainerStyles}>
      <SidebarHeader>
        <TeamSwitcher teams={data.teams} />
      </SidebarHeader>
      <SidebarContent>
        <NavMain items={data.navMain} />
        <NavProjects projects={data.projects} />
      </SidebarContent>
      <SidebarFooter>
        <NavUser user={data.user} />
      </SidebarFooter>
      <SidebarRail />
    </Sidebar>
  )
}

export function SidebarDemo() {
  return () => (
    <SidebarProvider mix={providerStyles}>
      <AppSidebar />
      <SidebarInset mix={insetStyles}>
        <header mix={headerStyles}>
          <div mix={headerInner}>
            <SidebarTrigger mix={triggerOffset} />
            <Separator orientation="vertical" mix={verticalSeparator} />
            <Breadcrumb>
              <BreadcrumbList>
                <BreadcrumbItem mix={hiddenUntilMd}>
                  <BreadcrumbLink href="#">Build Your Application</BreadcrumbLink>
                </BreadcrumbItem>
                <BreadcrumbSeparator mix={hiddenUntilMd} />
                <BreadcrumbItem>
                  <BreadcrumbPage>Data Fetching</BreadcrumbPage>
                </BreadcrumbItem>
              </BreadcrumbList>
            </Breadcrumb>
          </div>
        </header>
        <div mix={pageBody}>
          <div mix={cardGrid}>
            <div mix={card} />
            <div mix={card} />
            <div mix={card} />
          </div>
          <div mix={bigCard} />
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}

Installation

$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/sidebar.json

Usage

import { clientEntry, type Handle, type RemixNode } from 'remix/component'

import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarHeader,
  SidebarInset,
  SidebarProvider,
  SidebarTrigger,
} from '#app/ui/sidebar.tsx'
function AppSidebar() {
  return () => (
    <Sidebar>
      <SidebarHeader />
      <SidebarContent>
        <SidebarGroup />
        <SidebarGroup />
      </SidebarContent>
      <SidebarFooter />
    </Sidebar>
  )
}

export const Layout = clientEntry(import.meta.url, function Layout(handle: Handle<{ children?: RemixNode }>) {
  return () => (
    <SidebarProvider>
      <AppSidebar />
      <SidebarInset>
        <SidebarTrigger />
        {handle.props.children}
      </SidebarInset>
    </SidebarProvider>
  )
})

API Reference

SidebarProvider

PropTypeDefault
defaultOpenboolean-
openboolean-
onOpenChange((open: boolean) => void)-
mixMixValue-
PropTypeDefault
sideSidebarSide"left"
variantSidebarVariant"sidebar"
collapsibleSidebarCollapsible"offcanvas"
mixMixValue-

SidebarTrigger

PropTypeDefault
variantButtonVariant-
sizeButtonSize-
mixMixValue-

SidebarRail

PropTypeDefault
mixMixValue-

SidebarInset

PropTypeDefault
mixMixValue-

SidebarInput

PropTypeDefault
mixMixValue-

SidebarHeader

PropTypeDefault
mixMixValue-

SidebarFooter

PropTypeDefault
mixMixValue-

SidebarSeparator

PropTypeDefault
orientation"horizontal" | "vertical"-
decorativeboolean-
mixMixValue-

SidebarContent

PropTypeDefault
mixMixValue-

SidebarGroup

PropTypeDefault
mixMixValue-

SidebarGroupLabel

PropTypeDefault
mixMixValue-

SidebarGroupAction

PropTypeDefault
mixMixValue-

SidebarGroupContent

PropTypeDefault
mixMixValue-

SidebarMenu

PropTypeDefault
mixMixValue-

SidebarMenuItem

PropTypeDefault
mixMixValue-

SidebarMenuButton

PropTypeDefault
mixMixValue-
hrefstring-
targetstring-
relstring-
tooltipstring | TooltipContentProps-
variantSidebarMenuButtonVariant"default"
sizeSidebarMenuButtonSize"default"
isActivebooleanfalse

SidebarMenuAction

PropTypeDefault
showOnHoverbooleanfalse
mixMixValue-

SidebarMenuBadge

PropTypeDefault
mixMixValue-

SidebarMenuSkeleton

PropTypeDefault
showIconbooleanfalse
mixMixValue-

SidebarMenuSub

PropTypeDefault
mixMixValue-

SidebarMenuSubItem

PropTypeDefault
mixMixValue-

SidebarMenuSubButton

PropTypeDefault
sizeSidebarMenuSubButtonSize"md"
isActivebooleanfalse
mixMixValue-