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.
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.
<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
| Prop | Type | Default | Description |
|---|---|---|---|
root | React.ReactNode | — | Content of the bottom (root) panel, always visible |
rootLabel | React.ReactNode | "首頁" | Label shown for the root layer in the breadcrumbs |
stack | DrillLevel[] | — | Controlled mode: the current panel stack |
defaultStack | DrillLevel[] | [] | Uncontrolled mode: the initial panel stack |
onStackChange | (stack: DrillLevel[]) => void | — | Callback when the stack changes (fires on both push and pop) |
breadcrumbs | boolean | true | Whether to show the breadcrumb bar at the top |
recede | number | 0.06 | How much each lower layer scales back |
parallax | number | 44 | How far each lower layer shifts left (px), creating the parallax |
dim | number | 0.14 | Dimming opacity added per lower layer |
maxDim | number | 0.55 | Cap on the accumulated dimming opacity |
duration | number | 0.42 | Duration of the panel push / pop animation (seconds) |
closeOnEscape | boolean | true | Pop one level when Escape is pressed |
syncParam | string | — | Name of the URL query parameter to sync |
syncHistory | boolean | false | Use pushState so the browser's Back button pops one level at a time (default is replaceState) |
resolveLevel | (id: string) => DrillLevel | null | — | Restore a level from its id, supporting reloads and deep-link restoration |
className | string | — | Appended to the outermost container's className |
panelClassName | string | — | Class applied to every panel container |
DrillTrigger
Inherits the native <button> props (except onClick).
| Prop | Type | Default | Description |
|---|---|---|---|
level | DrillLevel | — | The panel to push when clicked |
children | React.ReactNode | — | Button content (a drill-in chevron is appended on the right automatically) |
useDrill()
Call it inside DrillStackPanels to get the drill controller:
| Field | Type | Description |
|---|---|---|
stack | DrillLevel[] | The currently open panel stack (excluding the root layer) |
depth | number | Stack depth |
activeId | string | null | The topmost panel's id; null when empty (root layer) |
push | (level: DrillLevel) => void | Push one level (ignored when the id is already in the stack) |
pop | () => void | Pop one level |
popTo | (id: string | null) => void | Jump back to a given level; null returns to the root |
reset | () => void | Collapse 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%andpx; it updates immediately when the viewport size changes - Popping is handled by
AnimatePresencelayer by layer, and the breadcrumb items pop out in the same order. Clicking any breadcrumb usespopToto jump straight back to that layer, and the root label collapses everything - Controlled and uncontrolled modes: pass
stackfor controlled, otherwise it managesdefaultStackinternally - URL sync encodes the stack path into a query parameter; when
resolveLevelis provided the full state can be restored from the URL, andsyncHistorywires 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-hiddenwith 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 thearia-label; the back button hasaria-label="返回上一層"(go back one level), and withcloseOnEscapepressing Escape pops one level - The breadcrumbs are a native
<nav><ol>structure with each layer as a focusable button; the current layer is markedaria-current="page"and is not clickable
Hero Condense Header
As the page scrolls, the hero's title, avatar, and meta migrate one by one into a sticky header via shared-element FLIP, then expand back into place in order when you scroll up.
Synced Outline Reader
A two-column reading layout of content plus outline, where an indicator spine stretches and slides continuously with the reading position and the current section comes into focus in sync.