# Layout patterns

> AppShell, ModuleRoot, SplitPane, PageHeader, DataTable, FloatingDock and Island — the props each takes and the decision each one makes for you.

Page: https://sdk.chatfuel.com/docs/reference/ui/patterns
Markdown: https://sdk.chatfuel.com/docs/reference/ui/patterns.md

Every layout below makes one decision for you and stays out of the rest, so a module composes them
and never re-derives the same answer in its own markup; the sizes and colours they reach for are
[tokens](/docs/reference/ui/tokens).

| Layout                | Where it lives                    | Use it for                                                                                            |
| --------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `AppShell` + `Topbar` | The app, around every module      | The frame: a top bar, a nav on the left, and a hamburger plus a drawer below the collapse breakpoint. |
| `NavRail`             | Inside `AppShell`'s `nav` slot    | A flat list of modules, icon-only or expanded.                                                        |
| `SideNav`             | Inside `AppShell`'s `nav` slot    | The same list with a group level above it — a rail of group icons and a hover flyout.                 |
| `ModuleRoot`          | The outermost element of a module | The container, the observed node, the gutter scope and the focus target. Every module starts here.    |
| `SplitPane`           | Inside a module                   | Master and detail: a list beside a thread, a nav beside a canvas.                                     |
| `PageHeader`          | Inside a module, at the top       | The module's own title bar, with meta, actions and an optional tab strip.                             |
| `DataTable`           | Inside a module                   | Rows: sorting, selection, resizing, reordering, inline editing.                                       |
| `FloatingDock`        | Over a canvas                     | A window that stays open while the scene behind it stays whole.                                       |
| `Island`              | Over a canvas                     | The elevated surface every floating toolbar, palette and zoom widget is built from.                   |

## The frame [#the-frame]

`AppShell` is slots, not data: it knows nothing about bots or modules, so what goes in the top bar
is the app's business and what goes in the nav is a component you choose.

<TypeTable
  type="{
  topbar: { description: 'Top bar content — use Topbar or roll your own.', type: 'ReactNode' },
  children: { description: 'The active module.', type: 'ReactNode' },
  nav: { description: 'Left nav rail. Omit for a single-module layout.', type: 'ReactNode', default: 'none' },
  navDrawer: {
    description:
      'The nav as it should look inside the drawer, usually the same items with variant=&#x22;expanded&#x22;. Two explicit slots rather than cloning: the shell should not rewrite someone else’s props.',
    type: 'ReactNode',
    default: 'the nav slot',
  },
  fill: {
    description:
      'Own the viewport height (a standalone app) or fill the host’s box (an embed). h-dvh rather than h-screen, because 100vh on mobile Safari is the height the viewport would have with the address bar hidden.',
    type: 'boolean',
    default: 'true',
  },
  navCollapsedBelow: {
    description:
      'Below this viewport width the rail becomes a hamburger that opens the nav in a Drawer. This is one of the two sanctioned viewport questions in the system: when the shell IS the top-level app, the viewport genuinely is its container.',
    type: &#x22;'sm' | 'md' | 'lg' | 'never'&#x22;,
    default: &#x22;'md'&#x22;,
  },
}"
/>

`Topbar` says where you are working, not what you are looking at. Fill at least one slot — an empty
bar is still a topbar's worth of border and background, so it reads as a chrome bug rather than as
restraint.

| Prop        | Type        | What goes in it                                                                                                                                                                            |
| ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `brand`     | `ReactNode` | The product's mark. Leftmost, because it is the one thing that never changes while the app is open. A node, not a `src`: the design system does not know where an app keeps its assets.    |
| `workspace` | `ReactNode` | Which account the session is pointed at — a bot label, a workspace name, a picker. The bar's primary content, and never hidden at narrow widths, where the nav rail has already collapsed. |
| `title`     | `string`    | The product's name, if the app wants one in the chrome. Not the active module's name: the rail and the module's own `PageHeader` already carry that.                                       |
| `right`     | `ReactNode` | Session-level controls: theme toggle, account menu. Never module actions.                                                                                                                  |

`NavRail` takes `items` (`id`, `title`, `icon`), `activeId` and `onSelect`. `variant="rail"` is the
icon-only strip with titles as tooltips and its own right-hand border; `variant="expanded"` adds
labels, fills its host and draws no edge, because that host is a drawer which already has one.

