Skip to content
Component installs need the registry setup
VegaStack Design

Settings Layout

The two-pane Settings frame — a sticky section nav and one 960px column where forms cap at 720px and lists fill; on a phone the nav comes first.

Status
stable
Since
0.25.87
Accessibility pattern
nav landmark; aria-current page; one pane when narrow

Last updated

Settings

Profile

Daily digestUnread items at 9:00.

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Settings Layout before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify settings-layout

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/settings-layout

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

Usage

"use client"; // app/settings/settings-frame.tsx — it reads the path

import Link from "next/link";
import { usePathname } from "next/navigation";
import {
  SettingsLayout,
  SettingsLayoutContent,
  SettingsNav,
} from "@/components/ui/settings-layout";

const SECTIONS = [
  {
    label: "Account",
    items: [
      { href: "/settings/profile", label: "Profile" },
      { href: "/settings/notifications", label: "Notifications" },
    ],
  },
  {
    label: "Workspace",
    items: [{ href: "/settings/members", label: "Members", badge: 3 }],
  },
];

// app/settings/layout.tsx renders this around every settings page.
export function SettingsFrame({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  return (
    <SettingsLayout
      view={pathname === "/settings" ? "nav" : "content"}
      backHref="/settings"
      linkRender={<Link href="" />}
      nav={
        <SettingsNav
          sections={SECTIONS}
          activeHref={pathname}
          linkRender={<Link href="" />}
        />
      }
    >
      {children}
    </SettingsLayout>
  );
}

// Each page:
<SettingsLayoutContent kind="form">…</SettingsLayoutContent>;

Settings is a place people browse sideways, so it has one frame: the section nav in a 220px column that sticks below the page's top edge, and every page in ONE column of up to 960px beside it. A page says what it is with SettingsLayoutContent kind: form caps it at 720px, list fills the column — and both start at the same x, so moving between Profile and Members never moves the left edge. Put it inside an AppShellPage at its default width (1280px), which holds both panes; the layout owns its own geometry inside whatever width it gets.

Scope

  • Owns: the two panes, their widths and gap, the sticky nav, the form and list measures, the current-page mark, the narrow one-pane mode and its back link.
  • Does not own: routing — links render through linkRender, and the host passes the current path (activeHref) and which pane a narrow layout shows (view).
  • Compose with: PageHeader and SettingsSection/SettingsRow inside a form page; DataList or MemberList inside a list page.

Anatomy

SettingsLayout — data-slot="settings-layout" | "settings-layout-main" | "settings-layout-nav"
SettingsLayoutContent — data-slot="settings-layout-content"
SettingsNav — data-slot="settings-nav" | "settings-nav-badge" | "settings-nav-section" | "settings-nav-section-label"

Examples

Two panes

The nav marks the current page with aria-current="page" and the accent fill — a nested path (/settings/members/42) marks its section — and an item's badge shows a count. Choose Members to see a list page fill the column, then a form page cap at 720px on the same left edge.

Settings

Profile

Daily digestUnread items at 9:00.

On a phone

The layout follows its own width (@container/settings-layout), not the viewport. Below 42rem it shows one pane at a time, as iOS does: view="nav" makes the nav the screen — its rows grow to 44px touch rows with a chevron — and view="content" shows the page with a "‹ Settings" link to backHref. The Settings index route passes nav; every page passes content. At 42rem and wider both panes always show, whatever view says, so /settings can show the first page beside the nav on a desktop.

Settings

Profile

Daily digestUnread items at 9:00.

API Reference

SettingsLayout

PropTypeDefaultDescription
nav*React.ReactNode—The section nav — a SettingsNav.
backHrefstring—Where the narrow layout's back link goes — the Settings index that shows the nav. Omit to show no back link.
backLabelstring"Settings"The back link's text, after a "‹".
linkRenderReact.ReactElement<unknown, string | React.JSXElementConstructor<any>><a />The element the back link renders as — your router's link (<Link />).
view"content" | "nav""content"Which pane shows while the layout is narrower than 42rem (a phone): nav — the section list is the screen (the Settings index); content — the page, with a back link to the nav. At 42rem and wider both panes always show.

Data attributes and CSS variables on SettingsLayout

AttributeValues
data-slot"settings-layout" | "settings-layout-main" | "settings-layout-nav"
data-viewmirrors a prop or state value

SettingsLayoutContent

PropTypeDefaultDescription
kind"form" | "list""form"The page's measure in the 960px column: form — capped at 720px and left-aligned (sections of settings rows); list — the whole column (a members table, a list of attributes).

Data attributes and CSS variables on SettingsLayoutContent

AttributeValues
data-kindmirrors a prop or state value
data-slot"settings-layout-content"

SettingsNav

PropTypeDefaultDescription
sections*readonly SettingsNavSection[]—The groups of pages, in order.
activeHrefstring—The current path. The item it names is current, else its nearest ancestor — the longest href it sits under (/settings/teams/42 marks Teams) — so only one item is ever current. Ignored when isActive is given.
aria-labelstring"Settings"The nav landmark's accessible name.
isActive((item: SettingsNavItem) => boolean)prefix match on `activeHref`Decides which item is current, for routing that activeHref cannot express.
linkRenderReact.ReactElement<unknown, string | React.JSXElementConstructor<any>><a />The element each item renders as — your router's link (<Link />). The item's href, classes, aria-current and children are merged into it.

Data attributes and CSS variables on SettingsNav

AttributeValues
data-slot"settings-nav" | "settings-nav-badge" | "settings-nav-section" | "settings-nav-section-label"

Each section and item:

PropTypeDefaultDescription
items*readonly SettingsNavItem[]—The pages in this group, in order.
labelReact.ReactNode—The group's label, shown above its items and naming their list. Omit for an unlabelled group.
PropTypeDefaultDescription
href*string—Where the item goes; also what activeHref is compared with.
label*React.ReactNode—The item's name.
badgeReact.ReactNode—A count or a short status after the name ("3", "New") — part of the link's accessible name. A number renders as a muted tabular count; any other node renders as given.

Accessibility

  • SettingsNav is a nav landmark named "Settings" (aria-label); each group is a list named by its label, and the current page's link carries aria-current="page".
  • A count badge is part of its link's name ("Members 3"); pass a word rather than a bare number when the count needs context.
  • Below 42rem the hidden pane is display: none, out of the tab order and the accessibility tree; the back link is a real link to the nav screen.
  • Section labels are not headings, so the page's own h1 stays the first heading in the outline.
KeyAction
TabMoves through the nav links, then the page
EnterOpens the focused settings page
ContractStates tested
Behaviourdefault, current-page, badge, nav-view, content-view, back-link
Accessibilitylandmark, labeled, current-page, browser-accessibility-test
Visualdefault, hover, current, narrow, wide

Do / Don't

Do
Give every settings page the same frame and say only its kind — form or list — so the left edge never moves between siblings.
Don't
Cap a settings page with its own max-w class, centre it, or hand-roll a second nav; that is the width jump this layout exists to remove.

On this page