Mischief

133 / Wayfinding

Stack Panel

A panel that drills into nested pages the way a phone's settings do. The row you pressed becomes the title of the page it opens.

Settings

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/stack-panel
import { StackPanel } from "mischief-ui/stack-panel"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/stack-panel/stack-panel.tsx
"use client" import * as React from "react"import { createPortal } from "react-dom"import { ChevronLeft, ChevronRight } from "lucide-react" import { cn } from "@/lib/utils" export interface StackPanelProps extends Omit<  React.HTMLAttributes<HTMLDivElement>,  "title"> {  /** The heading of the first page. */  title: React.ReactNode

Usage

export function Settings() {
  return (
    <StackPanel title="Settings" className="h-[26rem]">
      <StackPanelItem icon={<Bell />} label="Notifications" detail="On">
        <NotificationOptions />
        <StackPanelItem label="Quiet hours" detail="22:00 to 07:00">
          <QuietHoursForm />
        </StackPanelItem>
      </StackPanelItem>
      <StackPanelItem icon={<Globe />} label="Language" detail="English">
        <LanguageList />
      </StackPanelItem>
      <StackPanelItem label="Version" detail="4.2.0" />
    </StackPanel>
  )
}

Declared as a tree

Each StackPanelItem with children is a row that opens those children as the next page. Items nest as deep as the settings do, and anything else can sit beside them: checkboxes, a form, a paragraph. A page is only rendered once it is opened, and pages underneath stay mounted while covered, so their scroll position and any state inside them survive the trip back.

  • With children: a row with a chevron that opens a page titled with its label, or with title when that should differ.
  • With onSelect and no children: a button row that runs the action, such as Clear cache.
  • With neither: a plain row for a value that is only read, such as a version number.

Give the panel a height with className. Every page fills it and scrolls its own body under a fixed header.

The title travels

On a push the row's label moves up into the header and becomes the new page's title, while the page fades in and its content slides a short way from the right. Back runs the same motion in reverse, returning the title to its row. Each takes about a quarter of a second. With reduced motion pages are swapped at once.

API

titleReactNodeThe heading of the first page.
backLabelstringRead before the previous title on the back button. Defaults to "Back to".
childrenReactNodeThe first page: rows and any other content.
classNamestringClasses for size and placement. Set a height here.
...divProps (StackPanelProps)HTMLAttributesNative div attributes.
label (StackPanelItem)ReactNodeThe row's text, and the title of the page it opens.
title (StackPanelItem)ReactNodeA title for the opened page when it should differ from the label.
detail (StackPanelItem)ReactNodeA value at the end of the row, such as On or English.
icon (StackPanelItem)ReactNodeDrawn before the label. Hidden from assistive technology.
value (StackPanelItem)stringA stable id for the page. Defaults to one React generates.
onSelect (StackPanelItem)() => voidRuns for a row that opens no page.
disabled (StackPanelItem)booleanMakes the row unavailable.
className (StackPanelItem)stringClasses for the row.
children (StackPanelItemProps)ReactNodeThe page this row opens. A row without children opens nothing.

Accessibility

Each page is a section labelled by its own h2. Opening a page moves focus to that heading, and going back returns focus to the row that opened it, so a keyboard or screen reader user never loses their place. Pages underneath are inert while covered. The back button reads as Back to and the previous title, with only the title shown. Escape goes back one page, and so does Backspace unless focus is in a text field or select. On the first page neither key is taken, so a dialog around the panel still closes on Escape. Rows are native buttons at least 44px tall.