shadimage
whiskeyjack
uiapp-shellsource: ok · refreshed

AppShell

The app layout skeleton: a fixed-height shell or a document scroller, with a frosted header, nav pills, and the main scroll region.

npx shadcn add "https://whiskeyjack.net/r/app-shell.json"
free

Where it goes

navbar

Visual style

glass-blur

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/merge-refs.jsonhttps://whiskeyjack.net/r/scroll-indicator.jsonhttps://whiskeyjack.net/r/use-nav-reset.jsonhttps://whiskeyjack.net/r/use-reduced-motion.jsonhttps://whiskeyjack.net/r/use-scroll-region.jsonhttps://whiskeyjack.net/r/use-sliding-indicator.jsonhttps://whiskeyjack.net/r/use-travel-direction.jsonhttps://whiskeyjack.net/r/utils.json

Files1 · source cached 10/8/2026, 11:25:33 AM

components/ui/app-shell.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
import { ScrollIndicator } from "@/components/ui/scroll-indicator";
import { ScrollRegionProvider, type ScrollRegion } from "@/hooks/use-scroll-region";
import { NavResetProvider, useNavDoubleActivate } from "@/hooks/use-nav-reset";
import { useSlidingIndicator } from "@/hooks/use-sliding-indicator";
import { useTravelDirection } from "@/hooks/use-travel-direction";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useMergedRefs } from "@/lib/merge-refs";

/**
 * The app shell skeleton shared by every Whiskeyjack app: a root container,
 * the frosted desktop header (floating or sticky), the standard header nav
 * pills, and the main scroll region. Composable slots rather than a
 * monolith -- app-specific chrome (Tauri window controls, sidebar rails,
 * drawers) stays in the app and nests between these pieces.
 *
 * Two scroll models (see `.claude/rules/web-apps.md`, "Layout shell"):
 * - `shell`: fixed-height column (`h-dvh overflow-hidden`); ONLY the AppMain
 *   scroller moves. Pair with AppHeader `floating` + AppMain `self`.
 * - `document`: the page itself scrolls (content sites, simple apps). Pair
 *   with AppHeader `sticky` + AppMain `document`.
 */

// -- AppShell ----------------------------------------------------------------

export interface AppShellProps extends React.HTMLAttributes<HTMLDivElement> {
  /** 'shell': fixed h-dvh column, main scrolls. 'document': page scrolls. */
  scroll?: "shell" | "document";
}

