Skip to contentVibraUI
Navigation & layout

Dashboard shell

The sidebar, header, and content frame every dashboard page sits in.

A client component: it renders the upstream SidebarProvider and marks the subtree as being inside a shell, which is how HeaderBar knows it can show its sidebar trigger. Children passed in from a server page stay server-rendered. SidebarInset is already the page's main landmark, so the content region below it is a plain div rather than a second main. contained frames a box on a page instead of the viewport — the whole shell shown as one band — through SidebarProvider's contained mode: the sidebar sits in the box rather than fixed to the window, the page region is no main at all (the page around the box has it), ⌘B / Ctrl+B toggles the rail only while focus is inside the shell, and the rail's state never reaches the sidebar_state cookie. The provider writes --sidebar-width as an inline style, which beats any class, so sidebarWidth (or style) is the only way to change it. Read defaultOpen from the sidebar_state cookie on the server to avoid a collapse flash on first paint. DashboardContent is exported for pages that skip the shell, and useDashboardShell for components that adapt to being inside one.

Install

npx shadcn@latest add @vibra/dashboard-shell

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
sidebarReact.ReactNode—Usually an AppSidebar; it sits beside the content and owns its own collapse.
headerReact.ReactNode—Usually a HeaderBar; it stays above the scrolling content.
childrenReact.ReactNode—The page itself, stacked with six units of space between blocks.
defaultOpenbooleantrueWhether the sidebar starts expanded.
sidebarWidthstring16remHow wide the expanded sidebar is; the collapsed icon rail keeps its own width.
classNamestring—Classes for the outer provider element, which owns the sidebar width variables.
styleReact.CSSProperties—Merged over the provider's own --sidebar-width and --sidebar-width-icon.
contentClassNamestring—Classes for the content region, for a different max width or padding.
containedbooleanfalseFrames a box on a page instead of the viewport: the sidebar is placed in the box, the page is no second main, and ⌘B works only with focus inside.
DashboardContentReact.ComponentProps<"div">—A max-width container with the shell's own padding and spacing, for pages outside the shell.
useDashboardShell() => { hasSidebar: boolean } | null—The shell around the calling component, or null when there is none.

Dependencies

Source

components/ui/dashboard-shell.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"

type DashboardShellContextValue = {
  /** Always true: the shell provides a SidebarProvider around everything inside it. */
  hasSidebar: boolean
}

const DashboardShellContext = React.createContext<DashboardShellContextValue | null>(null)

// Frozen module constant rather than a memo: nothing about it can change.
const SHELL_CONTEXT: DashboardShellContextValue = { hasSidebar: true }

/** The shell wrapped around this subtree, or null when there is none. */
function useDashboardShell(): DashboardShellContextValue | null {
  return React.useContext(DashboardShellContext)
}

export type DashboardShellProps = {
  /** Usually an AppSidebar; it sits beside the content and owns its own collapse. */
  sidebar: React.ReactNode
  /** Usually a HeaderBar; it stays above the scrolling content. */
  header?: React.ReactNode
  children: React.ReactNode
  /** Whether the sidebar starts expanded; read it from the sidebar_state cookie to avoid a flash. */
  defaultOpen?: boolean
  /**
   * How wide the expanded sidebar is, as a CSS length. The provider writes
   * --sidebar-width as an inline style, which beats any class, so this is the
   * way to change it; the collapsed icon rail keeps its own width.
   */
  sidebarWidth?: string
  className?: string
  /** Merged into the provider's own --sidebar-width and --sidebar-width-icon. */
  style?: React.CSSProperties
  /** Classes for the content region, e.g. a different max width or padding. */
  contentClassName?: string
  /**
   * Frames a box on a page instead of the viewport — the whole shell shown as
   * one band of a page. The sidebar sits in the box rather than being fixed to
   * the window, the page is a plain region rather than a second <main>, and
   * ⌘B / Ctrl+B only toggles the rail while focus is inside the shell. Give
   * the box its height through className, or let the page inside set it.
   */
  contained?: boolean
}

/** The frame every dashboard page sits in: sidebar, header, and the page body. */
function DashboardShell({
  sidebar,
  header,
  children,
  defaultOpen = true,
  sidebarWidth,
  className,
  style,
  contentClassName,
  contained = false,
}: DashboardShellProps) {
  return (
    <DashboardShellContext.Provider value={SHELL_CONTEXT}>
      <SidebarProvider
        data-slot="dashboard-shell"
        defaultOpen={defaultOpen}
        contained={contained}
        className={cn(className)}
        style={
          sidebarWidth
            ? ({ "--sidebar-width": sidebarWidth, ...style } as React.CSSProperties)
            : style
        }
      >
        {sidebar}
        {/* SidebarInset is itself the <main> landmark, so the content region below
            is a plain div — a page has one main, not two. (Contained, the inset
            is a plain region too: the page around the box has the main.)
            Beside an inset sidebar it is the page's sheet: white, ringed, a
            step off the sidebar's ground, with the bar and the content on one
            plane. */}
        <SidebarInset>
          {header}
          <div
            data-slot="dashboard-shell-content"
            className={cn("flex-1 space-y-6 p-4 md:p-6", contentClassName)}
          >
            {children}
          </div>
        </SidebarInset>
      </SidebarProvider>
    </DashboardShellContext.Provider>
  )
}

/** The same width and spacing as the shell's content region, for a page that skips the shell. */
function DashboardContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="dashboard-content"
      className={cn("mx-auto w-full max-w-7xl flex-1 space-y-6 p-4 md:p-6", className)}
      {...props}
    />
  )
}

export { DashboardContent, DashboardShell, DashboardShellContext, useDashboardShell }