Layouts and regions
Start with one header widget, then compose rows, drawers and full-page layouts.
These examples assume the Shell plugin is installed and the host renders <AppShell>.
Keep using the host's central components and theme.
Put an Action in the toolbar
Create src/plugins/notes/actions/add.ts:
import { defineAction } from '@bitakit/core';
export default defineAction({
title: 'Add note',
slots: ['shell.toolbar'],
execute: () => console.log('Add a note'),
});Shell renders a standard button in the trailing header area, before shell.header.end, in
both sidebar and navbar layouts. An Action with href renders as a native link. No widget
file is needed. This example logs a message; a real Action calls the feature's service.
1. Add one header widget
Create src/plugins/example/ui/slots/shell.header.end.tsx:
export default function ExampleLink() {
return <a href="/example">Your action</a>;
}The link appears at the end of the header, in both sidebar and navbar layouts.
To place it at the other side, use shell.header.start.tsx instead.
Your plugin page
The owner renders this content. Moving the sample Action does not change the page.
The built-in toolbar sits beside custom header-end content in the same row. For a separate full-width row below the header, use shell.header.rows. On narrow screens, scroll the header illustration sideways to see all regions.
Navigation
Place the URL Action directly. Shell renders its current title and href as a native navigation link.
shell.navigation · actions
import { defineAction } from '@bitakit/core';export default defineAction({ title: 'Your action', href: '/example', slots: ['shell.navigation'],});2. Add another header row
Create src/plugins/project/ui/slots/shell.header.rows.tsx:
import { AppShell } from '@bitakit/shell';
export default function ProjectRow() {
return <AppShell.HeaderRow id="project" start="Current project" />;
}A row is an ordinary ordered widget. Add export const order = 20 only if its order matters.
Each mounted row must have a distinct ID. An empty row renders nothing.
A second plugin can extend just this row:
// sync/ui/slots/shell.header.row.end.tsx
import type { SlotPredicate } from '@bitakit/core';
export const when: SlotPredicate<'shell.header.row.end'> = ({ payload }) =>
payload.rowId === 'project';
export default function SyncStatus() {
return <span>Saved</span>;
}3. Place navigation or an Action
For a native navigation item, add one property to a URL Action:
// counter/actions/open.ts
import { defineAction } from '@bitakit/core';
export default defineAction({
title: 'Counter',
href: '/counter',
slots: ['shell.navigation'],
});The URL Action becomes a native link in shell.navigation. Groups do not choose a UI location.
Component regions accept custom UI. Use the existing Action binding if that UI needs behavior:
// counter/ui/slots/shell.header.end.tsx
import { ActionButton } from '@bitakit/ui';
export default function Increment() {
return <ActionButton action="counter.increment" />;
}For a second sidebar group, define an Action slot (for example reports.navigation),
place Actions in it, then contribute this component to sidebar.body:
import { AppShell } from '@bitakit/shell';
export default function ReportsNavigation() {
return (
<AppShell.NavigationGroup id="reports" title="Reports" navigationSlot="reports.navigation" />
);
}This uses the same standard navigation renderer. The default group's ID is main.
4. Compose a drawer
import { AppShell } from '@bitakit/shell';
import { Button } from '@bitakit/ui/components';
<AppShell.Drawer id="activity" title="Activity" trigger={<Button>Activity</Button>}>
<p>Recent changes</p>
</AppShell.Drawer>;The existing Sheet owns keyboard focus, Escape, focus return and modality. side="start"
selects the opposite logical edge; pass dir="rtl" or use the Radix direction context for RTL.
Use one distinct id for each mounted drawer. Widgets receive { drawerId }:
// audit/ui/slots/shell.drawer.footer.tsx
import type { SlotPredicate } from '@bitakit/core';
export const when: SlotPredicate<'shell.drawer.footer'> = ({ payload }) =>
payload.drawerId === 'activity';
export default function AuditLink() {
return <a href="/audit">View audit</a>;
}Without when, the widget intentionally appears in every drawer outlet. IDs scope context;
they are not authorization. Protected content still belongs inside the owner's access boundary.
5. Choose a layout
A plugin page names a layout family with export const layout = '<family>'. Each family is
a replaceable part, the single slot layout.<family>: Shell renders its default until a layout
file replaces it.
| Need | Use |
|---|---|
| Navigation and header | No export (or 'app'): <AppShell>{children}</AppShell> |
| Shared page spacing, no navigation/header | export const layout = 'full-page' |
| Sign-in, sign-up and similar | export const layout = 'auth': a centered column with shell.brand |
| Error pages | export const layout = 'error' |
| Completely bare plugin page | export const layout = 'blank' |
| A family of your own | src/layouts/<family>.tsx and export const layout = '<family>' |
Replace a family's look with a layout file; the page arrives as children:
// src/layouts/auth/split.tsx — a variant of the auth family
import type { ReactNode } from 'react';
export default function AuthSplitLayout({ children }: { children?: ReactNode }) {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="hidden bg-primary lg:block" />
<div className="flex items-center justify-center p-6">
<div className="w-full max-w-sm">{children}</div>
</div>
</div>
);
}src/layouts/<family>.tsxis the variantdefault;src/layouts/<family>/<variant>.tsxadds variants, and a plugin keeps its layouts inui/layouts/.npx bitakit add layout auth/splitscaffolds one.- One candidate renders automatically; the app's own file beats plugins. With several, choose in
bitakit.config.ts:choose: { 'layout.auth': 'split' }. A tie renders Shell's default and doctor asks for a decision. src/layouts/app.tsxreplaces the whole frame of normal pages; composeAppShell.Sidebar,AppShell.TopBarandAppShell.Contentso navigation and header slots keep working.- Framework files pass the family:
<AppShell layout="error"><NotFoundPage /></AppShell>.
Native host routes compose the components directly. Layout selection does not replace the
router. Each layout renders children once. Full-page supports shell.content.before/after;
the other chrome regions have no outlet there. Attached content is not automatically visible in
every layout.
6. Change a shared part once
The built-in framed and full-page layouts both use ShellSurface. Host replacements can
share the same component too:
// ui/layouts/shared-surface.tsx — define outside render
import { ShellSurface } from '@bitakit/shell';
import type { ReactNode } from 'react';
export default function Surface({ children }: { children?: ReactNode }) {
return (
<ShellSurface>
<p>Workspace notice</p>
{children}
</ShellSurface>
);
}import Surface from './ui/layouts/shared-surface';
const parts = { Surface }; // stable identity
<AppShell components={parts}>{children}</AppShell>
<AppShell.FullPage surface={Surface}>{children}</AppShell.FullPage>These are alternative placements, not two wrappers around the same page. Existing Sidebar,
TopBar, Content and NavigationItem replacements remain available; Footer is replaceable too.
Replacing a part means owning its outlets. Keep the default part when adding a widget is enough.
Region reference
All rows below accept components, use ordered list policy and are owned by Shell,
except shell.navigation (Actions) and shell.state.content (keyed component selection).
Definitions use the normal defineSlot / programmatic / flat-or-nested discovery mechanism.
| Target | Context | Where |
|---|---|---|
shell.brand | None | Sidebar header or navbar start |
shell.header.start/center/end | None | Main header areas |
shell.toolbar | None | Standard Action buttons/links before shell.header.end |
app-bar.end | None | Existing trailing region, before shell.header.end |
shell.header.rows | None | Rows below the header |
shell.header.row.start/end | { rowId } | A contributed HeaderRow |
shell.navigation | None | Default navigation Actions |
shell.navigation.group.before/after | { groupId } | Existing sidebar groups |
sidebar.header/body/footer | { collapsed } | Sidebar sections; same sections inside its mobile drawer |
shell.content.before/after | None | Shared page surface |
shell.footer | None | App frame footer |
shell.drawer.header/body/footer | { drawerId } | A composed Drawer |
shell.state.content | { surfaceId }, matching key | Optional replacement message |
shell.state.actions | { surfaceId } | Optional additional recovery UI |
layout.app/auth/error/full-page | Page as children; single | The frame of a layout family (replaceable) |
screen.not-found/error/forbidden/loading | screen.error: { reset }; single | Shell's page states, everywhere they render (replaceable) |
Slash notation abbreviates separate slot IDs. Hiding/unmounting a region hides its contents; it does not stop the contributors. Empty optional rows/footer wrappers collapse. Dotted names do not create DOM nesting: the owning component chooses which child outlet to mount.
7. Replace a screen
NotFoundPage, ErrorPage, AccessDeniedPage and LoadingPage render the single slots
screen.not-found, screen.error, screen.forbidden and screen.loading. A contribution
replaces the whole page state wherever it appears (the catch-all route, error.tsx,
not-found.tsx), including call-site titles:
// src/plugins/brand/ui/slots/screen.error.tsx
'use client';
import type { SlotProps } from '@bitakit/ui';
import { Button } from '@bitakit/ui/components';
import { Empty, EmptyContent, EmptyHeader, EmptyTitle } from '@bitakit/shell/pages';
export default function ErrorScreen({ payload }: SlotProps<'screen.error'>) {
return (
<Empty>
<EmptyHeader>
<EmptyTitle>That did not work</EmptyTitle>
</EmptyHeader>
{payload.reset && (
<EmptyContent>
<Button onClick={payload.reset}>Try again</Button>
</EmptyContent>
)}
</Empty>
);
}A replacement that throws renders the default instead. Outside a runtime the defaults render unchanged. To change only the message of one occurrence, use the surface extensions below.
8. Extend an empty or error surface
Start with host-owned recovery that works even without BitaKit:
import { ErrorPage } from '@bitakit/shell/pages';
<ErrorPage reset={retry}>
<a href="/">Home</a>
</ErrorPage>;Set content={<YourMessage />} to replace the message through composition. Add surfaceId="report"
only when plugin extensions are wanted. The Shell's optional shell.state.actions widget can
then filter payload.surfaceId === 'report'. A shell.state.content widget also exports
key = 'report' to select this occurrence. Duplicate keys are errors, not implicit overrides.
The default message and host retry survive absent/stopped runtimes and failing extensions. The optional content can disappear when its owner stops. Keep essential recovery in the host, never solely inside the failing plugin. Access-denied surfaces must not reveal protected extensions.
Settings owns its own region map: see Settings extensions.