# App shell

> A workspace app's frame: a header with Command menu on Cmd+K, pages for home, projects and an inbox, and Floating nav to move between them.

A shadcn/ui block from the [@fibo](https://fibo.toribryan.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/fibo/app-shell).

- Page, with a live preview: https://shadcn.exchange/r/fibo/app-shell
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/fibo/app-shell-light-0fe676dcda40afd8.png
- Registry item: https://fibo.toribryan.com/r/app-shell.json
- Categories: blocks, shells
- Listed: 2026-10-17, source last changed 2026-10-17

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/fibo/app-shell.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `https://fibo.toribryan.com/r/avatar.json`, `https://fibo.toribryan.com/r/command-menu.json`, `https://fibo.toribryan.com/r/floating-nav.json`, `utils`, `https://fibo.toribryan.com/r/badge.json`, `https://fibo.toribryan.com/r/button.json`, `https://fibo.toribryan.com/r/empty-state.json`, `https://fibo.toribryan.com/r/kbd.json`, `https://fibo.toribryan.com/r/table.json`

## Notes from the registry

Installed into components/blocks/app-shell. Render <AppShell /> where the screen goes, then edit it: it's yours. Docs: https://fibo.toribryan.com/?path=/docs/blocks-app-shell--docs

## Files

### `registry/blocks/app-shell/app-shell.tsx`

```tsx
"use client"

import * as React from "react"
import {
  CheckCheckIcon,
  FolderIcon,
  FolderOpenIcon,
  HouseIcon,
  InboxIcon,
  PlusIcon,
} from "lucide-react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
  CommandMenu,
  type CommandMenuGroup,
} from "@/components/ui/command-menu"
import { FloatingNav } from "@/components/ui/floating-nav"
import { cn } from "@/lib/utils"

import {
  messages as initialMessages,
  projects as initialProjects,
  user,
  workspace,
  type Page,
  type Project,
} from "./data"
import { HomePage, InboxPage, ProjectsPage } from "./pages"

const NAV = [
  { value: "home", label: "Home", icon: <HouseIcon /> },
  { value: "projects", label: "Projects", icon: <FolderIcon /> },
  { value: "inbox", label: "Inbox", icon: <InboxIcon /> },
] satisfies { value: Page; label: string; icon: React.ReactNode }[]

type AppShellProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** Where Command menu portals to. Defaults to the body. */
  container?: HTMLElement | null
}

/**
 * A workspace app's frame: a header with Command menu, the current page, and
 * Floating nav to move between pages. Every command changes real state here,
 * so wiring it to your data means replacing the setters below.
 */
function AppShell({ container, className, ...props }: AppShellProps) {
  const [page, setPage] = React.useState<Page>("home")
  const [projects, setProjects] = React.useState(initialProjects)
  const [messages, setMessages] = React.useState(initialMessages)
  const [highlighted, setHighlighted] = React.useState<string>()

  const openProject = (id: string) => {
    setHighlighted(id)
    setPage("projects")
  }

  const createProject = () => {
    const project: Project = {
      id: `untitled-${projects.length + 1}`,
      name: "Untitled project",
      status: "active",
      owner: { name: user.name, initials: user.initials },
      updated: "Just now",
    }
    setProjects((current) => [project, ...current])
    openProject(project.id)
  }

  const groups: CommandMenuGroup[] = [
    {
      label: "Go to",
      items: NAV.map((item) => ({
        value: `go-${item.value}`,
        label: item.label,
        icon: item.icon,
        onSelect: () => setPage(item.value),
      })),
    },
    {
      label: "Projects",
      items: [
        {
          value: "new-project",
          label: "New project",
          icon: <PlusIcon />,
          keywords: ["create", "add"],
          onSelect: createProject,
        },
        {
          value: "open-project",
          label: "Open project",
          icon: <FolderOpenIcon />,
          placeholder: "Search projects…",
          items: projects.map((project) => ({
            value: `open-${project.id}`,
            label: project.name,
            keywords: [project.owner.name],
            onSelect: () => openProject(project.id),
          })),
        },
      ],
    },
    {
      label: "Inbox",
      items: [
        {
          value: "mark-all-read",
          label: "Mark all as read",
          icon: <CheckCheckIcon />,
          disabled: messages.length === 0,
          onSelect: () => {
            setMessages([])
            setPage("inbox")
          },
        },
      ],
    },
  ]

  return (
    <div
      data-slot="app-shell"
      className={cn(
        "@container/app-shell flex min-h-svh flex-col bg-background text-foreground",
        className
      )}
      {...props}
    >
      <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background px-4 @2xl/app-shell:px-8">
        <span className="flex min-w-0 items-center gap-2 text-sm font-semibold">
          <span
            aria-hidden="true"
            className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary text-xs text-primary-foreground"
          >
            {workspace.initials}
          </span>
          {/* Below md the search needs the room; the name stays for screen readers. */}
          <span className="sr-only truncate @md/app-shell:not-sr-only">
            {workspace.name}
          </span>
        </span>
        <div className="ml-auto flex items-center gap-3">
          <CommandMenu
            groups={groups}
            container={container}
            placeholder="Search or run a command…"
          />
          <Avatar size="sm">
            <AvatarFallback>{user.initials}</AvatarFallback>
          </Avatar>
        </div>
      </header>

      <main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-4 pt-8 pb-28 @2xl/app-shell:px-8">
        {page === "home" ? (
          <HomePage
            firstName={user.firstName}
            projects={projects}
            onOpenProject={openProject}
          />
        ) : page === "projects" ? (
          <ProjectsPage
            projects={projects}
            highlighted={highlighted}
            onCreate={createProject}
          />
        ) : (
          <InboxPage
            messages={messages}
            onMarkAllRead={() => setMessages([])}
          />
        )}
      </main>

      {/* Sticky rather than fixed, so the nav stays inside the shell wherever
          it's mounted, and the main padding keeps the last row clear of it. */}
      <div className="pointer-events-none sticky bottom-4 flex justify-center px-4">
        <FloatingNav
          items={NAV}
          value={page}
          onValueChange={(value) => setPage(value as Page)}
          position="static"
          aria-label="Pages"
        />
      </div>
    </div>
  )
}

export { AppShell }
export type { AppShellProps }
```

### `registry/blocks/app-shell/data.ts`

```ts
// Placeholder content for the App shell block. Swap these for your own data;
// the types are what the pages read.

type Page = "home" | "projects" | "inbox"

type ProjectStatus = "active" | "review" | "paused"

type Project = {
  id: string
  name: string
  status: ProjectStatus
  owner: { name: string; initials: string }
  updated: string
}

type Message = {
  id: string
  from: { name: string; initials: string }
  subject: string
  received: string
}

const workspace = { name: "Northwind", initials: "N" }

const user = { name: "Ada Lovelace", firstName: "Ada", initials: "AL" }

const projects: Project[] = [
  {
    id: "atlas",
    name: "Atlas redesign",
    status: "active",
    owner: { name: "Grace Hopper", initials: "GH" },
    updated: "2 hours ago",
  },
  {
    id: "beacon",
    name: "Beacon onboarding",
    status: "review",
    owner: { name: "Ada Lovelace", initials: "AL" },
    updated: "Yesterday",
  },
  {
    id: "cinder",
    name: "Cinder billing",
    status: "paused",
    owner: { name: "Alan Turing", initials: "AT" },
    updated: "Last week",
  },
]

const messages: Message[] = [
  {
    id: "m1",
    from: { name: "Grace Hopper", initials: "GH" },
    subject: "Atlas: final nav spec is ready for review",
    received: "9:41",
  },
  {
    id: "m2",
    from: { name: "Alan Turing", initials: "AT" },
    subject: "Cinder is paused until the pricing call",
    received: "Yesterday",
  },
  {
    id: "m3",
    from: { name: "Katherine Johnson", initials: "KJ" },
    subject: "Beacon copy notes",
    received: "Monday",
  },
]

export { messages, projects, user, workspace }
export type { Message, Page, Project, ProjectStatus }
```

### `registry/blocks/app-shell/pages.tsx`

```tsx
import * as React from "react"
import {
  ArrowRightIcon,
  CheckCheckIcon,
  InboxIcon,
  PlusIcon,
} from "lucide-react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateMedia,
  EmptyStateTitle,
} from "@/components/ui/empty-state"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

import type { Message, Project, ProjectStatus } from "./data"

const STATUS: Record<
  ProjectStatus,
  { label: string; variant: "success" | "info" | "secondary" }
> = {
  active: { label: "Active", variant: "success" },
  review: { label: "In review", variant: "info" },
  paused: { label: "Paused", variant: "secondary" },
}

const noSubscription = () => () => {}

// Matches the key Command menu listens for: Cmd on Apple devices, Ctrl
// elsewhere. The server snapshot keeps the first render hydration-safe.
function useIsMac() {
  return React.useSyncExternalStore(
    noSubscription,
    () => /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent),
    () => false
  )
}

function PageHeader({
  title,
  description,
  action,
}: {
  title: string
  description: string
  action?: React.ReactNode
}) {
  return (
    <div className="flex flex-col gap-4 @lg/app-shell:flex-row @lg/app-shell:items-end">
      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <h1 className="m-0 text-2xl font-semibold tracking-tight text-foreground">
          {title}
        </h1>
        <p className="m-0 text-sm text-muted-foreground">{description}</p>
      </div>
      {action}
    </div>
  )
}

function HomePage({
  firstName,
  projects,
  onOpenProject,
}: {
  firstName: string
  projects: Project[]
  onOpenProject: (id: string) => void
}) {
  const mac = useIsMac()
  return (
    <div className="flex flex-col gap-8">
      <PageHeader
        title={`Welcome back, ${firstName}`}
        description="Pick up where you left off."
      />
      <section
        aria-labelledby="app-shell-recent"
        className="flex flex-col gap-3"
      >
        <h2
          id="app-shell-recent"
          className="m-0 text-sm font-medium text-muted-foreground"
        >
          Jump back in
        </h2>
        <div className="grid gap-3 @lg/app-shell:grid-cols-3">
          {projects.slice(0, 3).map((project) => (
            <button
              key={project.id}
              type="button"
              onClick={() => onOpenProject(project.id)}
              className="group flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-ring focus-visible:ring-[3px] focus-visible:ring-ring-subtle focus-visible:outline-none"
            >
              <span className="flex items-center gap-2">
                <span className="min-w-0 flex-1 truncate text-sm font-medium text-card-foreground">
                  {project.name}
                </span>
                <ArrowRightIcon
                  aria-hidden="true"
                  className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
                />
              </span>
              <span className="text-xs text-muted-foreground">
                Updated {project.updated.toLowerCase()}
              </span>
            </button>
          ))}
        </div>
      </section>
      <p className="m-0 flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
        Press
        <KbdGroup>
          <Kbd>{mac ? "⌘" : "Ctrl"}</Kbd>
          <Kbd>K</Kbd>
        </KbdGroup>
        to search, open a project or create one.
      </p>
    </div>
  )
}

function ProjectsPage({
  projects,
  highlighted,
  onCreate,
}: {
  projects: Project[]
  /** The project opened last, marked so the eye lands on it. */
  highlighted?: string
  onCreate: () => void
}) {
  return (
    <div className="flex flex-col gap-6">
      <PageHeader
        title="Projects"
        description={`${projects.length} projects in this workspace.`}
        action={
          <Button onClick={onCreate}>
            <PlusIcon data-icon="inline-start" />
            New project
          </Button>
        }
      />
      <div className="overflow-hidden rounded-xl border border-border">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Name</TableHead>
              <TableHead>Status</TableHead>
              <TableHead className="hidden @xl/app-shell:table-cell">
                Owner
              </TableHead>
              <TableHead className="hidden text-right @xl/app-shell:table-cell">
                Updated
              </TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {projects.map((project) => (
              <TableRow
                key={project.id}
                data-state={project.id === highlighted ? "selected" : undefined}
              >
                <TableCell className="font-medium">{project.name}</TableCell>
                <TableCell>
                  <Badge variant={STATUS[project.status].variant}>
                    {STATUS[project.status].label}
                  </Badge>
                </TableCell>
                <TableCell className="hidden @xl/app-shell:table-cell">
                  <span className="flex items-center gap-2">
                    <Avatar size="sm">
                      <AvatarFallback>{project.owner.initials}</AvatarFallback>
                    </Avatar>
                    {project.owner.name}
                  </span>
                </TableCell>
                <TableCell className="hidden text-right text-muted-foreground @xl/app-shell:table-cell">
                  {project.updated}
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  )
}

function InboxPage({
  messages,
  onMarkAllRead,
}: {
  messages: Message[]
  onMarkAllRead: () => void
}) {
  return (
    <div className="flex flex-col gap-6">
      <PageHeader
        title="Inbox"
        description={
          messages.length
            ? `${messages.length} unread`
            : "Nothing new since you last looked."
        }
        action={
          messages.length ? (
            <Button variant="outline" onClick={onMarkAllRead}>
              <CheckCheckIcon data-icon="inline-start" />
              Mark all as read
            </Button>
          ) : null
        }
      />
      {messages.length ? (
        <ul className="m-0 flex list-none flex-col divide-y divide-border rounded-xl border border-border p-0">
          {messages.map((message) => (
            <li key={message.id} className="flex items-center gap-3 px-4 py-3">
              <Avatar>
                <AvatarFallback>{message.from.initials}</AvatarFallback>
              </Avatar>
              <span className="flex min-w-0 flex-1 flex-col">
                <span className="truncate text-sm font-medium text-foreground">
                  {message.subject}
                </span>
                <span className="truncate text-xs text-muted-foreground">
                  {message.from.name}
                </span>
              </span>
              <span className="shrink-0 text-xs text-muted-foreground">
                {message.received}
              </span>
            </li>
          ))}
        </ul>
      ) : (
        <EmptyState className="rounded-xl border border-dashed border-border py-12">
          <EmptyStateMedia>
            <InboxIcon />
          </EmptyStateMedia>
          <EmptyStateTitle>{"You're all caught up"}</EmptyStateTitle>
          <EmptyStateDescription>
            New messages from your projects land here.
          </EmptyStateDescription>
        </EmptyState>
      )}
    </div>
  )
}

export { HomePage, InboxPage, ProjectsPage }
```
