Sitelet https://docs.dashfoo.com/responsive
dashfoo

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.