shadimage
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 · light

Registry 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";

More from whiskeyjack

Browse all components in this library →