Skip to contentVibraUI
Navigation & layout

Settings layout

The two-column settings page: a section list beside the section you are editing.

A 220px nav column beside the content from 768px up, and the same sections as a labelled select below it. The nav is a real nav landmark named "Settings", and the section being shown is the current page rather than a highlighted div. Pass renderLink to route through your framework: renderLink={(item, children, className) => <Link href={item.href} className={className} aria-current={item.href === activeHref ? "page" : undefined}>{children}</Link>}. The layout also clones aria-current="page" onto the element it gets back for the active section, so a plain link is announced even without that attribute; set it yourself when renderLink returns something that is not a single element. onNavigate drives the small-screen select, and, when there is no renderLink, the default anchors too, so a single prop is enough to make both breakpoints move without a page load.

Install

npx shadcn@latest add @vibra/settings-layout

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
navSettingsNavItem[]—The sections, in the order to show them.
activeHrefstring—The section being shown; the matching link is the current page.
titleReact.ReactNode—A heading above both columns.
descriptionReact.ReactNode—A quiet line under the heading.
childrenReact.ReactNode—The section itself, in the right-hand column.
renderLink(item: SettingsNavItem, children: React.ReactNode, className: string) => React.ReactNodea plain anchorSwaps the anchor for a router link; it owns aria-current on its own element.
onNavigate(href: string) => void—Called by the small-screen select, and by the default links when there is no renderLink.
SettingsNavItem.titlestring—The section name, in both the nav and the select.
SettingsNavItem.hrefstring—Unique; compared against activeHref.
SettingsNavItem.iconReact.ReactNode—Sits before the title; sized to 4 unless it sets its own size.
SettingsNavItem.descriptionstring—A quiet second line under the title in the nav column.

Dependencies

Source

components/ui/settings-layout.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

export type SettingsNavItem = {
  title: string
  href: string
  /** Sits before the title; sized to 4 unless it sets its own size. */
  icon?: React.ReactNode
  /** A quiet second line under the title, for sections whose name is not enough. */
  description?: string
}

export type SettingsLayoutProps = React.ComponentProps<"div"> & {
  nav: SettingsNavItem[]
  /** The section being shown; the matching link is the current page. */
  activeHref: string
  title?: React.ReactNode
  description?: React.ReactNode
  /**
   * Swaps the plain anchor for a router link; it receives the item, its
   * content, and its classes. The layout stamps aria-current="page" onto the
   * element it gets back for the active section.
   */
  renderLink?: (
    item: SettingsNavItem,
    children: React.ReactNode,
    className: string
  ) => React.ReactNode
  /** Called by the small-screen select, and by the default links when there is no renderLink. */
  onNavigate?: (href: string) => void
  /**
   * True when the shell around this page already lists the same sections in
   * its sidebar. The 220px column is then the same six links a second time,
   * eight pixels from the first — so it is dropped and the page gets the width
   * back. The small-screen Select stays either way: below md the sidebar is
   * off-canvas, so it is the only section switcher on the page.
   */
  sectionsInSidebar?: boolean
}

/**
 * Stamps the current page onto a link the caller rendered. A router link that
 * only took `className` would otherwise be highlighted but not announced, so
 * the layout puts `aria-current` on it rather than trusting every caller to;
 * a `renderLink` that returns something other than a single element — a
 * Fragment included, since React drops every prop but `key` on one — has to set
 * it itself.
 */
function markCurrent(node: React.ReactNode, active: boolean): React.ReactNode {
  if (!active || !React.isValidElement(node) || node.type === React.Fragment) return node
  type CurrentProps = {
    "aria-current"?: React.AriaAttributes["aria-current"]
    "data-active"?: string
  }
  return React.cloneElement(node as React.ReactElement<CurrentProps>, {
    "aria-current": "page",
    "data-active": "true",
  })
}

const NAV_ITEM =
  "flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground transition-colors duration-(--duration-fast) ease-(--ease-standard) focus-ring hover:bg-accent hover:text-foreground [&_svg]:size-4 [&_svg]:shrink-0"

/** The two-column settings page: a section list beside the section you are editing. */
function SettingsLayout({
  className,
  nav,
  activeHref,
  title,
  description,
  renderLink,
  onNavigate,
  sectionsInSidebar = false,
  children,
  ...props
}: SettingsLayoutProps) {
  return (
    <div
      data-slot="settings-layout"
      className={cn("flex w-full flex-col gap-6", className)}
      {...props}
    >
      {title || description ? (
        <div data-slot="settings-layout-header" className="flex flex-col gap-1">
          {title ? (
            <h2 className="type-display text-xl text-foreground">{title}</h2>
          ) : null}
          {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
        </div>
      ) : null}

      <div
        data-sections-in-sidebar={sectionsInSidebar || undefined}
        className={cn("grid gap-6 md:gap-8", !sectionsInSidebar && "md:grid-cols-[220px_1fr]")}
      >
        {/* The same sections as the nav, in the shape a phone can hold. */}
        <Select value={activeHref} onValueChange={(value) => onNavigate?.(String(value))}>
          <SelectTrigger aria-label="Settings section" className="w-full md:hidden">
            <SelectValue>
              {(value: string) => nav.find((item) => item.href === value)?.title ?? "Settings"}
            </SelectValue>
          </SelectTrigger>
          <SelectContent>
            {nav.map((item) => (
              <SelectItem key={item.href} value={item.href}>
                {item.title}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>

        <nav
          aria-label="Settings"
          data-slot="settings-layout-nav"
          className={cn("hidden flex-col gap-0.5", !sectionsInSidebar && "md:flex")}
        >
          {nav.map((item) => {
            const active = item.href === activeHref
            const itemClassName = cn(
              NAV_ITEM,
              active && "bg-accent font-medium text-accent-foreground"
            )
            const content = (
              <>
                {item.icon}
                <span className="min-w-0 flex-1">
                  <span className="block truncate">{item.title}</span>
                  {item.description ? (
                    <span className="block truncate text-xs font-normal text-muted-foreground">
                      {item.description}
                    </span>
                  ) : null}
                </span>
              </>
            )

            if (renderLink) {
              return (
                <React.Fragment key={item.href}>
                  {markCurrent(renderLink(item, content, itemClassName), active)}
                </React.Fragment>
              )
            }

            return (
              <a
                key={item.href}
                href={item.href}
                data-slot="settings-layout-nav-item"
                data-active={active || undefined}
                aria-current={active ? "page" : undefined}
                className={itemClassName}
                // Without a renderLink these are plain anchors, so a caller
                // that only passed onNavigate still gets client-side moves
                // rather than a full page load.
                onClick={
                  onNavigate
                    ? (event) => {
                        event.preventDefault()
                        onNavigate(item.href)
                      }
                    : undefined
                }
              >
                {content}
              </a>
            )
          })}
        </nav>

        <div data-slot="settings-layout-content" className="min-w-0">
          {children}
        </div>
      </div>
    </div>
  )
}

export { SettingsLayout }