App Shell
Page frame for the chat starter: the sidebar, a phone drawer, and a titled content card, carrying the starter's own navigation so the sidebar only links to pages the app has.
Live preview from the library's source
Install
From BoardUI
npx shadcn@latest add https://www.boardui.com/r/app-shell.jsonFrom this collection
npx shadcn@latest add https://allcomps.vercel.app/r/boardui--app-shell.jsonCode
"use client";
import { useTemplateCopy } from "@/components/foundations/template-copy/template-copy";
import {
RiAddFill,
RiChatAiLine,
RiCloseLine,
RiDashboardLine,
RiFilter3Fill,
RiLayoutGridLine,
RiLoginBoxLine,
RiMenuLine,
RiUserAddLine,
} from "@remixicon/react";
import type { ComponentType } from "react";
import { usePathname } from "next/navigation";
import { useState, type ReactNode } from "react";
import {
DashboardSidebar,
type DashboardNavItem,
} from "@/components/application/dashboard/dashboard-sidebar";
import { NotificationBell } from "@/components/application/app-shell/notification-bell";
import { ProOfferCard } from "@/components/application/app-shell/pro-offer-card";
import { Avatar } from "@/components/base/avatar/avatar";
import { Breadcrumb, BreadcrumbItem } from "@/components/base/breadcrumb/breadcrumb";
import { Button } from "@/components/base/buttons/button";
import { IconButton } from "@/components/base/buttons/icon-button";
import { cx } from "@/utils/cx";
/**
* The starter's page frame: the sidebar, a drawer for phones, and a titled
* content card. Every page of the starter except the chat renders inside it,
* and the chat shares its navigation, so the sidebar only ever links to pages
* the app actually has.
*
* On boardui.com the starter lives under /templates/chat-starter; in a
* deployed copy it is the root. The base is read off the URL rather than
* configured, so the same files run in both places untouched.
*/
export const STARTER_SITE_PREFIX = "/templates/chat-starter";
// Re-exported so the catalogue ships with the shell; the starter's pages are
// copied into the public repo by path and can only import what a registry
// item carries.
export {
CatalogTierFilter,
ComponentsCatalog,
type CatalogTier,
} from "@/components/application/app-shell/components-catalog";
/** "" in a deployed starter, the site prefix on boardui.com. */
export function useStarterBase(): string {
const pathname = usePathname() ?? "";
// Preserve an optional language prefix on the hosted preview. Installed
// starters still use their normal root routes.
return pathname.match(/^((?:\/[a-z]{2}(?:-[a-zA-Z]{2})?)?\/templates\/chat-starter)(?:\/|$)/)?.[1] ?? "";
}
export function starterNav(base: string): DashboardNavItem[] {
return [
{ key: "chat", label: "Chat", icon: RiChatAiLine, href: base || "/" },
{ key: "dashboard", label: "Dashboard", icon: RiDashboardLine, href: `${base}/dashboard` },
{ key: "components", label: "Components and Blocks", icon: RiLayoutGridLine, href: `${base}/components` },
// Auth screens, as examples: full-page, so nothing is selected while on them.
{ key: "login", label: "Sign in", icon: RiLoginBoxLine, href: `${base}/login` },
{ key: "signup", label: "Sign up", icon: RiUserAddLine, href: `${base}/signup` },
];
}
/** The nav key of the page at the current URL. */
export function useStarterSelected(): string {
const pathname = usePathname() ?? "";
const base = useStarterBase();
const rest = pathname.slice(base.length);
if (rest.startsWith("/dashboard")) return "dashboard";
if (rest.startsWith("/components")) return "components";
return "chat";
}
type IconComponent = ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
/**
* The page standard every BoardUI template follows, free or Pro: content sits
* directly on the page background inside a 1300px column, so cards and tables
* carry their own surfaces; above it a breadcrumb trail (team, member, page)
* and the page heading. Pages pass what differs and get the rest.
*/
export function AppShell({
title,
heading = title,
icon: Icon = RiDashboardLine,
actions,
children,
className,
columnClassName,
}: {
/** The page's name, shown as the current breadcrumb item. */
title: string;
/** The heading over the content. Defaults to the title. */
heading?: string;
/** Icon on the current breadcrumb item. */
icon?: IconComponent;
/** Header actions on the right; the Pro dashboard's set by default, `null` for none. */
actions?: ReactNode;
children: ReactNode;
className?: string;
/** Overrides the 1300px content column, e.g. `max-w-[964px]` for the catalogue's grids. */
columnClassName?: string;
}) {
const localize = useTemplateCopy();
const [navOpen, setNavOpen] = useState(false);
const items = starterNav(useStarterBase());
const selected = useStarterSelected();
return localize((
<div
className={cx(
"relative flex h-dvh w-full gap-4 overflow-hidden bg-background-full p-3",
className,
)}
>
<DashboardSidebar items={items} selected={selected} className="hidden lg:flex" />
{/* Below lg the sidebar rides in as an overlay drawer. */}
{navOpen && (
<div className="fixed inset-0 z-50 flex lg:hidden">
<button
type="button"
aria-label="Close navigation"
onClick={() => setNavOpen(false)}
className="absolute inset-0 cursor-pointer bg-black/40"
/>
<div className="relative flex h-full p-3">
<DashboardSidebar
mobile
items={items}
selected={selected}
onClose={() => setNavOpen(false)}
className="flex"
/>
</div>
</div>
)}
<main className="relative flex min-h-0 min-w-0 flex-1 justify-center overflow-x-hidden overflow-y-auto bg-background-full sm:pt-3">
<div className={cx("flex w-full max-w-[1300px] flex-col gap-2.5", columnClassName)}>
<header className="flex w-full flex-col gap-2">
<Breadcrumb>
<BreadcrumbItem href="#">
<Avatar size="xs" color="blue" initials="B" />
Board team
</BreadcrumbItem>
<BreadcrumbItem href="#">
<Avatar size="xs" color="neutral" initials="M" />
Mertcan
</BreadcrumbItem>
<BreadcrumbItem current icon={Icon}>
{title}
</BreadcrumbItem>
</Breadcrumb>
<div className="flex w-full flex-wrap items-end justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<IconButton
icon={navOpen ? RiCloseLine : RiMenuLine}
size="medium"
aria-label="Open navigation"
onClick={() => setNavOpen((open) => !open)}
className="lg:hidden"
/>
<h1 className="px-1 text-title-2-medium whitespace-nowrap text-text-primary">
{heading}
</h1>
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
{actions !== undefined ? actions : (
<>
<NotificationBell />
<Button variant="secondary" size="medium" leadingIcon={RiFilter3Fill}>
Filters
</Button>
<Button variant="primary" size="medium" leadingIcon={RiAddFill}>
Create ticket
</Button>
</>
)}
</div>
</div>
</header>
<div className="flex w-full flex-col gap-4 pb-4">{children}</div>
</div>
</main>
<ProOfferCard />
</div>
));
}
Other dashboards
The same kind of component from the other libraries in the collection.