Skip to content

Layout Presets

Six shell arrangements, all of them CSS. How they work, and how to add a seventh.

The presets

PresetRailNotes
sidebar260pxThe default. Collapsible to 68px; the collapsed state persists.
sidebar-mini68pxIcon rail that expands over the content on hover and on keyboard focus.
sidebar-compact88pxLabel stacked under the icon.
sidebar-float260pxInset from the viewport edge, rounded and elevated. Collapsible.
topnavnoneGroups become dropdowns in a bar; the brand moves into the header.
topnav-hybrid68pxThe bar plus a permanent icon rail.

Compare them side by side at /layouts. Below thelgbreakpoint every preset falls back to the same off-canvas drawer, so these are desktop distinctions.

How a preset is applied

Choosing one writes a single class to <html>. Nothing in the shell branches on the layout mode, so switching re-renders nothing and loses no form state.

import { useSidebar } from "@dashboardpack/core/providers/sidebar-context";

const { layout, setLayout } = useSidebar();
setLayout("sidebar-mini");   // adds class="layout-sidebar-mini" to <html>

That is not a stylistic preference. This template is statically exported, so every page is prerendered with the default preset while the client reads the saved one from localStorage. Deriving classNames from React state was a genuine hydration mismatch — and suppressHydrationWarning sits on <html>, so it never covered the shell.

The two width variables

One preset needs them to disagree, which is why there are two.

--app-sidebar-rail-w   /* the width the rail RENDERS at */
--app-sidebar-w        /* the width it RESERVES — the content's offset */

They derive from each other by default, so a preset that only changes the rail width states one thing. sidebar-mini breaks the link deliberately: hovering overrides the rail width on the aside, where the content element cannot inherit it, so the rail expands over the content without shifting it. sidebar-float breaks it the other way — the reserved space also has to cover the gutter on both sides.

Adding a preset

Three steps, two of which the compiler enforces.

// 1. Core: add the mode. This widens LayoutMode and gives it a class.
//    node_modules/@dashboardpack/core/lib/layout/modes.ts
export const LAYOUT_MODES = [..., "sidebar-dual"] as const;

// 2. This template: name and describe it. src/lib/layouts.ts is a
//    Record<LayoutMode, LayoutPreset>, so omitting this is a COMPILE ERROR.
"sidebar-dual": {
  mode: "sidebar-dual",
  label: "Dual",
  description: "An icon column driving a second panel.",
  detail: "",
  rail: "narrow",
  topbar: false,
  floating: false,
},

// 3. globals.css: the appearance.
html.layout-sidebar-dual {
  --app-sidebar-rail-w: var(--app-sidebar-rail-narrow);
}

Step 3 is the one a type cannot check — a preset with no CSS is not a build failure, just a picker entry that does nothing. So src/lib/layouts.test.ts parses globals.css and asserts a rule exists for every preset the union allows. It also asserts no .sidebar-collapsed rule is left unscoped, because that flag outlives the preset that set it: unscoped, it shrinks sidebar-compact to 68px for anyone who has ever collapsed their sidebar.

Watch out for

  • Match class names exactly. layout-sidebar is a prefix of layout-sidebar-mini, and layout-topnav of layout-topnav-hybrid. A startsWith or a [class^="layout-sidebar"] selector resolves all four rail presets to the plain one.
  • Validate the saved value in your pre-paint script. The common shape — l === "topnav" ? … : "sidebar" — coerces a saved sidebar-mini to the default and flashes on every load. Interpolate LAYOUT_MODES rather than retyping the list.