-

Resource Sidebar

PreviousNext

A collapsible sidebar shell for agent, dataset, database, and workflow detail pages.

Installation

pnpm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/resource-sidebar.json
npm
pnpm dlx shadcn@latest add https://ui.zgi.ai/r/resource-sidebar.json
yarn
yarn dlx shadcn@latest add https://ui.zgi.ai/r/resource-sidebar.json

Component

components/ui/resource-sidebar.tsx
"use client"

import * as React from "react"
import {
  ArrowLeftIcon,
  PanelLeftCloseIcon,
  PanelLeftOpenIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"

export interface ResourceSidebarNavItem {
  title: React.ReactNode
  href?: string
  icon?: React.ReactNode
  active?: boolean
  children?: ResourceSidebarNavItem[]
  onSelect?: () => void
}

export interface ResourceSidebarProps
  extends Omit<React.ComponentProps<"aside">, "title"> {
  collapsed?: boolean
  onCollapsedChange?: (collapsed: boolean) => void
  title?: React.ReactNode
  description?: React.ReactNode
  icon?: React.ReactNode
  backHref?: string
  backLabel?: React.ReactNode
  navItems?: ResourceSidebarNavItem[]
  footer?: React.ReactNode
  loading?: boolean
  expandedClassName?: string
}

function SidebarLink({
  item,
  collapsed,
  depth = 0,
}: {
  item: ResourceSidebarNavItem
  collapsed?: boolean
  depth?: number
}) {
  const isChild = depth > 0

  const content = (
    <>
      {item.active && !collapsed ? (
        <span
          className={cn(
            "absolute top-2 bottom-2 rounded-full",
            isChild ? "bg-primary/70 left-3 w-0.5" : "bg-primary left-0.5 w-1"
          )}
          aria-hidden="true"
        />
      ) : null}
      {item.icon ? (
        <span
          className={cn(
            "flex size-7 shrink-0 items-center justify-center rounded-md transition-colors [&_svg]:size-4",
            item.active
              ? "bg-primary/10 text-primary"
              : "text-muted-foreground group-hover/link:bg-muted group-hover/link:text-foreground",
            isChild && "size-5 bg-transparent"
          )}
        >
          {item.icon}
        </span>
      ) : null}
      <span
        className={cn(
          "truncate transition-all",
          collapsed ? "w-0 opacity-0" : "w-full opacity-100",
          isChild ? "text-xs" : "text-sm"
        )}
      >
        {item.title}
      </span>
    </>
  )
  const className = cn(
    "group/link relative flex min-h-9 items-center gap-2 rounded-md px-2.5 font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground",
    item.active && "bg-primary/5 text-foreground",
    collapsed && "w-8 justify-center px-0",
    isChild && !collapsed && "ml-5 min-h-8 pl-5 text-sm font-normal"
  )

  if (item.href) {
    return (
      <a href={item.href} className={className} onClick={item.onSelect}>
        {content}
      </a>
    )
  }

  return (
    <button type="button" className={className} onClick={item.onSelect}>
      {content}
    </button>
  )
}

export function ResourceSidebar({
  collapsed = false,
  onCollapsedChange,
  title,
  description,
  icon,
  backHref,
  backLabel = "Back",
  navItems = [],
  footer,
  loading = false,
  expandedClassName = "w-56",
  className,
  ...props
}: ResourceSidebarProps) {
  const ToggleIcon = collapsed ? PanelLeftOpenIcon : PanelLeftCloseIcon

  return (
    <aside
      className={cn(
        "bg-background relative flex h-full shrink-0 flex-col border-r transition-all",
        collapsed ? "w-12" : expandedClassName,
        className
      )}
      {...props}
    >
      <div className="border-b p-3">
        <div className="flex items-center justify-between gap-2">
          {backHref && !collapsed ? (
            <a
              href={backHref}
              className="text-muted-foreground hover:bg-muted hover:text-foreground inline-flex min-w-0 items-center gap-1 rounded-md px-1.5 py-1 text-xs transition-colors"
            >
              <ArrowLeftIcon className="size-3.5" />
              <span className="truncate">{backLabel}</span>
            </a>
          ) : (
            <span />
          )}
          <Button
            type="button"
            variant="ghost"
            size="icon"
            className="text-muted-foreground hover:text-foreground size-8 rounded-md"
            onClick={() => onCollapsedChange?.(!collapsed)}
            aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
          >
            <ToggleIcon className="size-4" />
          </Button>
        </div>

        <div
          className={cn(
            "mt-2 flex min-w-0 items-center gap-2",
            collapsed && "justify-center"
          )}
        >
          {loading ? (
            <Skeleton className="size-9 rounded-lg" />
          ) : (
            <div className="bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg">
              {icon}
            </div>
          )}
          <div className={cn("min-w-0 flex-1", collapsed && "hidden")}>
            {loading ? (
              <div className="space-y-1">
                <Skeleton className="h-3.5 w-24" />
                <Skeleton className="h-3 w-32" />
              </div>
            ) : (
              <>
                <div className="truncate text-sm font-semibold">{title}</div>
                {description ? (
                  <div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-5">
                    {description}
                  </div>
                ) : null}
              </>
            )}
          </div>
        </div>
      </div>

      <nav className="flex flex-1 flex-col gap-1 p-3">
        {navItems.map((item, index) => (
          <div key={index} className="space-y-1">
            <SidebarLink item={item} collapsed={collapsed} />
            {!collapsed && item.children?.length ? (
              <div className="border-border/70 ml-4 space-y-1 border-l py-1">
                {item.children.map((child, childIndex) => (
                  <SidebarLink key={childIndex} item={child} depth={1} />
                ))}
              </div>
            ) : null}
          </div>
        ))}
      </nav>

      {footer && !collapsed ? (
        <div className="border-t p-2">{footer}</div>
      ) : null}
    </aside>
  )
}

Usage

import { BotIcon } from "lucide-react"
 
import { ResourceSidebar } from "@/components/ui/resource-sidebar"
 
export function Example() {
  return (
    <ResourceSidebar
      title="Support Agent"
      icon={<BotIcon className="size-5" />}
      navItems={[{ title: "Overview", href: "#", active: true }]}
    />
  )
}

Props

PropTypeDefault
collapsedbooleanfalse
onCollapsedChange(collapsed: boolean) => void-
titleReactNode-
descriptionReactNode-
iconReactNode-
backHrefstring-
navItemsResourceSidebarNavItem[][]
footerReactNode-
loadingbooleanfalse