BitaKit
API reference

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.

App sidebar

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

example/actions/open.ts
import { defineAction } from '@bitakit/core';export default defineAction({  title: 'Your action',  href: '/example',  slots: ['shell.navigation'],});
Choose a destination: the highlighted sample Action moves and the authoring example changes. This is an illustrative preview; it does not navigate or mutate your app.

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.

NeedUse
Navigation and headerNo export (or 'app'): <AppShell>{children}</AppShell>
Shared page spacing, no navigation/headerexport const layout = 'full-page'
Sign-in, sign-up and similarexport const layout = 'auth': a centered column with shell.brand
Error pagesexport const layout = 'error'
Completely bare plugin pageexport const layout = 'blank'
A family of your ownsrc/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>.tsx is the variant default; src/layouts/<family>/<variant>.tsx adds variants, and a plugin keeps its layouts in ui/layouts/. npx bitakit add layout auth/split scaffolds 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.tsx replaces the whole frame of normal pages; compose AppShell.Sidebar, AppShell.TopBar and AppShell.Content so 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.

TargetContextWhere
shell.brandNoneSidebar header or navbar start
shell.header.start/center/endNoneMain header areas
shell.toolbarNoneStandard Action buttons/links before shell.header.end
app-bar.endNoneExisting trailing region, before shell.header.end
shell.header.rowsNoneRows below the header
shell.header.row.start/end{ rowId }A contributed HeaderRow
shell.navigationNoneDefault 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/afterNoneShared page surface
shell.footerNoneApp frame footer
shell.drawer.header/body/footer{ drawerId }A composed Drawer
shell.state.content{ surfaceId }, matching keyOptional replacement message
shell.state.actions{ surfaceId }Optional additional recovery UI
layout.app/auth/error/full-pagePage as children; singleThe frame of a layout family (replaceable)
screen.not-found/error/forbidden/loadingscreen.error: { reset }; singleShell'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.

On this page