Skip to content
ChatfuelSDK
ReferenceUI

Layout patterns

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

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.

LayoutWhere it livesUse it for
AppShell + TopbarThe app, around every moduleThe frame: a top bar, a nav on the left, and a hamburger plus a drawer below the collapse breakpoint.
NavRailInside AppShell's nav slotA flat list of modules, icon-only or expanded.
SideNavInside AppShell's nav slotThe same list with a group level above it — a rail of group icons and a hover flyout.
ModuleRootThe outermost element of a moduleThe container, the observed node, the gutter scope and the focus target. Every module starts here.
SplitPaneInside a moduleMaster and detail: a list beside a thread, a nav beside a canvas.
PageHeaderInside a module, at the topThe module's own title bar, with meta, actions and an optional tab strip.
DataTableInside a moduleRows: sorting, selection, resizing, reordering, inline editing.
FloatingDockOver a canvasA window that stays open while the scene behind it stays whole.
IslandOver a canvasThe elevated surface every floating toolbar, palette and zoom widget is built from.

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.

Prop

Type

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.

PropTypeWhat goes in it
brandReactNodeThe 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.
workspaceReactNodeWhich 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.
titlestringThe 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.
rightReactNodeSession-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

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.

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

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

Prop

Type

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.

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

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.

Prop

Type

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

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.

FieldTypeWhat it does
value(row: T) => stringThe 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>) => ReactNodeA custom editor. The default is a single-line text input.
enabled(row: T) => booleanPer-row veto — a computed field, an attribute this record does not carry.
placeholderstringFor 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

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.

Prop

Type

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

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.

On this page