whiskeyjack
uiapp-panessource: ok · refreshed
AppPanes
The three-phase content row below a floating header: optional left sidebar, main region, optional right rail, each gated on the layout variants.
npx shadcn add "https://whiskeyjack.net/r/app-panes.json"free
Where it goes
navbarsidebardashboard-shell
Visual style
3d
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
https://whiskeyjack.net/r/scroll-indicator.jsonhttps://whiskeyjack.net/r/use-layout-gate.jsonhttps://whiskeyjack.net/r/utils.json
Files1 · source cached 10/8/2026, 5:40:05 AM
components/ui/app-panes.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
import { ScrollIndicator } from "@/components/ui/scroll-indicator";
import { useLayoutGate } from "@/hooks/use-layout-gate";
/**
* The three-phase content row that sits below a floating `AppHeader`.
*
* The phases are keyed to the `wide`/`xlwide` layout gates rather than plain
* width breakpoints, so a portrait tablet keeps the tabbed layout and a
* landscape one deploys the sidebar:
*
* - **below `wide`** -- a single centered column: just the main region.
* - **`wide`..`xlwide`** -- the left sidebar appears and main shrinks to fill
* the rest with no right gap.
* - **`xlwide`+** -- the right rail appears and main centers its capped content
* between the two.
*
* Both rails are **portal hosts**: the Layout owns the region, the page owns
* what goes in it. A page reads the node from `useSidebarHost()` /
* `useRailHost()` and `createPortal`s its content there, which keeps tab state
* with the page that owns it.
*
* A rail whose `filled` is false collapses to zero width rather than
* unmounting, so the portal host survives for a page that fills it a moment
* later -- unmounting it produces a null host on the render that needed it.
* `filled: 'auto'` lets a pane decide that for itself by watching its host for
* children, for a Layout that would otherwise have to enumerate its pages.
*/
const SidebarHostContext = React.createContext<HTMLElement | null>(null);
const RailHostContext = React.createContext<HTMLElement | null>(null);
/**
* Resolves a pane's `filled`. A boolean passes through; `'auto'` watches the
* host for children, which is how a Layout that does not know its pages' plans
* still collapses an empty pane instead of holding the column open.
*
* **What this asks of a page**: whatever it portals here has to be there on its
* FIRST render. A page that paints a loading placeholder first portals nothing,
* so the column collapses to `w-0`, `main` starts at the screen edge, and
* everything shifts right when the real render lands -- an arrival flash that
* reads as a horizontal jump, and only at the widths where the pane deploys.
* See "Arriving at a page" in `.claude/rules/web-apps.md`.
*/
function usePaneFilled(host: HTMLElement | null, filled: boolean | "auto"): boolean {
const [observed, setObserved] = React.useState(false);
// A LAYOUT effect, because this decides whether a whole column exists. As a
// passive effect it ran after paint, so returning to a page painted once with
// the pane collapsed and the content full-width, then shifted when the page's
// portal filled the host -- a visible jump on every arrival, and only in the
// layouts that have the pane at all.
React.useLayoutEffect(() => {
if (filled !== "auto" || !host) {
setObserved(false);
return;
}
const sync = () => setObserved(host.childElementCount > 0);
sync();
const observer = new MutationObserver(sync);
observer.observe(host, { childList: true });
return () => observer.disconnect();
}, [host, filled]);
return filled === "auto" ? observed : filled;
}
/**
* The left sidebar's DOM node, or **null below the `wide` gate**.
*
* The gate is read in JS as well as CSS precisely so this can be null there.
* The `<aside>` itself stays mounted at every width -- an ancestor's
* `display: none` never unmounts a node, and unmounting it deliberately would
* produce a null host on exactly the render that needed it -- so a hook that
* simply handed back the ref would be non-null on a phone, and a page asking
* "is there a sidebar to portal into?" would get yes and portal its filters
* into something nobody can see.
*/
export const useSidebarHost = () => React.useContext(SidebarHostContext);
/** The right rail's DOM node, or **null below the `xlwide` gate**. See `useSidebarHost`. */
export const useRailHost = () => React.useContext(RailHostContext);
export interface AppPaneConfig {
/**
* Whether the page has content for this pane. False collapses it to zero
* width, keeping the host mounted.
*
* `'auto'` lets the pane answer for itself by watching its own host for
* children. Prefer it when the Layout would otherwise have to enumerate which
* pages fill which pane -- the Layout cannot read the host, since `AppPanes`
* owns that node and only hands it down. Pass a boolean when the Layout
* already knows (Icon Stack collapses both panes on a page with no source,
* before any page has rendered into them).
*/
filled: boolean | "auto";
/**
* Tailwind width classes. Defaults suit a nav rail (`w-64 2xl:w-72`) and a
* content rail (`w-72 2xl:w-80`); override when a pane's content genuinely
* needs different room -- Icon Stack's preview rail runs wider so previews
* are not cramped.
*/
width?: string;
/** Accessible label for the region (caller-translated). */
"aria-label"?: string;
}
export interface AppPanesProps extends React.HTMLAttributes<HTMLDivElement> {
/** Left sidebar, gated on `wide`. Omit for a layout with no sidebar. */
sidebar?: AppPaneConfig;
/** Right rail, gated on `xlwide`. Omit for a layout with no rail. */
rail?: AppPaneConfig;
/**
* Distance from the top of the row to the first item, clearing the floating
* header. Default `5.5rem` puts the first rail item on the same line as the
* main region's first card for a 64px header.
*/
railPaddingTop?: string;
/** `topOffset` for each rail's bounded ScrollIndicator. Default 68. */
indicatorTopOffset?: number;
/** The main region -- normally an `AppMain`. */
children: React.ReactNode;
}
export const AppPanes = React.forwardRef<HTMLDivElement, AppPanesProps>(
function AppPanes(
{
sidebar,
rail,
railPaddingTop = "5.5rem",
indicatorTopOffset = 68,
className,
children,
...props
},
ref,
) {
const [sidebarHost, setSidebarHost] = React.useState<HTMLElement | null>(null);
const [railHost, setRailHost] = React.useState<HTMLElement | null>(null);
const sidebarRef = React.useRef<HTMLElement | null>(null);
const railRef = React.useRef<HTMLElement | null>(null);
const sidebarFilled = usePaneFilled(sidebarHost, sidebar?.filled ?? false);
const railFilled = usePaneFilled(railHost, rail?.filled ?? false);
// The panes are gated in CSS (`hidden wide:block`), which hides them without
// unmounting the host. The same gates are read here so the CONTEXTS can be
// null below them: a page branching on `useRailHost()` is asking whether
// there is a rail on screen, and the ref alone answers yes on a phone.
// SSR-safe -- both are false with no window, which is the narrow layout.
const sidebarDeployed = useLayoutGate("wide");
const railDeployed = useLayoutGate("xlwide");
return (
<SidebarHostContext.Provider value={sidebarDeployed ? sidebarHost : null}>
<RailHostContext.Provider value={railDeployed ? railHost : null}>
<div
ref={ref}
className={cn(
// Side env() padding clears the notch in phone landscape, 0 elsewhere.
"flex-1 flex min-h-0 pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]",
className,
)}
{...props}
>
{sidebar && (
<div
className={cn(
"hidden wide:block relative shrink-0",
// Both widths are definite, so the pane eases open and shut
// instead of snapping. Deploying a sidebar reflows the main
// region beside it, and an instant jump there reads as the
// content having been replaced rather than moved.
"transition-[width] duration-300 ease-out motion-reduce:transition-none",
sidebarFilled
? (sidebar.width ?? "w-64 2xl:w-72")
: "w-0 overflow-hidden",
)}
>
<aside
ref={(el) => {
sidebarRef.current = el;
setSidebarHost(el);
}}
aria-label={sidebar["aria-label"]}
className="h-full overflow-y-auto px-6 pb-8 scrollbar-hide"
style={{ paddingTop: railPaddingTop }}
/>
{sidebarFilled && (
<ScrollIndicator scrollRef={sidebarRef} topOffset={indicatorTopOffset} />
)}
</div>
)}
{children}
{rail && (
<div
className={cn(
"hidden xlwide:block relative shrink-0",
"transition-[width] duration-300 ease-out motion-reduce:transition-none",
railFilled ? (rail.width ?? "w-72 2xl:w-80") : "w-0 overflow-hidden",
)}
>
<aside
ref={(el) => {
railRef.current = el;
setRailHost(el);
}}
aria-label={rail["aria-label"]}
className="h-full overflow-y-auto px-6 pb-8 scrollbar-hide"
style={{ paddingTop: railPaddingTop }}
/>
{railFilled && (
<ScrollIndicator scrollRef={railRef} topOffset={indicatorTopOffset} />
)}
</div>
)}
</div>
</RailHostContext.Provider>
</SidebarHostContext.Provider>
);
},
);
AppPanes.displayName = "AppPanes";