Skip to content

Components

Footer

One component, three shapes: a tab bar for primary navigation, a floating slot for pills and FABs, and a slim mini bar for persistent status — all fixed to the bottom, safe-area aware, and optionally scroll-aware.

Tab bar

The default variant is a fixed bottom bar that lays out FooterItem children as equal-width tabs. Active state is fully controlled — pass active and switch it in onClick. A badge number renders a count bubble on the icon; values over 99 render as “99+”. Three to five tabs is the sweet spot — beyond five, each tab gets too narrow for a comfortable tap target.

Open fullscreen
9:41
app.tsx
import { AppShell, Content, Footer, FooterItem } from "appshell-react";
import { useState } from "react";
import { Bell, Home, Search, User } from "lucide-react";

export default function App() {
  const [tab, setTab] = useState("home");

  return (
    <AppShell safeArea>
      {/* pb-24 keeps the last item clear of the fixed tab bar */}
      <Content>
        <div className="p-4">{/* active tab's screen */}</div>
      </Content>
      <Footer variant="tab-bar" behavior="auto-hide">
        <FooterItem
          icon={<Home />}
          label="Home"
          active={tab === "home"}
          onClick={() => setTab("home")}
        />
        <FooterItem
          icon={<Search />}
          label="Search"
          active={tab === "search"}
          onClick={() => setTab("search")}
        />
        <FooterItem
          icon={<Bell />}
          label="Alerts"
          badge={12}
          active={tab === "alerts"}
          onClick={() => setTab("alerts")}
        />
        <FooterItem
          icon={<User />}
          label="Profile"
          active={tab === "profile"}
          onClick={() => setTab("profile")}
        />
      </Footer>
    </AppShell>
  );
}
badge is a number, not a string — the bubble only renders for values greater than 0, so there is no “dot” or text-badge mode. Need one? Put your own element inside icon.

Floating

variant="floating" renders no bar at all — it is a fixed, full-width slot along the bottom edge that positions whatever you put inside it. Your pill, FAB, or button group is the child; position docks it left, center, or right, and the slot pads itself above the bottom safe-area inset. The slot itself ignores pointer events, so the page stays tappable around your content.

Open fullscreen
9:41
app.tsx
import { AppShell, Content, Footer } from "appshell-react";
import { ShoppingBag } from "lucide-react";

export default function App() {
  return (
    <AppShell safeArea>
      <Content>
        <div className="p-4">{/* product grid */}</div>
      </Content>
      <Footer variant="floating" position="center" behavior="auto-hide">
        <button
          type="button"
          className="flex items-center gap-2 rounded-full bg-primary px-6 py-3 font-medium text-primary-foreground shadow-lg"
        >
          <ShoppingBag className="size-4" />
          Add to cart
        </button>
      </Footer>
    </AppShell>
  );
}

Mini

variant="mini" is a slim strip — a 3rem (h-12) content row with a top border and background blur, sitting above the bottom safe-area inset. Use it for now-playing bars, upload progress, or connection status. Children render inside the centered row; lay them out yourself with flex utilities.

app.tsx
import { AppShell, Content, Footer } from "appshell-react";
import { Pause } from "lucide-react";

export default function App() {
  return (
    <AppShell safeArea>
      <Content>
        <div className="p-4">{/* track list */}</div>
      </Content>
      <Footer variant="mini">
        <div className="flex flex-1 items-center justify-between">
          <span className="text-sm font-medium">Now playing — Midnight Sun</span>
          <button type="button" aria-label="Pause" className="text-primary">
            <Pause className="size-5" />
          </button>
        </div>
      </Footer>
    </AppShell>
  );
}

Auto-hide

behavior="auto-hide" slips the footer away when the user scrolls down and brings it back the moment they scroll up — the same useScrollDirection hook that powers reveal headers, so both bars move in sync. All three variants support it. Tune the animation with speed="slow" | "normal" | "fast"; with the Framer Motion adapter installed the hide/show becomes a real exit/enter transition (see Motion).

FooterItem props

PropTypeDefaultDescription
icon*ReactNode—Tab icon.
label*string—Tab label.
hrefstring—Destination URL — rendered through the LinkProvider component, so the tab announces as a link. The active one carries aria-current="page" either way.
activebooleanfalseHighlight as the current tab.
badgenumber—Count bubble. Values over 99 render as “99+”.
onClick() => void—Tap handler.
classNamestring—Extra classes.