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>
    </>
  );
}

Compose your own

These styled parts are included in the same install. Use children to build your layout; add className only when you want an override.

Scroll horizontally for long lines
"use client";
import {
  ApplicationShellRoot,
  ApplicationShellNavigation,
  ApplicationShellWorkspace,
} from "@/components/jez-ui/blocks/application-shell";

export default function Example() {
  return (
    <ApplicationShellRoot>
      <ApplicationShellNavigation />
      <ApplicationShellWorkspace />
    </ApplicationShellRoot>
  );
}
Scroll horizontally for long lines
import { ApplicationShell, ApplicationShellRoot, ApplicationShellAside, ApplicationShellContent, ApplicationShellItem, ApplicationShellNavigation, ApplicationShellWorkspace } from "@/components/jez-ui/blocks/application-shell";
Read the composition guide →

Props & types

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

export type ApplicationShellProps = Omit<
  React.ComponentProps<"div">,
  keyof ApplicationShellOptions
> &
  ApplicationShellOptions;

ApplicationShellProps & { composition?: React.ReactNode }

Source

View 2 source files

registry/blocks/application-shell.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Slot } from "radix-ui";
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 type ApplicationShellOptions = {
  children: React.ReactNode;
  brand?: string;
  items?: { label: string; href: string }[];
  className?: string;
  currentPath?: string;
};
export type ApplicationShellProps = Omit<
  React.ComponentProps<"div">,
  keyof ApplicationShellOptions
> &
  ApplicationShellOptions;
function useApplicationShellModel({
  children,
  brand = "Workspace",
  items = [
    { label: "Overview", href: "#overview" },
    { label: "Projects", href: "#projects" },
    { label: "Settings", href: "#settings" },
  ],
  className,
  currentPath,
  ...rootProps
}: ApplicationShellProps) {
  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 {
    children,
    brand,
    items,
    className,
    currentPath,
    rootProps,
    path,
    setPath,
    search,
    setSearch,
    collapsed,
    setCollapsed,
    mobileOpen,
    setMobileOpen,
    cleanPath,
    active,
    page,
  };
}
const ApplicationShellCompositionContext = React.createContext<ReturnType<
  typeof useApplicationShellModel
> | null>(null);
function useApplicationShellComposition() {
  const context = React.useContext(ApplicationShellCompositionContext);
  if (!context)
    throw new Error("ApplicationShell parts must be inside ApplicationShell.");
  return context;
}
export function ApplicationShell(
  props: ApplicationShellProps & { composition?: React.ReactNode },
) {
  const { composition, ...modelProps } = props;
  const model = useApplicationShellModel(modelProps);
  const { className, rootProps, collapsed } = model;
  return (
    <ApplicationShellCompositionContext.Provider value={model}>
      <div
        {...rootProps}
        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,
        )}
      >
        {composition !== undefined ? (
          composition
        ) : (
          <>
            <ApplicationShellNavigation />
            <ApplicationShellWorkspace />
          </>
        )}
      </div>
    </ApplicationShellCompositionContext.Provider>
  );
}
export function ApplicationShellRoot({
  children,
  ...props
}: Omit<
  React.ComponentProps<typeof ApplicationShell>,
  "children" | "composition"
> & { children?: React.ReactNode }) {
  return <ApplicationShell {...props} children={null} composition={children} />;
}

export function ApplicationShellAside({
  className,
  ...props
}: React.ComponentProps<"aside">) {
  return (
    <aside
      data-slot="application-shell-aside"
      className={cn(
        "flex flex-col md:sticky md:top-2 md:h-[calc(100vh-16px)]",
        className,
      )}
      {...props}
    />
  );
}
export function ApplicationShellContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="application-shell-content"
      className={cn(
        "min-w-0 bg-background md:rounded-xl md:border md:border-border md:shadow-xs",
        className,
      )}
      {...props}
    />
  );
}

export function ApplicationShellItem({
  className,
  asChild,
  ...props
}: React.ComponentProps<"a"> & { asChild?: boolean }) {
  const Comp = asChild ? Slot.Root : "a";
  return (
    <Comp
      data-slot="application-shell-item"
      className={cn(
        "flex items-center gap-2.5 whitespace-nowrap rounded-md px-2.5 py-2 text-sm transition-colors",
        className,
      )}
      {...props}
    />
  );
}

export function ApplicationShellNavigation({
  children,
  ...props
}: Partial<React.ComponentProps<typeof ApplicationShellAside>> & {
  children?: React.ReactNode;
}) {
  const {
    brand,
    items,
    search,
    setSearch,
    collapsed,
    mobileOpen,
    setMobileOpen,
    active,
  } = useApplicationShellComposition();
  return (
    <ApplicationShellAside {...props}>
      {children === undefined ? (
        <>
          <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 (
                  <ApplicationShellItem
                    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(
                      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"
                          />
                        )}
                      </>
                    )}
                  </ApplicationShellItem>
                );
              })}
            {!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>
        </>
      ) : (
        children
      )}
    </ApplicationShellAside>
  );
}
export function ApplicationShellWorkspace({
  children,
  ...props
}: Partial<React.ComponentProps<typeof ApplicationShellContent>> & {
  children?: React.ReactNode;
}) {
  const { children: modelChildren } = useApplicationShellComposition();
  const { brand, collapsed, setCollapsed, page } =
    useApplicationShellComposition();
  return (
    <ApplicationShellContent {...props}>
      {children === undefined ? (
        <>
          <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">
            {modelChildren}
          </main>
        </>
      ) : (
        children
      )}
    </ApplicationShellContent>
  );
}

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

radix-ui@1.6.7 · 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 →