WebberUI

Drill Stack Panels

A layered drill-down detail panel system — new panels push in from the right, lower layers recede with parallax and dimming, and breadcrumbs pop back one level at a time with optional URL sync.

This is a WebberUI Pro component

Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.

How to install Pro components →See the plans →

Loading preview…
npx shadcn@latest add "https://webberui.com/r/drill-stack-panels.json?t=<install token>"

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

0.06
44
0.14
0.42
<DrillStackPanels />

Installation

npx shadcn@latest add "https://webberui.com/r/drill-stack-panels.json?t=<install token>"

Or, once registries are configured in components.json, install it as @webberui/drill-stack-panels.

Usage

root is the bottom layer and is always visible. Inside any panel's content, call useDrill() to get push and drill one level deeper — or use the convenience component DrillTrigger, which pushes the level you give it on a single click. The breadcrumbs and the back button in each panel header handle going back one level at a time.

import {
  DrillStackPanels,
  DrillTrigger,
  useDrill,
  type DrillLevel,
} from "@/components/ui/drill-stack-panels";

function ProfilePanel() {
  const { push } = useDrill();
  const detail: DrillLevel = {
    id: "detail",
    title: "Details",
    content: <p className="p-4">The deepest layer's content.</p>,
  };
  return (
    <button onClick={() => push(detail)} className="p-4">
      View details
    </button>
  );
}

<div className="h-[360px]">
  <DrillStackPanels
    rootLabel="Home"
    root={
      <DrillTrigger
        level={{ id: "profile", title: "Profile", content: <ProfilePanel /> }}
      >
        Profile
      </DrillTrigger>
    }
  />
</div>

Every layer is a DrillLevel (id / title / content). The content can contain another DrillTrigger or call useDrill(), giving you drill-downs of any depth.

Controlled mode

Passing stack and onStackChange puts the component in controlled mode, with the stack fully owned by the outside. Omit them and it uses defaultStack in uncontrolled mode (managing the state internally).

const [stack, setStack] = React.useState<DrillLevel[]>([]);

<DrillStackPanels root={root} stack={stack} onStackChange={setStack} />;

URL sync

Set syncParam to write the current drill path into the URL query (the value is each layer's id). Combine it with resolveLevel (which restores a DrillLevel from an id) to support reloads and deep-link restoration; add syncHistory and the browser's Back button pops one level at a time.

<DrillStackPanels
  root={root}
  syncParam="path"
  syncHistory
  resolveLevel={(id) => LEVELS[id] ?? null}
/>

Props

DrillStackPanels

PropTypeDefaultDescription
rootReact.ReactNodeContent of the bottom (root) panel, always visible
rootLabelReact.ReactNode"首頁"Label shown for the root layer in the breadcrumbs
stackDrillLevel[]Controlled mode: the current panel stack
defaultStackDrillLevel[][]Uncontrolled mode: the initial panel stack
onStackChange(stack: DrillLevel[]) => voidCallback when the stack changes (fires on both push and pop)
breadcrumbsbooleantrueWhether to show the breadcrumb bar at the top
recedenumber0.06How much each lower layer scales back
parallaxnumber44How far each lower layer shifts left (px), creating the parallax
dimnumber0.14Dimming opacity added per lower layer
maxDimnumber0.55Cap on the accumulated dimming opacity
durationnumber0.42Duration of the panel push / pop animation (seconds)
closeOnEscapebooleantruePop one level when Escape is pressed
syncParamstringName of the URL query parameter to sync
syncHistorybooleanfalseUse pushState so the browser's Back button pops one level at a time (default is replaceState)
resolveLevel(id: string) => DrillLevel | nullRestore a level from its id, supporting reloads and deep-link restoration
classNamestringAppended to the outermost container's className
panelClassNamestringClass applied to every panel container

DrillTrigger

Inherits the native <button> props (except onClick).

PropTypeDefaultDescription
levelDrillLevelThe panel to push when clicked
childrenReact.ReactNodeButton content (a drill-in chevron is appended on the right automatically)

useDrill()

Call it inside DrillStackPanels to get the drill controller:

FieldTypeDescription
stackDrillLevel[]The currently open panel stack (excluding the root layer)
depthnumberStack depth
activeIdstring | nullThe topmost panel's id; null when empty (root layer)
push(level: DrillLevel) => voidPush one level (ignored when the id is already in the stack)
pop() => voidPop one level
popTo(id: string | null) => voidJump back to a given level; null returns to the root
reset() => voidCollapse every panel back to the root

How it works

  • Each new layer pushes in full-width from the right; at the same time every layer below shifts left (parallax), scales back proportionally, and gains a dimming overlay — deeper means darker, capped at maxDim — building a sense of stacked depth
  • The push start position uses the panel viewport width measured by ResizeObserver (in px), avoiding the animation jumps that come from mixing % and px; it updates immediately when the viewport size changes
  • Popping is handled by AnimatePresence layer by layer, and the breadcrumb items pop out in the same order. Clicking any breadcrumb uses popTo to jump straight back to that layer, and the root label collapses everything
  • Controlled and uncontrolled modes: pass stack for controlled, otherwise it manages defaultStack internally
  • URL sync encodes the stack path into a query parameter; when resolveLevel is provided the full state can be restored from the URL, and syncHistory wires it up to the browser's Back / Forward buttons

Accessibility

  • When the user has "reduce motion" enabled at the system level, the push / recede / dim transition durations all drop to zero and it cuts straight to the final position; layout and functionality are unaffected
  • Pushing a new layer moves focus to the topmost panel automatically (preventScroll); popping and the initial mount do not steal focus
  • Panels that are not on top are marked aria-hidden with pointer events disabled, so assistive technology and the mouse only reach the currently active panel
  • Each panel is a role="group" with its title as the aria-label; the back button has aria-label="返回上一層" (go back one level), and with closeOnEscape pressing Escape pops one level
  • The breadcrumbs are a native <nav><ol> structure with each layer as a focusable button; the current layer is marked aria-current="page" and is not clickable

On this page