`SideNav` is the same contract one level up: `groups` of `{ id, title, icon, items }`, the active
**item** id, and `onSelect`. The group level is information architecture, not a route — nothing
below the component knows groups exist, and the address bar still says `/<moduleId>`. It renders
nothing for zero groups and falls back to a flat `NavRail` for one, because a rail of a single icon
hiding everything behind a hover is worse than the flat rail it replaced. In `rail` the items open
as a flyout that survives the pointer leaving it for 180 ms, which is the diagonal-move grace.

## ModuleRoot [#moduleroot]

The outermost element of every module, and the thing that makes the rest of the layout system work.
It takes `children` and an optional `className`, forwards its ref to the outer element, and does
four jobs at once.

<Accordions>
  <Accordion title="It is the container">
    `@container/module`, so every `@compact:` / `@wide:` / `@inline:` class anywhere below resolves
    against the module's box rather than the viewport. That is the whole embed argument: a module can
    be 700px wide inside a 2560px screen.
  </Accordion>

  <Accordion title="It is the observed node">
    The band comes from a `ResizeObserver` on this same element, and that equality is the point.
    Observe a canvas instead and a detail panel opening beside it narrows the canvas, which flips the
    band, which closes the panel, which widens the canvas — forever. Here the component owns the ref,
    so attaching it to the wrong element is structurally impossible.
  </Accordion>

  <Accordion title="It scopes the fluid gutter">
    `[data-band-scope]` goes on the inner element, because a container element cannot match its own
    container query. `tokens.css` re-assigns `--spacing-gutter` there, which retunes every `p-gutter`,
    `px-gutter` and `gap-gutter` already written below it — no responsive classes, no call-site edits.
  </Accordion>

  <Accordion title="It takes focus when it mounts">
    `useHotkeys` fires only while focus is inside the module root or nowhere at all, which is what
    keeps a host's own keyboard working in an embed. Without this, arriving from a menu left focus on
    the menu item and every module shortcut was dead until something inside the module was clicked. It
    stands down while somebody is typing.
  </Accordion>
</Accordions>

`useBand()` reads the band from a child — never in `ModuleRoot` itself, where the provider is still
only a return value. It answers `'compact'` below 600px, `'narrow'` below 900px, `'wide'` below
1280px and `'inline'` at or above it; outside a `ModuleRoot` it answers `'wide'`, so a component in a
gallery cell or a test gets the ordinary desktop layout rather than throwing. `bandAtLeast(band,
'wide')` is the comparison.

The rule for which tool answers a layout question is one line: if the answer changes what React
renders, ask the band; if it only changes how something looks, ask a container variant; never ask
the viewport from module code.

## SplitPane [#splitpane]

Master and detail — two panes above the collapse band, one pane plus a back control below it.

<TypeTable
  type="{
  side: { description: 'The master pane: a conversation list, a scope nav, a flow picker.', type: 'ReactNode' },
  children: { description: 'The detail pane: a thread, a canvas, a settings body.', type: 'ReactNode' },
  sideLabel: { description: 'Names the region, and labels the back control when the panes are stacked.', type: 'string' },
  sideWidth: {
    description:
      'Which --width-* token sizes the side pane: rail is w-rail, sidenav is w-sidenav, list is w-list, inspector is w-inspector.',
    type: &#x22;'rail' | 'sidenav' | 'list' | 'inspector'&#x22;,
    default: &#x22;'list'&#x22;,
  },
  collapseBelow: {
    description:
      &#x22;Below this band the two panes stack into one. 'never' keeps them side by side at every width — correct only when the side pane is a rail.&#x22;,
    type: &#x22;Band | 'never'&#x22;,
    default: &#x22;'wide'&#x22;,
  },
  showing: {
    description:
      'Which pane is visible while stacked. Controlled, because the module already knows whether something is selected and deriving it here would fight a deep link that opens straight into the detail pane.',
    type: &#x22;'side' | 'detail'&#x22;,
    default: &#x22;'side'&#x22;,
  },
  onShowingChange: { description: 'Fired by the back control.', type: &#x22;(next: 'side' | 'detail') => void&#x22;, default: 'none' },
}"
/>

Stacking is a JS decision rather than a container query on purpose: it changes which pane is in the
DOM and adds a control that does not otherwise exist. CSS can only restyle what is already
rendered — it cannot make a back button appear, and hiding the other pane in CSS would keep
mounting, measuring and subscribing for a list nobody can see.

## PageHeader [#pageheader]

The module's own top bar: `title` (a node, when it carries a live dot or a count), `meta` beside it,
`actions` on the right, an optional `tabs` row and a `className`.

It is `min-h-topbar` with `flex-wrap`, not a fixed height: at desktop widths the two are identical,
but a fixed height in a narrow container squashes the actions into each other instead of letting
them drop to a second line. The padding is the gutter token, so the header steps with the band
without knowing the band exists.

