react-floating-dock
v0.4.0
Published
A floating bottom navigation dock for React, with an animated active-tab pill and an overflow sheet for extra destinations. Framework-agnostic — bring your own router.
Maintainers
Readme
react-floating-dock
A floating, fully-rounded bottom navigation bar for React, in the style of WhatsApp's mobile tab bar: an animated pill slides behind the active tab, and an optional "More" button opens the rest of your destinations in a bottom sheet.
Framework-agnostic — it doesn't assume Next.js, React Router, or Tailwind. Bring your own router's
Link component and your own colors.

Install
npm install react-floating-dock framer-motionreact, react-dom and framer-motion are peer dependencies — install them if your project
doesn't already have them.
Quick start
import { FloatingDock, type DockItem } from "react-floating-dock";
import "react-floating-dock/style.css";
import { Home, Wallet, ArrowLeftRight, User, Settings } from "lucide-react";
import Link from "next/link"; // or your router's Link
import { usePathname } from "next/navigation";
const items: DockItem[] = [
{ key: "home", href: "/", label: "Home", icon: Home },
{ key: "wallet", href: "/wallet", label: "Wallet", icon: Wallet },
{ key: "activity", href: "/activity", label: "Activity", icon: ArrowLeftRight },
];
const moreItems: DockItem[] = [
{ key: "profile", href: "/profile", label: "Profile", icon: User },
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
];
export function AppNav() {
const pathname = usePathname();
return (
<FloatingDock
items={items}
moreItems={moreItems}
isActive={(href) => pathname === href}
LinkComponent={Link}
/>
);
}That's it — four or five primary tabs, an overflow sheet for the rest, and a theme you can override with CSS custom properties.
See example/ for a complete, runnable demo (pnpm example).
Why a LinkComponent prop instead of just using <a>?
So client-side navigation works with whatever router you're on. Most router Link components
accept href, className and children — the same shape this package expects — so passing
next/link's Link, React Router's Link, or TanStack Router's Link directly just works. If
you don't pass one, it falls back to a plain <a href>.
API
<FloatingDock />
| Prop | Type | Default | Description |
| ----------------- | -------------------------------------- | -------------- | ----------------------------------------------------------------------------- |
| items | DockItem[] | — | Destinations shown directly in the bar. Four or five is the sweet spot. |
| moreItems | DockItem[] | [] | Extra destinations shown in the overflow sheet. Omit to hide the "More" button entirely. |
| isActive | (href: string) => boolean | — | Tells the dock which item (if any) is the current page. |
| LinkComponent | ComponentType<DockLinkProps> | plain <a> | Your router's Link component. |
| moreLabel | string | "More" | Label under the overflow button. |
| moreSheetTitle | string | "Menu" | Heading inside the overflow sheet. |
| closeLabel | string | "Close" | aria-label for the sheet's close button. |
| extraContent | ReactNode | — | Rendered below the overflow grid — a natural slot for a theme toggle, sign-out button, language switcher, etc. |
| ariaLabel | string | "Main" | aria-label on the <nav> element. |
| children | ReactNode \| ({ close }) => ReactNode | — | Your own design for the overflow sheet (see below). With none, the sheet shows moreItems as the default grid. |
| moreActive | boolean | derived | Light up the "More" button. Derived from moreItems unless you set it — do set it when using custom children. |
| showMoreSheetTitle | boolean | true | Show the sheet's title row and close button. The title still labels the dialog when hidden. |
| className | string | — | Extra class on the <nav>, alongside the built-in one. |
| theme | "dark" \| "light" \| "frosted" \| "liquid" | "dark" | Colour preset for the bar and the sheet. Override --rfd-* variables for anything custom. |
| hideOnScroll | boolean | true | Slide the dock away when scrolling down, bring it back on scroll up (or keyboard focus). Pass false to keep it fixed. |
type DockItem = DockLinkItem | DockActionItem;
interface DockLinkItem {
key: string; // React key, usually the same as href
href: string;
label: string;
icon: ComponentType<{ className?: string }>; // any icon component — lucide-react, heroicons, your own SVGs
badge?: number | string | boolean; // 5, "99+", or true for a dot. 0/false hide it.
}
// If any moreItem carries a badge, the "More" button itself gets a plain attention dot — so
// something needing attention is never hidden from view just because it's tucked in the overflow.
// No href: renders a <button> instead of a link — e.g. a centre "Create" button.
interface DockActionItem {
key: string;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
prominent?: boolean; // raised, accent-filled button
badge?: number | string | boolean;
}Custom overflow content
The sheet opens directly on top of the dock. By default it shows moreItems as a grid of round
icons. To design it yourself, pass children — a node, or a function that receives close so your
own rows can dismiss the sheet. The "More" button appears whenever there are moreItems or children.
<FloatingDock items={PRIMARY} isActive={(href) => href === pathname} moreActive={isInMenuSection}>
{({ close }) => (
<nav aria-label="More">
<AccountSwitcher />
<MenuRow href="/members" onClick={close}>Members</MenuRow>
<MenuRow href="/settings" onClick={close}>Settings</MenuRow>
</nav>
)}
</FloatingDock>Pair it with showMoreSheetTitle={false} for a header-less sheet (it closes by dragging, tapping
outside, Escape, or More), and use --rfd-sheet-gap to leave space between the dock and the sheet.
extraContent, when given, renders below your children.
<FloatingDockSpacer />
A fixed dock covers the bottom of your page. Render <FloatingDockSpacer /> as the last child of
your scrolling content to reserve exactly the dock's height (including the iOS safe area), or use
padding-bottom: var(--rfd-height) yourself.
<DockDrawer />
The bottom sheet FloatingDock uses internally for its overflow menu. Exported separately because
it's useful on its own for any mobile-style sheet (filters, a confirmation prompt, etc.).
| Prop | Type | Default | Description |
| ------------ | ------------ | --------- | ----------------------------------- |
| open | boolean | — | Whether the sheet is visible. |
| onClose | () => void | — | Called on backdrop click, Escape, or the close button. |
| title | string | — | Heading shown in the sheet's header. |
| children | ReactNode | — | Sheet body. |
| closeLabel | string | "Close" | aria-label for the close button. |
| theme | "dark" \| "light" \| "frosted" \| "liquid" | "dark" | Colour preset. |
| showHeader | boolean | true | Show the title row and close button. The title still labels the dialog when hidden. |
| bodyClassName | string | — | Extra class on the scrolling body. |
On touch screens the sheet can be dragged down by its handle or header to dismiss it.
Styling
Import react-floating-dock/style.css once, anywhere in your app. It's plain CSS driven entirely
by custom properties, so no Tailwind (or any other build-time CSS tooling) is required.
To theme it, redefine the variables wherever makes sense for your app — :root for a global
theme, or a wrapper element for something scoped:
:root {
--rfd-bg: rgba(255, 255, 255, 0.9); /* bar + sheet background */
--rfd-bg-solid: #ffffff; /* sheet background (no backdrop-filter) */
--rfd-border: #e2e8f0;
--rfd-text: #0f172a;
--rfd-text-muted: #64748b;
--rfd-accent: #3b82f6; /* fills: the raised centre button, the active circle in the sheet */
--rfd-accent-text: #1d4ed8; /* active tab text and icon — keep it at 4.5:1 against --rfd-bg */
--rfd-accent-bg: rgba(59, 130, 246, 0.12); /* active tab's pill background */
--rfd-accent-contrast: #ffffff; /* text/icon color on top of --rfd-accent */
--rfd-hover: rgba(0, 0, 0, 0.05);
--rfd-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
--rfd-radius: 9999px; /* fully rounded by default — set a smaller value for softer corners */
--rfd-max-width: 32rem; /* the bar is centred and never wider than this */
--rfd-bar-height: 52px; /* total height of the bar (tabs are this minus its padding and border) */
--rfd-offset: 0.75rem; /* gap to the screen edges (the safe area is added on top) */
--rfd-badge-bg: #ef4444;
--rfd-badge-text: #ffffff;
}Liquid Glass
theme="liquid" gives the dock and its sheet an iOS-style glass look: the page shows through a
clear, blurred bar with a bright rim, an inner glow, and a glass pill on the active tab. The ink
switches with the system light/dark setting. It looks best over content with some colour behind it —
on a flat background there is nothing to show through. For a clearer or smokier bar, change
--rfd-glass (the backdrop-filter) and the tint in --rfd-bg.
All of the dock's classes are prefixed rfd- to avoid collisions with the rest of your app's CSS.
Accessibility
- The active tab gets
aria-current="page". - The overflow sheet is a
role="dialog"witharia-modal="true", locks body scroll while open, and closes on Escape. - Focus moves into the sheet when it opens, Tab/Shift+Tab stay inside it, and focus returns to the button that opened it when it closes.
- Every theme keeps ink, muted ink and the active tab at 4.5:1 contrast or better. The glass themes
(
frosted,liquid) stay see-through and get there by squeezing the contrast and shifting the brightness of whatever is behind the bar (via--rfd-glass), with the active pill as a denser chip on top. Calculated for black, white, grey, saturated colours, orange, navy and sky blue behind the bar — not for every possible photo, so check it over your own content. They fall back to an opaque system surface underprefers-reduced-transparencyor withoutbackdrop-filter. - Keyboard focus shows a 2px ring on tabs, the close button and sheet items in every theme
(
--rfd-focusto recolour it). Motion — the pill's travel and stretch, the sheet's slide — is turned off underprefers-reduced-motion. - Count and text badges are part of the item's accessible name; dots are decorative.
- Every interactive element is a real
<button>or yourLinkComponent, so it's reachable and operable by keyboard out of the box.
Development
pnpm install
pnpm test # vitest
pnpm typecheck
pnpm lint
pnpm build # outputs dist/ (ESM + CJS + types + style.css)
pnpm example # runs the example app against the live source, at localhost:5173License
MIT
