PhoneDock
Home and a menu in a small glass dock, whose menu opens a sheet of rows above it.
The idea
A phone has no hover to reveal five icons with and no room for their names, so the dock stops trying to show the pages and shows the two things always worth a thumb: home, and everything else. The menu's bars rotate into a cross, so open and closed are one object at two angles. On touch, fill can only ever mean state, so the current page is the filled row and sits on the highlight.
Composes DropMenu.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { PhoneDock, PhoneDockRow } from "mihirmodi-ui";
// Phones only; the desktop Dock takes over above sm.
<div className="fixed inset-x-0 bottom-5 flex justify-center sm:hidden">
<PhoneDock
home={{ label: "Home", to: "/", mark: <Avatar /> }}
items={PAGES} // { id, label, icon, to }[]
activeId={current}
extra={
<PhoneDockRow
icon={<ThemeIcon />}
label="Mode"
value={dark ? "Dark" : "Light"}
onClick={toggleTheme} // the sheet stays open
/>
}
/>
</div>Props
| Prop | Type | Notes |
|---|---|---|
| home{ label, mark, to?, href? } | { label, mark, to?, href? } | The button on the left — an avatar or a mark. |
| itemsPhoneDockItem[] | PhoneDockItem[] | `{ id, label, icon, to? | href? | onClick? }`. `href` rows get an arrow and open in a new tab. |
| activeIdstring | string | The page you are on: filled, on the highlight. |
| extraReactNode | ReactNode | Rows after a hairline. Build them with `PhoneDockRow`; pressing one leaves the sheet open. |
| sheet"viewport" | "dock" | "viewport" | "dock"= "viewport" | Fix the sheet to the screen's bottom, or hang it from the dock for an inline one. |
| placement"top" | "bottom" | "top" | "bottom"= "bottom" | With `sheet="dock"`: which edge the dock sits on. `top` drops the sheet down — this site's own navbar. |
| sheetAlign"center" | "end" | "center" | "end"= "center" | Hang the sheet from the dock's end, for a dock parked in a corner. |
| density"comfortable" | "compact" | "comfortable" | "compact"= "comfortable" | 44px rows for a thumb, or 33px rows for a pointer. Rows you build in `extra` read it from `usePhoneDockRow()`. |
| sheetWidthnumber | string | number | string= 280 | The sheet's width when it hangs from the dock — pixels, or any CSS width such as `min(388px, calc(100vw - 32px))`. |
| open / onOpenChangeboolean / (open) => void | boolean / (open) => void | Optional control. It closes itself on a tap outside, Escape, or following a link. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { createContext, useContext, useEffect, useState, type ComponentType, type ReactNode } from "react";
import { DropMenu } from "./DropMenu.js";
import { useLinkComponent, type LinkComponent } from "../lib/link.js";
import { MenuGlyph, type Glyph } from "../lib/glyph.js";
export type PhoneDockItem = {
id: string;
label: string;
icon: ComponentType<Glyph>;
/** A routed page. */
to?: string;
/** Off-site. Gets an arrow, and opens in a new tab. */
href?: string;
/** An action instead of a destination — the sheet still closes after it. */
onClick?: () => void;
};
export type PhoneDockProps = {
/** The home button on the left of the dock: an avatar or a mark. */
home: { label: string; mark: ReactNode; to?: string; href?: string };
items: PhoneDockItem[];
/** Which item is the page you are on. It is filled, and sits on the highlight. */
activeId?: string;
/**
* Rows under the pages, after a hairline: toggles, socials. Build them with
* `PhoneDockRow` so they match. Pressing one of these does not close the sheet —
* you may want to flip a setting twice.
*/
extra?: ReactNode;
/**
* `viewport` fixes the sheet to the bottom of the screen, centred, whatever
* else sits in the strip beside the dock. `dock` hangs it from the dock itself,
* for a dock rendered inline.
*/
sheet?: "viewport" | "dock";
/**
* Which edge the dock sits on, when the sheet hangs from it. `bottom` (default)
* opens the sheet upward; `top` opens it downward, for a dock used as a top bar.
*/
placement?: "top" | "bottom";
/** Where the sheet hangs from the dock: centred, or from its end — for a dock parked in a corner. */
sheetAlign?: "center" | "end";
/** `comfortable` rows are 44px, sized for a thumb. `compact` rows are 33px, for a pointer. */
density?: "comfortable" | "compact";
/** The sheet's width when it hangs from the dock — a number of pixels, or any CSS width. */
sheetWidth?: number | string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
link?: LinkComponent;
className?: string;
};
const ROWS = {
comfortable: "flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-[14px] leading-none transition-colors duration-150 ease-geist",
compact: "flex h-[33px] w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-[13px] leading-none transition-colors duration-150 ease-geist",
};
/** The row style for the sheet's density, for rows you build yourself in `extra`. */
const RowCtx = createContext(ROWS.comfortable);
export const usePhoneDockRow = () => useContext(RowCtx);
/**
* The navigation for a phone: a small glass dock with home and a menu button,
* whose menu opens a sheet of rows above it.
*
* On a phone there is no hover to reveal five icons with and no room for their
* names, so the dock gives up on showing the pages and shows the two things
* that are always worth a thumb — home, and everything else. The menu's two
* bars rotate into a cross, so open and closed are one object at two angles.
*
* The sheet closes on a tap outside, on Escape, and on following any link in
* it, including the page you are already on. The current page is filled and
* sits on the highlight; on touch, fill can only ever mean state, since there
* is no hover for it to preview.
*/
export function PhoneDock({
home,
items,
activeId,
extra,
sheet = "viewport",
placement = "bottom",
sheetAlign = "center",
density = "comfortable",
sheetWidth = 280,
open: openProp,
onOpenChange,
link,
className = "",
}: PhoneDockProps) {
const Link = useLinkComponent(link);
const row = ROWS[density];
const [openState, setOpenState] = useState(false);
const open = openProp ?? openState;
const setOpen = (v: boolean) => {
setOpenState(v);
onOpenChange?.(v);
};
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const close = () => setOpen(false);
return (
<>
{/* Tap-outside catcher: under the dock, over the page. */}
{open && <div aria-hidden onClick={close} className="fixed inset-0 z-40" />}
<div className={`relative z-50 ${className}`}>
<DropMenu
open={open}
placement={sheet === "dock" && placement === "top" ? "down" : "up"}
origin={sheet === "dock" && sheetAlign === "end" ? (placement === "top" ? "origin-top-right" : "origin-bottom-right") : undefined}
className={
sheet === "viewport"
? "fixed bottom-[76px] left-1/2 w-screen max-w-[420px] -translate-x-1/2 px-4"
: `absolute ${placement === "top" ? "top-full pt-2" : "bottom-full pb-2"} ${sheetAlign === "end" ? "right-0" : "left-1/2 -translate-x-1/2"}`
}
style={sheet === "dock" ? { width: sheetWidth } : undefined}
>
<RowCtx.Provider value={row}>
<ul className={`glass-panel dock-shadow flex flex-col border-[0.5px] border-gray-alpha-400 ${density === "compact" ? "rounded-xl p-1" : "rounded-2xl p-1.5"}`}>
{items.map((p) => {
const current = p.id === activeId;
return (
<li key={p.id}>
{p.onClick && !p.to && !p.href ? (
<button
type="button"
onClick={() => {
p.onClick?.();
close();
}}
aria-current={current ? "page" : undefined}
className={`${row} ${current ? "bg-highlight text-gray-1000" : "text-gray-900 hover:bg-highlight active:bg-highlight"}`}
>
<span className={current ? "text-gray-1000" : "text-quiet"}>
<p.icon filled={current} />
</span>
{p.label}
</button>
) : p.href ? (
<a href={p.href} target="_blank" rel="noopener" onClick={close} className={`${row} text-gray-900 hover:bg-highlight active:bg-highlight`}>
<span className="text-quiet">
<p.icon />
</span>
{p.label}
<span aria-hidden className="ml-auto text-[13px] text-quiet">
↗
</span>
</a>
) : (
<Link
href={p.to ?? "#"}
onClick={close}
aria-current={current ? "page" : undefined}
className={`${row} ${current ? "bg-highlight text-gray-1000" : "text-gray-900 hover:bg-highlight active:bg-highlight"}`}
>
<span className={current ? "text-gray-1000" : "text-quiet"}>
<p.icon filled={current} />
</span>
{p.label}
</Link>
)}
</li>
);
})}
{extra && (
<>
<li aria-hidden>
<span className="mx-3 my-1 block h-[0.5px] bg-gray-alpha-300" />
</li>
{extra}
</>
)}
</ul>
</RowCtx.Provider>
</DropMenu>
<div className="glass-nav dock-shadow relative flex items-center rounded-2xl border-[0.5px] border-gray-alpha-400 p-1">
{home.href ? (
<a href={home.href} aria-label={home.label} onClick={close} className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[12px] transition-transform duration-150 ease-geist active:scale-[0.92]">
{home.mark}
</a>
) : (
<Link href={home.to ?? "/"} aria-label={home.label} onClick={close} className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[12px] transition-transform duration-150 ease-geist active:scale-[0.92]">
{home.mark}
</Link>
)}
<span aria-hidden className="mx-1 h-6 w-[0.5px] shrink-0 bg-gray-alpha-300" />
<button
type="button"
aria-haspopup="true"
aria-expanded={open}
aria-label={open ? "Close menu" : "Menu"}
onClick={() => setOpen(!open)}
className={`flex h-10 w-10 items-center justify-center rounded-[12px] transition-[color,background-color,transform] duration-150 ease-geist active:scale-[0.92] ${
open ? "bg-highlight text-gray-1000" : "text-quiet hover:text-gray-1000"
}`}
>
<MenuGlyph open={open} />
</button>
</div>
</div>
</>
);
}
/** A setting row for `PhoneDock`'s `extra`: an icon, a label, and its value on the right. */
export function PhoneDockRow({
icon,
label,
value,
onClick,
pressed,
}: {
icon: ReactNode;
label: ReactNode;
value?: ReactNode;
onClick: () => void;
pressed?: boolean;
}) {
const row = usePhoneDockRow();
return (
<li>
<button type="button" aria-pressed={pressed} onClick={onClick} className={`${row} text-gray-900 hover:bg-highlight active:bg-highlight`}>
<span className="text-quiet">{icon}</span>
{label}
{value !== undefined && <span className="ml-auto text-[13px] text-quiet">{value}</span>}
</button>
</li>
);
}