`tabs` is a slot of its own because an underline tab strip carries its own baseline rule. Put in
`actions`, that rule floats a few pixels above the header's own bottom border and draws two parallel
lines. On its own full-width row, a `-mb-px` pulls the header's border up by exactly the pixel the
strip occupies, and the two land on one continuous edge.

## DataTable [#datatable]

Fully controlled, deliberately: rows are server-paged and merged from subscriptions, so any sort or
selection the table held internally would be overwritten by the next live echo. The owner holds the
state and `lib/data/table.ts` computes the next one. Two things are not controlled, because they die
with the interaction rather than surviving a page of rows: which cell is open for editing, and where
the cell cursor sits.

<TypeTable
  type="{
  columns: { description: 'See DataTableColumn below.', type: 'DataTableColumn<T>[]' },
  rows: { description: 'The rows as the owner holds them.', type: 'T[]' },
  rowKey: { description: 'A stable id per row.', type: '(row: T) => string' },
  empty: { description: 'Rendered when rows is empty and nothing is loading.', type: 'ReactNode', default: 'none' },
  density: { description: 'Row height and cell padding: h-row-compact, -cozy or -comfortable.', type: &#x22;'compact' | 'cozy' | 'comfortable'&#x22;, default: &#x22;'cozy'&#x22; },
  stickyHeader: { description: 'The header stays put while the scroll container moves.', type: 'boolean', default: 'false' },
  pinFirstColumn: { description: 'Freeze the leading column and the checkbox with it. Pinning only a checkbox would freeze the one column nobody needs to read.', type: 'boolean', default: 'false' },
  sort: { description: 'The current sort, or null.', type: 'SortState | null', default: 'null' },
  onSortChange: { description: 'A sortable header was activated.', type: '(sort: SortState | null) => void', default: 'none' },
  selectedIds: { description: 'Passing this and onSelectionChange together is what turns selection on.', type: 'readonly string[]', default: 'none' },
  onSelectionChange: { description: 'The next selection.', type: '(ids: string[]) => void', default: 'none' },
  columnWidths: { description: 'Per-key widths in px, from an interactive resize.', type: 'Readonly<Record<string, number>>', default: 'none' },
  onColumnWidthsChange: { description: 'Persist a resize.', type: '(widths: Record<string, number>) => void', default: 'none' },
  hiddenColumns: { description: 'Keys to leave out.', type: 'readonly string[]', default: 'none' },
  onColumnOrderChange: {
    description:
      'Turns header drag and its keyboard twin on. Receives the FULL key order, hidden columns included and back in the slots they held — the caller persists one list, so handing it only what was on screen would drop every hidden column.',
    type: '(keys: string[]) => void',
    default: 'none',
  },
  loading: { description: 'Render skeleton rows instead of the empty state.', type: 'boolean', default: 'false' },
  skeletonRows: { description: 'How many.', type: 'number', default: '6' },
  rowActions: { description: 'Trailing cell, revealed on row hover or keyboard focus.', type: '(row: T) => ReactNode', default: 'none' },
  isRowDisabled: { description: 'Restricted records: not selectable, not clickable, not editable, still counted.', type: '(row: T) => boolean', default: 'none' },
  onRowClick: { description: 'Open a row.', type: '(row: T) => void', default: 'none' },
  onRowContextMenu: { description: 'Right-click a row. The handler owns preventDefault.', type: '(row: T, event: MouseEvent) => void', default: 'none' },
  rowNavigation: {
    description:
      'Rows become a single tab stop with arrow-key navigation: Enter opens, Space toggles selection. Off by default — a table of five settings rows does not want to swallow the arrow keys.',
    type: 'boolean',
    default: 'false',
  },
  caption: { description: 'Accessible description of what the table holds.', type: 'string', default: 'none' },
}"
/>

A column is `{ key, header, render? }` plus the optional capabilities: `width` and `minWidth`
(the resize floor, 64px), `align`, `sortable`, `resizable`, `wrap` for a cell holding a control or a
stacked message, and `reorderable`. `reorderable` is the odd one out and opts a column **out**:
sorting and resizing are per-column capabilities, while reordering is a property of the table, and
the exception is the pinned identity column everything else is read against.

### The inline-edit contract [#the-inline-edit-contract]

`column.edit` turns a cell into an editor. The table holds the draft, not you — that is what lets
Escape restore the original, lets blur ask whether anything actually changed, and lets Enter carry a
half-typed value into the cell below without a round trip through the owner's state, which at 500
rows would re-render the whole body on every keystroke.

