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 · lightRegistry 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";