Skip to content

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

useAppShell(): { headerVisible, footerVisible, scrollDirection, setHeaderVisible, setFooterVisible }

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.

scroll-status.tsx
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>
  );
}
The component calling useAppShell must sit inside <AppShell> (or a bare <AppShellProvider>) — outside the provider the hook throws.

useScrollDirection

useScrollDirection(threshold = 10): "up" | "down" | null

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.

back-to-top.tsx
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

useSafeArea(edges?): { top, bottom, left, right }

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).

overlay.tsx
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

useKeyboardInset(): number

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.

composer.tsx
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>
A shrink smaller than 80px is treated as browser chrome — a URL bar sliding back in — rather than a keyboard, so a tab bar does not flicker away as you scroll.

useBelowBreakpoint

useBelowBreakpoint(bp: "sm" | "md" | "lg" | "none" | null): boolean

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.

shell.tsx
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

useHeaderTheme(): "light" | "primary" | "dark" | "none"

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.

app-header.tsx
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 />} />;
}
The theme is provided by the Header to its own rows, so the hook is only meaningful for components rendered inside a Header slot. Elsewhere it does not throw — it just falls back to the default, "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

useSearchShortcut(onTrigger, { key = 'k', slash = false, enabled = true }?)

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.

app-search.tsx
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>
  );
}