export const AppShell = React.forwardRef<HTMLDivElement, AppShellProps>(
  ({ scroll = "shell", className, children, ...props }, ref) => (
    // The nav-reset slot wraps everything, so the Layout's nav chrome and the
    // page that registers its start are both below it.
    <NavResetProvider>
      <div
        ref={ref}
        className={cn(
          "flex flex-col",
          scroll === "shell"
            ? "h-dvh overflow-hidden relative"
            : "min-h-screen pb-24 md:pb-0",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </NavResetProvider>
  ),
);
AppShell.displayName = "AppShell";

// -- AppHeader ---------------------------------------------------------------

export interface AppHeaderProps extends React.HTMLAttributes<HTMLElement> {
  /**
   * 'floating': absolute over the scroller(s) -- content scrolls behind the
   * frost; scrollers reserve the header height (md:pt-14 / md:pt-16).
   * 'sticky': in-flow sticky bar for document-scroll apps.
   */
  variant?: "floating" | "sticky";
  /**
   * 'contained': centered title-bar (max-w 800). 'full': edge-to-edge.
   * 'none': no width/padding classes on the row -- supply them via
   * `rowClassName` (Tauri headers with window-control clearance vars).
   */
  width?: "contained" | "full" | "none";
  /** Header row height in Tailwind units. 14 = 56px, 16 = 64px. */
  height?: 14 | 16;
  /** Right side of the bar (action pill, toggle, ...). */
  toolbar?: React.ReactNode;
  /**
   * Absolutely-positioned chrome inside the header but outside the content
   * row -- the Tauri extension point (window-control containers pinned to the
   * window edges while the row clears them via the --wc-* vars).
   */
  chrome?: React.ReactNode;
  /** Extra classes for the inner content row (merged after the width preset). */
  rowClassName?: string;
}

export const AppHeader = React.forwardRef<HTMLElement, AppHeaderProps>(
  (
    {
      variant = "floating",
      width = "contained",
      height = 16,
      toolbar,
      chrome,
      rowClassName,
      className,
      children,
      ...props
    },
    ref,
  ) => (
    <header
      ref={ref}
      className={cn(
        "hidden md:block z-50 wj-frost-blur border-b border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]",
        variant === "floating" ? "absolute top-0 inset-x-0" : "sticky top-0",
        className,
      )}
      style={{ background: "var(--header-bg)" }}
      {...props}
    >
      {chrome}
      <div
        className={cn(
          "flex items-center justify-between gap-4",
          height === 16 ? "h-16" : "h-14",
          width === "contained" && "max-w-[800px] mx-auto w-full px-4",
          width === "full" && "w-full px-4 lg:px-6",
          rowClassName,
        )}
      >
        {children}
        {toolbar}
      </div>
    </header>
  ),
);
AppHeader.displayName = "AppHeader";

// -- HeaderNav ---------------------------------------------------------------

export interface HeaderNavItem {
  key: string;
  /** Passed to the link component's `to` prop. */
  to: string;
  label: React.ReactNode;
  active: boolean;
  renderIcon: (args: { active: boolean; size: number }) => React.ReactNode;
}

export interface HeaderNavProps extends React.HTMLAttributes<HTMLElement> {
  items: HeaderNavItem[];
  /** Router link component (e.g. react-router's Link) -- same injection as MobileBottomNav. */
  linkComponent: React.ElementType;
  /**
   * The app's scroll region, for double-clicking the active item to return to
   * the top (then to the page's start -- see `useRegisterNavReset`). Pass the
   * same ref given to `AppMain`; omit it for a document-scroll app. Taken as a
   * ref for the reason `useScrollReset` does: this nav renders above the scroll
   * region `AppMain` publishes, so it cannot read it from context.
   */
  scrollRef?: React.RefObject<HTMLElement | null>;
}

export const HeaderNav = React.forwardRef<HTMLElement, HeaderNavProps>(function HeaderNav(
  { items, linkComponent: LinkComp, scrollRef, className, ...props },
  ref,
) {
  const onActivate = useNavDoubleActivate(scrollRef);
  const navRef = React.useRef<HTMLElement>(null);
  const mergedRef = useMergedRefs(navRef, ref);
  const reduced = useReducedMotion();
  // The highlight travels between items instead of appearing on the new one,
  // the horizontal read of the SidebarTabs rail. Desktop only by construction:
  // MobileBottomNav is its own component and shares nothing here.
  const activeKey = items.find((i) => i.active)?.key;
  const indicator = useSlidingIndicator(navRef, activeKey, {
    attribute: "data-nav-key",
    changeKey: items.length,
  });
  // Which edge leads depends on the direction of travel, so `.wj-nav-highlight`
  // names it -- the same stretch the SegmentedControl pill takes.
  const direction = useTravelDirection(items.findIndex((i) => i.active));
  return (
    <nav ref={mergedRef} className={cn("relative flex items-center gap-1", className)} {...props}>
      {indicator && (
        <span
          aria-hidden
          data-dir={direction}
          className={cn(
            // inset-y-0 is the item height: every item carries the same
            // min-height and padding, so the nav is exactly as tall as one.
            "pointer-events-none absolute inset-y-0 z-0 rounded-lg",
            "bg-[var(--wj-nav-active)] shadow-bevel",
            // Positioned by both edges, so `.wj-nav-highlight` can give each its
            // own easing and the highlight stretches through the travel.
            !reduced && "wj-nav-highlight",
          )}
          style={{
            left: indicator.offset,
            right: indicator.extent - (indicator.offset + indicator.size),
          }}
        />
      )}
      {items.map((item) => (
        <LinkComp
          key={item.key}
          to={item.to}
          data-nav-key={item.key}
          onClick={() => onActivate(item.active)}
          aria-current={item.active ? "page" : undefined}
          className={cn(
            // touch-manipulation: the active item takes a double-click/tap
            // (useNavDoubleActivate), which Safari would read as zoom on a
            // zoomable page. Pinch-zoom is unaffected.
            // z-10 so the label rides above the travelling highlight, and
            // wj-nav-handoff fades this item's own fill out as that
            // highlight crosses to reach it.
            "wj-nav-handoff relative z-10 flex items-center gap-2 min-h-control-md px-3 py-2 rounded-lg text-sm font-medium transition-colors wj-focus-ring touch-manipulation",
            item.active
              ? "text-[var(--color-text-primary-light)] dark:text-[var(--color-text-primary-dark)]"
              : "text-[var(--color-text-secondary-light)] dark:text-[var(--color-text-secondary-dark)] hover:bg-[var(--wj-nav-active)]",
          )}
        >
          {item.renderIcon({ active: item.active, size: 20 })}
          <span>{item.label}</span>
        </LinkComp>
      ))}
    </nav>
  );
});
HeaderNav.displayName = "HeaderNav";

// -- AppMain -----------------------------------------------------------------

export interface AppMainProps extends React.HTMLAttributes<HTMLElement> {
  /**
   * 'self': the main element is the scroller (shell model) -- hides its native
   * scrollbar, ships a bounded ScrollIndicator, and carries `overscroll-none`
   * so a touch overscroll cannot rubber-band the shell or trigger the
   * browser's pull-to-refresh (a caller's own `overscroll-*` class wins).
   * 'document': plain flex-1 region, the page scrolls.
   */
  scroll?: "self" | "document";
  /**
   * ScrollIndicator top offset (header height + ~4px: 60 for h-14, 68 for
   * h-16). Only used with scroll='self'.
   */
  indicatorTopOffset?: number;
  /**
   * Hide the native scrollbar at every width (default). Pass false when the
   * app gates the hiding itself (Chip Away hides it md+ only via CSS, keeping
   * the native overlay indicator on phones).
   */
  hideScrollbar?: boolean;
  /**
   * Hide the bounded ScrollIndicator below `md` (default `true`), where a
   * touch viewport's own overlay scrollbar is the better affordance.
   *
   * Pass `false` for a real desktop WINDOW, which can be resized narrower than
   * `md` while still being a desktop: the default would hand it back the native
   * scrollbar, which then runs the full height behind a floating header --
   * exactly what the indicator exists to avoid. Width is not a proxy for
   * platform once the app ships as a resizable window.
   */
  indicatorHideBelowMd?: boolean;
}

export const AppMain = React.forwardRef<HTMLElement, AppMainProps>(
  (
    {
      scroll = "self",
      indicatorTopOffset = 68,
      hideScrollbar = true,
      indicatorHideBelowMd = true,
      className,
      children,
      ...props
    },
    ref,
  ) => {
    const innerRef = React.useRef<HTMLElement | null>(null);
    const setRefs = (el: HTMLElement | null) => {
      innerRef.current = el;
      if (typeof ref === "function") ref(el);
      else if (ref) ref.current = el;
    };

    // Which element "scroll to the top" means is a choice this component just
    // made on the app's behalf, so it is also this component's job to publish
    // the answer -- a page has no way to know, and `window.scrollTo` is wrong
    // here without saying so. See `useScrollReset`.
    const region = React.useMemo<ScrollRegion>(
      () => ({
        scrollToTop: () => {
          if (scroll === "document") window.scrollTo(0, 0);
          else if (innerRef.current) innerRef.current.scrollTop = 0;
        },
        getElement: () => (scroll === "document" ? null : innerRef.current),
      }),
      [scroll],
    );

    if (scroll === "document") {
      return (
        <main
          ref={setRefs}
          data-app-main
          tabIndex={-1}
          className={cn("flex-1 focus:outline-none", className)}
          {...props}
        >
          <ScrollRegionProvider value={region}>{children}</ScrollRegionProvider>
        </main>
      );
    }

    return (
      <>
        <main
          ref={setRefs}
          // Stable hook for chrome that must reserve space at the top of the
          // content region from outside React -- the Tauri pack's sub-md
          // window-control strip is the case. Same idiom as TabBar's
          // `data-tab-bar`; the attribute is API, so it does not move.
          data-app-main
          tabIndex={-1}
          className={cn(
            "flex-1 min-w-0 overflow-y-auto overscroll-none pb-24 md:pb-0 focus:outline-none",
            hideScrollbar && "scrollbar-hide",
            className,
          )}
          {...props}
        >
          <ScrollRegionProvider value={region}>{children}</ScrollRegionProvider>
        </main>
        <ScrollIndicator
          scrollRef={innerRef}
          topOffset={indicatorTopOffset}
          hideBelowMd={indicatorHideBelowMd}
        />
      </>
    );
  },
);
AppMain.displayName = "AppMain";

More from whiskeyjack

Browse all components in this library →