Skip to content
Blocks

Application Shell

A responsive workspace with current-page navigation, page search, and a collapsible sidebar.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/application-shell.json

Use it

Scroll horizontally for long lines
"use client";
import {ApplicationShell} from '@/components/jez-ui/blocks/application-shell';
import {TaskList} from '@/components/jez-ui/blocks/task-list';
import {ActivityFeed} from '@/components/jez-ui/blocks/activity-feed';

export default function Example(){

return <><ApplicationShell brand="Acme" currentPath="#overview"><div className="mb-7"><p className="mb-2 text-xs text-muted-foreground">Tuesday, 8 September</p><h2 className="font-display text-2xl">Good morning, Alex.</h2><p className="mt-2 text-sm text-muted-foreground">Here’s what needs your attention.</p></div><div className="grid gap-6 xl:grid-cols-[1.3fr_1fr]"><div><h3 className="mb-4 text-sm font-semibold">Your tasks</h3><TaskList/></div><ActivityFeed/></div></ApplicationShell></>;
}

Props & types

Scroll horizontally for long lines
{
  children: React.ReactNode;
  brand?: string;
  items?: { label: string; href: string }[];
  className?: string;
  currentPath?: string;
}

Source

View 2 source files

registry/blocks/application-shell.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import {
  Search,
  LayoutDashboard,
  FolderOpen,
  Settings,
  Users,
  MessageSquare,
  ChartNoAxesCombined,
  PanelLeftClose,
  PanelLeftOpen,
  ListTodo,
  Library,
  Menu,
  X,
  ChevronRight,
} from "lucide-react";
import { cn } from "../ui/utils";
const icons: Record<string, typeof Search> = {
  Overview: LayoutDashboard,
  Projects: FolderOpen,
  Settings,
  Team: Users,
  Customers: Users,
  Reports: ChartNoAxesCombined,
  Board: LayoutDashboard,
  List: ListTodo,
  Library,
  Menu,
  X,
  ChevronRight,
  "Conversation Demo": MessageSquare,
};
export function ApplicationShell({
  children,
  brand = "Workspace",
  items = [
    { label: "Overview", href: "#overview" },
    { label: "Projects", href: "#projects" },
    { label: "Settings", href: "#settings" },
  ],
  className,
  currentPath,
}: {
  children: React.ReactNode;
  brand?: string;
  items?: { label: string; href: string }[];
  className?: string;
  currentPath?: string;
}) {
  const [path, setPath] = React.useState(currentPath ?? "");
  const [search, setSearch] = React.useState("");
  const [collapsed, setCollapsed] = React.useState(false);
  const [mobileOpen, setMobileOpen] = React.useState(false);
  React.useEffect(() => {
    if (currentPath !== undefined) {
      setPath(currentPath);
      return;
    }
    const update = () =>
      setPath(window.location.pathname + window.location.hash);
    update();
    window.addEventListener("popstate", update);
    window.addEventListener("hashchange", update);
    return () => {
      window.removeEventListener("popstate", update);
      window.removeEventListener("hashchange", update);
    };
  }, [currentPath]);
  const cleanPath = path.replace(/\/$/, "");
  const active = items
    .filter(
      (i) =>
        (i.href.startsWith("#") && path.endsWith(i.href)) ||
        cleanPath === i.href.replace(/\/$/, "") ||
        (i.href !== items[0]?.href &&
          cleanPath.startsWith(i.href.replace(/\/$/, "") + "/")),
    )
    .sort((a, b) => b.href.length - a.href.length)[0];
  const page = active?.label ?? items[0]?.label ?? brand;
  return (
    <div
      className={cn(
        "min-h-screen bg-muted/45 md:grid md:p-2 md:pl-0",
        collapsed
          ? "md:grid-cols-[72px_minmax(0,1fr)]"
          : "md:grid-cols-[224px_minmax(0,1fr)]",
        className,
      )}
    >
      <aside className="flex flex-col md:sticky md:top-2 md:h-[calc(100vh-16px)]">
        <div className="flex h-16 items-center justify-between gap-2 px-4">
          <a
            href={items[0]?.href}
            aria-label={brand}
            className="flex min-w-0 items-center gap-2.5"
          >
            <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-foreground font-display text-sm text-background">
              {brand.slice(0, 1)}
            </span>
            {!collapsed && (
              <span className="truncate text-sm font-semibold">{brand}</span>
            )}
          </a>
          <button
            type="button"
            aria-label={mobileOpen ? "Close navigation" : "Open navigation"}
            aria-expanded={mobileOpen}
            onClick={() => setMobileOpen((v) => !v)}
            className="rounded-md p-2 hover:bg-muted md:hidden"
          >
            {mobileOpen ? <X size={18} /> : <Menu size={18} />}
          </button>
        </div>
        {!collapsed && (
          <label
            className={cn(
              "mx-3 mb-5 items-center gap-2 rounded-lg border border-border bg-background px-2.5 text-muted-foreground",
              mobileOpen ? "flex" : "hidden md:flex",
            )}
          >
            <Search size={14} />
            <input
              aria-label="Find a page"
              value={search}
              onChange={(e) => setSearch(e.target.value)}
              placeholder="Find a page…"
              className="min-w-0 flex-1 bg-transparent py-2 text-xs text-foreground"
            />
          </label>
        )}
        {!collapsed && (
          <p
            className={cn(
              "mb-2 px-5 text-xs font-medium text-muted-foreground",
              mobileOpen ? "block" : "hidden md:block",
            )}
          >
            Workspace
          </p>
        )}
        <nav
          aria-label="Workspace"
          className={cn(
            "gap-1 px-3 pb-3 md:grid md:content-start",
            mobileOpen ? "grid" : "hidden",
          )}
        >
          {items
            .filter(
              (i) =>
                collapsed ||
                i.label.toLowerCase().includes(search.toLowerCase()),
            )
            .map((i) => {
              const Icon = icons[i.label] ?? FolderOpen;
              const selected = active?.href === i.href;
              return (
                <a
                  key={i.href}
                  href={i.href}
                  title={collapsed ? i.label : undefined}
                  onClick={() => setMobileOpen(false)}
                  aria-label={i.label}
                  aria-current={selected ? "page" : undefined}
                  className={cn(
                    "flex items-center gap-2.5 whitespace-nowrap rounded-md px-2.5 py-2 text-sm transition-colors",
                    selected
                      ? "bg-background font-medium text-foreground shadow-xs ring-1 ring-border/60"
                      : "text-muted-foreground hover:bg-muted hover:text-foreground",
                    collapsed && "justify-center",
                  )}
                >
                  <Icon size={16} strokeWidth={1.7} />
                  {!collapsed && (
                    <>
                      <span className="flex-1">{i.label}</span>
                      {selected && (
                        <ChevronRight
                          size={13}
                          className="text-muted-foreground"
                        />
                      )}
                    </>
                  )}
                </a>
              );
            })}
          {!collapsed &&
            !items.some((i) =>
              i.label.toLowerCase().includes(search.toLowerCase()),
            ) && (
              <p className="px-2 py-3 text-xs text-muted-foreground">
                No matching pages.
              </p>
            )}
        </nav>
        <div className="mt-auto hidden p-3 md:block">
          {!collapsed && (
            <div className="flex items-center gap-2.5 rounded-lg border border-border bg-background/50 p-3">
              <span className="grid size-7 place-items-center rounded-full bg-muted text-xs">
                AM
              </span>
              <div>
                <p className="text-xs font-medium">Alex Morgan</p>
                <p className="mt-0.5 text-xs text-muted-foreground">
                  Personal account
                </p>
              </div>
            </div>
          )}
        </div>
      </aside>
      <div className="min-w-0 bg-background md:rounded-xl md:border md:border-border md:shadow-xs">
        <header className="flex h-14 items-center justify-between gap-4 border-b border-border px-5 md:px-7">
          <div className="flex items-center gap-3">
            <button
              type="button"
              onClick={() => setCollapsed((v) => !v)}
              aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
              className="hidden rounded-md p-1.5 text-muted-foreground hover:bg-muted md:block"
            >
              {collapsed ? (
                <PanelLeftOpen size={16} />
              ) : (
                <PanelLeftClose size={16} />
              )}
            </button>
            <p className="text-xs">
              <span className="text-muted-foreground">
                {brand}
                <span className="mx-3 text-border">/</span>
              </span>
              {page}
            </p>
          </div>
          <span className="hidden items-center gap-1.5 text-xs text-muted-foreground sm:flex">
            <span className="size-1.5 rounded-full bg-primary" />
            Personal workspace
          </span>
        </header>
        <main className="mx-auto min-w-0 max-w-[1440px] p-5 md:p-7">
          {children}
        </main>
      </div>
    </div>
  );
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

Dependencies

lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0

Accessibility & behaviour

Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.

Read the accessibility and performance guide →