| Field         | Type                                                  | What it does                                                                                                                                                                                                                                                      |
| ------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `value`       | `(row: T) => string`                                  | The value the editor opens with.                                                                                                                                                                                                                                  |
| `commit`      | `(row: T, value: string) => void \| Promise<unknown>` | Save. Called only when the draft differs from what the editor opened with, because blur commits and every cell somebody merely tabs through would otherwise fire a mutation. Return a promise and the cell shows pending, then a tick or the rejection's message. |
| `render`      | `(context: DataTableEditorContext<T>) => ReactNode`   | A custom editor. The default is a single-line text input.                                                                                                                                                                                                         |
| `enabled`     | `(row: T) => boolean`                                 | Per-row veto — a computed field, an attribute this record does not carry.                                                                                                                                                                                         |
| `placeholder` | `string`                                              | For the default input.                                                                                                                                                                                                                                            |

A custom editor receives `row`, `column`, the draft `value` and `setValue`, `commit` and `cancel`
(already wired to Enter, Tab, blur and Escape), a `status`, and `inputProps`. Spread `inputProps`
onto the editor's own focusable element — a native input, select or textarea, not a wrapper. An
editor that drops it still edits, but Enter, Tab and Escape stop working, and its `onFocus` is what
tells the table which editor is live, so a commit already made by a key is not made again by the
blur that follows. `commit` takes an optional value because a `<select>` needs it: the change event
carries the chosen option, and `setValue` then `commit()` in one handler would commit the value from
before the change.

## FloatingDock [#floatingdock]

A floating window over a canvas — a test chat, a notes pane, anything that has to stay open while
the scene behind it stays whole. The alternative is a third column, and a canvas narrowed twice is
no longer the thing being edited; a window costs the scene the rectangle it covers and nothing else.

<TypeTable
  type="{
  open: { description: 'Expanded, or a pill in the corner. The session behind it survives either way.', type: 'boolean' },
  onOpenChange: { description: 'The collapse button, and Escape from inside the panel.', type: '(open: boolean) => void' },
  title: { description: &#x22;The header's own line — a name, a glyph, a tag.&#x22;, type: 'ReactNode' },
  label: { description: &#x22;What the pill says when collapsed. Also the panel's accessible name.&#x22;, type: 'string' },
  size: { description: 'Pixels. Controlled, so the host can remember it across sessions.', type: '{ width: number; height: number }' },
  band: {
    description:
      'The host measures its own width with useBand and says which band it is in. The design system never asks the viewport.',
    type: 'Band',
  },
  children: { description: 'The panel body. It stays mounted while collapsed.', type: 'ReactNode' },
  active: { description: 'A dot on the pill: something is running behind it.', type: 'boolean', default: 'false' },
  actions: { description: 'Header controls, before the collapse button.', type: 'ReactNode', default: 'none' },
  onSizeChange: { description: 'Fired by the resize grip, which sits on the corner opposite the anchor.', type: '(size: FloatingDockSize) => void', default: 'none' },
  minSize: { description: 'Resize floor.', type: 'FloatingDockSize', default: '{ width: 288, height: 320 }' },
  inlineFrom: { description: 'Below this band the whole thing is a bottom Drawer instead — a floating window is not a shape a 360px screen has room for.', type: 'Band', default: &#x22;'narrow'&#x22; },
  anchor: { description: 'Which corner it sits in.', type: &#x22;'bottom-right' | 'bottom-left'&#x22;, default: &#x22;'bottom-right'&#x22; },
}"
/>

It is positioned against its nearest positioned ancestor, so the host wraps it in a `relative`
element — usually the same one the canvas fills. Collapsing is not closing: the pill keeps its place
and the children stay mounted, because behind a test chat is a live conversation and a subscription.
Escape collapses it, but only from inside and never out of a text field, because the canvas has its
own Escape and a half-written message is about the message.

## Island [#island]

The skin every floating surface shares: `children`, `padding` (`'none'`, `'sm'`, `'md'`, default
`'md'`), `orientation` (`'horizontal'` or `'vertical'`) and a `className`. One radius, one
elevation, one border, one padding scale — that tininess is the value, because a rebrand retunes
`--radius-island` and `--shadow-island` and every floating surface in the product moves together.

It is not a positioning primitive. `FloatingSurface` — portal, anchoring, presence, dismissal —
remains the engine for anything anchored to a trigger; `Island` is the skin, and the two compose.
For a toolbar pinned to the corner of a canvas, absolute positioning plus this is the whole story.
