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