Advanced
Hooks
Seven hooks expose the state the shell already tracks — scroll direction, bar visibility, safe-area insets, how much of the screen the keyboard covers, the active breakpoint, the header theme — plus the desktop search shortcut, so your own components can join in.
useAppShell
Shell context. scrollDirection is “up”, “down”, or null before the first scroll. Must be used inside <AppShell> (or <AppShellProvider>).
This is the shared shell context: what direction the user is scrolling, whether the header and footer are currently shown, and setters to drive them yourself. scrollDirection starts as null and stays null until the first scroll — branch on it before rendering scroll-dependent UI.
import { useAppShell } from "appshell-react";
export function ScrollStatus() {
const { scrollDirection, headerVisible } = useAppShell();
// null until the user scrolls for the first time
if (scrollDirection === null) return null;
return (
<p className="text-xs text-muted-foreground">
Scrolling {scrollDirection} · header {headerVisible ? "shown" : "hidden"}
</p>
);
}useAppShell must sit inside <AppShell> (or a bare <AppShellProvider>) — outside the provider the hook throws.useScrollDirection
Window scroll direction with a movement threshold in pixels. Powers reveal headers and auto-hide footers; use it for your own scroll-aware UI.
Unlike useAppShell, this hook needs no provider — it listens to the window directly, so it works anywhere. The threshold (default 10) is how many pixels the page must move before the direction flips; raise it to keep momentum-scroll wobble from toggling your UI.
import { useScrollDirection } from "appshell-react";
export function BackToTop() {
// Only flip after 24px of movement, ignoring tiny jitters
const direction = useScrollDirection(24);
if (direction !== "up") return null;
return (
<button
type="button"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
className="fixed bottom-24 right-4 z-50 rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg"
>
Back to top
</button>
);
}useSafeArea
Current safe-area inset values in pixels, resolved from env(safe-area-inset-*) (or a simulated --appshell-safe-area-inset-* override).
Where SafeArea applies insets as CSS padding, this hook hands you the numbers — for canvas drawing, absolutely positioned overlays, or gesture math. Pass an edges array to zero out the edges you don’t care about; values re-measure on window resize (rotation included).
import { useSafeArea } from "appshell-react";
export function EdgeToEdgeOverlay() {
// Numbers in px — ideal for canvas work and absolute layouts
const insets = useSafeArea(["top", "bottom"]);
return (
<div
className="pointer-events-none fixed inset-0"
style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
>
{/* controls stay inside the safe region */}
</div>
);
}useKeyboardInset
How many pixels of the viewport the on-screen keyboard covers, and — while anything is subscribed — the same value published as --appshell-keyboard-inset-bottom for CSS. Measured through visualViewport, the only mechanism iOS Safari supports; 0 with no keyboard and during SSR.
A fixed bar, a bottom sheet or a full-screen search all end up underneath the on-screen keyboard, and the web gives you no keyboard event to react to. visualViewport is what works everywhere — the VirtualKeyboard API is Chromium-only and iOS Safari ignores the interactive-widget meta. The shell already uses this: a modal BottomSheet and the SearchModal keep their content clear, and <Footer hideOnKeyboard> steps aside.
import { useKeyboardInset } from "appshell-react";
function Composer() {
// Pixels the keyboard covers — 0 when it is down.
const keyboard = useKeyboardInset();
return (
<form style={{ paddingBottom: keyboard }}>
<input placeholder="Write a reply" />
</form>
);
}
// Or stay in CSS: the hook publishes the same value as a variable while
// anything is subscribed, and appshell-react/safe-area.css wraps it.
// <div className="pb-safe-keyboard">…</div>useBelowBreakpoint
True when the viewport is narrower than that Tailwind breakpoint. The docked Sidebar uses it to decide when the drawer takes over — ask the same question rather than re-deriving it and disagreeing. Pass null to disable.
The docked Sidebar decides with it whether the drawer is the active presentation. Rendering stays CSS-gated so there is no hydration mismatch — this hook is for the decisions around it, like whether your trigger button should be in the header at all.
import { Sidebar, useBelowBreakpoint } from "appshell-react";
function Shell() {
const [open, setOpen] = useState(false);
// The same answer the Sidebar itself uses, so the two never disagree.
const isPhone = useBelowBreakpoint("md");
return (
<Sidebar
variant="docked"
breakpoint="md"
open={open}
onClose={() => setOpen(false)}
collapsible={!isPhone}
>
…
</Sidebar>
);
}useHeaderTheme
The active Header theme, for custom components rendered inside header rows that need to adapt their colors.
Custom content placed in a Header slot — logo, actions, nav — renders on whatever background the header’s theme paints. Read the active theme to pick legible colors instead of hard-coding them.
import { Header, useHeaderTheme } from "appshell-react";
import { ShoppingCart } from "lucide-react";
function CartButton() {
const theme = useHeaderTheme();
const onDark = theme === "primary" || theme === "dark";
return (
<button
type="button"
aria-label="Cart"
className={
onDark
? "text-white/90 hover:text-white"
: "text-muted-foreground hover:text-foreground"
}
>
<ShoppingCart className="size-5" />
</button>
);
}
export function AppHeader() {
return <Header behavior="fixed" theme="primary" actions={<CartButton />} />;
}"light". That delivery is HeaderProvider — the context provider Header wraps its rows in internally. It is exported for the rare case of rendering a Header slot’s content in isolation (a Storybook story, a unit test) without mounting a real Header: <HeaderProvider value={{ theme: "dark" }}>.useSearchShortcut
Binds the desktop search shortcut — ⌘K on macOS, Ctrl+K elsewhere — to a handler, typically opening a SearchModal. Opt into a bare “/” trigger (ignored while typing) with slash: true.
The ⌘/Ctrl combination fires even while an input has focus — palette muscle memory — while the opt-in slash trigger stays quiet inside editable fields. Show the shortcut on the trigger with SearchField’s shortcutHint prop, and suspend the binding without unmounting via enabled: false.
import { SearchModal, useSearchShortcut } from "appshell-react";
import { useState } from "react";
export function AppSearch() {
const [open, setOpen] = useState(false);
// ⌘K on macOS, Ctrl+K elsewhere. Add { slash: true } for "/" too.
useSearchShortcut(() => setOpen(true));
return (
<SearchModal open={open} onClose={() => setOpen(false)}>
{(q) => <Results query={q} />}
</SearchModal>
);
}