Ansora UI
Blocks

Ansora app shell

Application skeleton on top of the sidebar — collapsible navigation with a brand and a user block, a top bar with the page title, breadcrumbs and an actions slot, a skip link, and a slot for the content. A server component; the only client parts are the sidebar itself, deriving the active nav item from the path, and the user menu.

blocknavigation

Install

pnpm dlx shadcn@4.23.0 add @ansora/app-shell

Examples

This item has no fixture: it needs a whole page around it (a router, a layout cookie) to render. Run `pnpm demo` to see it inside a real consumer app.

Usage

Call readSidebarState() from @/lib/ansora/sidebar-state in a server layout and pass the result to AppShell as defaultOpen — without it the sidebar re-expands on every navigation.

Props: defaultOpen and nav are required; brand, navSecondary, user, userMenu, title, breadcrumbs and headerActions are optional and each one is the only way to fill in its part of the skeleton.

  • brand ({ label, icon?, href? }) is the row at the top of the sidebar column. Without it the column starts with the navigation.
  • user ({ name, email, initials }) draws the footer block; without it no footer is rendered at all. userMenu (NavItem[]) is what its dropdown contains — without it the block is inert. The initials are yours to supply: deriving them from a Czech name with a title ("MUDr. Jana Krejčí") would produce "MJ".
  • title renders as the page's <h1> in the top bar. Breadcrumbs do not replace it; with both filled in the bar shows the title, then a separator, then the breadcrumbs. The last breadcrumb always renders as plain text, even when it has an href — it is the current page, not a link to it.
  • headerActions is the right-hand side of the top bar. This is where <DashboardActions /> from @ansora/dashboard goes.

nav and breadcrumbs are pure data and the shell knows no icon library: icon on a nav item is a ReactNode, not an icon name (Phosphor, regular weight; switching the active item to fill is up to you, the shell only ever sees a finished node).

Two things the shell already renders, so that you do not add a second one:

  • A skip link as the first focusable element on the page. Its target is the container around children, which therefore carries the id ansora-app-shell-content — that id is taken, do not reuse it.
  • The sidebar trigger, in the top bar rather than in the sidebar column. It has to be there: below 768px the sidebar renders as a Sheet that starts closed, and a closed dialog mounts no children, so a trigger inside <Sidebar> would not exist in the DOM on a phone and nothing would open the navigation.
import { AppShell, type NavGroup } from "@/components/ansora/app-shell"
import { readSidebarState } from "@/lib/ansora/sidebar-state"

const nav: NavGroup[] = [
  { label: "Provoz", items: [{ href: "/", label: "Přehled", icon: <HouseIcon /> }] },
]

export default async function Layout({ children }: { children: React.ReactNode }) {
  return (
    <AppShell
      defaultOpen={await readSidebarState()}
      brand={{ label: "Ambulance Vinohrady", icon: <HeartbeatIcon />, href: "/" }}
      nav={nav}
      user={{ name: "MUDr. Jana Krejčí", email: "j.krejci@ambulance.cz", initials: "JK" }}
      userMenu={[{ href: "/ucet", label: "Účet", icon: <UserCircleIcon /> }]}
      title="Přehled"
      headerActions={<DashboardActions />}
    >
      {children}
    </AppShell>
  )
}

Files

Dependencies

  • @ansora/sidebar
  • https://ui.shadcn.com/r/styles/base-nova/avatar.json
  • https://ui.shadcn.com/r/styles/base-nova/dropdown-menu.json
  • https://ui.shadcn.com/r/styles/base-nova/separator.json

On this page