Adaptive layouts
Stack and lock the layout at a breakpoint with the responsive prop, without remounting or losing state.
A dashfoo layout is already fluid: panels are weight-based percentages, so the whole tree scales with its container at any size. A breakpoint needs a structural change: a three-pane terminal should collapse into a single scrollable column on a phone, where dragging a tab onto a 22%-wide dock band or grabbing a splitter is too cramped.
The responsive prop handles that case. Below a width it renders the layout as
one stacked column and locks every structural interaction (tab and tabset
drag, split resize), leaving tap-to-switch. VS Code makes the same
call: docking is a desktop interaction, so mobile gets a read-only, navigable
view rather than a cramped one.
The responsive prop
import type { Dashfoo, TabNode } from "@dashfoo/core";
import { model, row, tab, tabset } from "@dashfoo/core";
import { DashfooLayout } from "@dashfoo/react";
import type { ReactNode } from "react";
import { useMemo } from "react";
const dashboard = (): Dashfoo =>
model(
row([
tabset([tab("chart", "Chart"), tab("trades", "Trades")], { id: "left", weight: 2 }),
tabset([tab("book", "Order Book")], { id: "right", weight: 1 }),
]),
);
const renderPanel = (node: TabNode): ReactNode => <div>{node.name}</div>;
const Dashboard = (): ReactNode => {
const defaultModel = useMemo(() => dashboard(), []);
return (
<DashfooLayout
defaultModel={defaultModel}
factory={renderPanel}
responsive={{ maxWidth: 720 }}
/>
);
};
export { Dashboard };responsive is { maxWidth: number; orientation?: Orientation }. At or below
maxWidth the layout is compact: stacked and locked. orientation controls the
stack direction ("column" by default). The width is measured on the layout's
own container with a ResizeObserver, not the viewport. A docking layout
often lives inside a sidebar or split, so it reacts to its own width.
State is preserved across the breakpoint
The stacked view is a derived projection of your model, not a replacement:
the projection does not replace the canonical model. Widening past maxWidth
restores its desktop arrangement, and the row hierarchy stays mounted. Tab
selection, closing and renaming still update that canonical model. Undo history, persistence, tab selection, and mounted panel
content survive the breakpoint cross. Tap-to-select still works because the
projection preserves tabset and tab ids, so a tap routes to the same tabset in
the canonical model.
The projection keeps nested row identities, so mounted panel state survives an
ordinary breakpoint change. Entering compact mode from a maximized view, moving
a tab between tabsets, or supplying a different tree can remount content, so
keep durable application state outside panel components. The lower-level
stackModel helper flattens the tree, so hand-composed layouts that use it can
also remount nested panels.
Existing floats stay overlays in compact mode. Their displayed bounds fit the viewport without changing the saved geometry, and the float controls still minimize or dock them back.
On touch devices, the theme grows tab hit targets to the 44px
minimum under @media (pointer: coarse), so tap-to-switch stays comfortable once
drag is off.
Hand-built layouts
If you compose the Layout.* primitives yourself instead of using
DashfooLayout, make the same behavior with useContainerWidth and stackModel:
measure the container, derive isCompact, render a stacked projection, and
lock the flags. The projection is view-only, and dispatch still targets your
canonical store.
import { stackModel } from "@dashfoo/core";
import { Layout, useContainerWidth, useDashfooStore } from "@dashfoo/react";
import { useMemo, useRef } from "react";
const HandBuilt = ({ defaultModel }) => {
const store = useDashfooStore({ defaultModel });
const [containerRef, width] = useContainerWidth();
const isCompact = width <= 720;
const view = useMemo(
() => (isCompact ? stackModel(store.model) : store.model),
[isCompact, store.model],
);
// View-only: drop the adjustSplit rrp auto-fires while it re-measures the
// stacked structure. The ref (written during render so it is current before
// rrp's onLayoutChanged) gates even the closure rrp captured before the swap.
const isCompactRef = useRef(isCompact);
isCompactRef.current = isCompact;
const dispatch = (action) =>
isCompactRef.current && action.type === "adjustSplit" ? undefined : store.dispatch(action);
return (
<Layout.Root
dispatch={dispatch}
draggableTabs={!isCompact}
draggableTabsets={!isCompact}
model={view}
renderers={{ tab: renderPanel }}
resizableSplits={!isCompact}
rootRef={containerRef}
>
<Layout.DragLayer>
<Layout.Rows node={view.layout} />
</Layout.DragLayer>
</Layout.Root>
);
};Distinct per-breakpoint models
When mobile and desktop should be different layouts (not the same tree
stacked), such as a hand-authored tablet arrangement or a panel hidden on small
screens, use useResponsiveModel. You describe the breakpoints; it returns the active model,
the locking flags, and a containerRef. Feed them to DashfooLayout as
reactive props, with no key, so it swaps the model without a remount.
import { stackModel } from "@dashfoo/core";
import { DashfooLayout, useResponsiveModel } from "@dashfoo/react";
import { useMemo } from "react";
const Adaptive = ({ base }) => {
const breakpoints = useMemo(
() => [
{ id: "mobile", model: stackModel(base), query: { maxWidth: 720 }, compact: true },
{ id: "desktop", model: base },
],
[base],
);
const { containerRef, model, draggableTabs, draggableTabsets, resizableSplits } =
useResponsiveModel({ breakpoints });
return (
<div ref={containerRef} style={{ height: "100%" }}>
<DashfooLayout
draggableTabs={draggableTabs}
draggableTabsets={draggableTabsets}
model={model}
factory={renderPanel}
resizableSplits={resizableSplits}
/>
</div>
);
};A breakpoint is { id, model, query?, compact? }. query is either a
container width ({ maxWidth: number }) or a media query
({ media: string }, via matchMedia); omit it on the last breakpoint to make it
the catch-all. Mark a breakpoint compact: true to lock drag and resize while it
is active. The returned draggableTabs / draggableTabsets / resizableSplits
flip to false. Feed the model to model (controlled) rather than defaultModel
so swaps take effect without a remount.
See also: The layout model · Persisting layouts.