Sidebar that folds to icons in place, with tooltips only when narrow.
01Preview
Inbox
Priya Raman replied in Q3 roadmap
Invoice INV-2041 was paid
Tom Becker invited you to Growth
Deploy to production finished
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react @number-flow/react03Usage
import Link from "next/link";
import { IconRail, IconRailHeader, IconRailContent, IconRailGroup, IconRailItem, IconRailFooter, IconRailToggle } from "@/components/ui/icon-rail";
<IconRail value={pathname} defaultCollapsed={false}>
<IconRailHeader mark={<Logo />} title="Northwind" />
<IconRailContent>
<IconRailGroup>
<IconRailItem value="/inbox" icon={<Inbox />} count={4} render={<Link href="/inbox" />}>Inbox</IconRailItem>
<IconRailItem value="/projects" icon={<Folder />} render={<Link href="/projects" />}>Projects</IconRailItem>
</IconRailGroup>
</IconRailContent>
<IconRailFooter>
<IconRailToggle />
</IconRailFooter>
</IconRail>04Source
"use client";
import { Tooltip } from "@base-ui/react/tooltip";
import NumberFlow from "@number-flow/react";
import { animate, motion, useMotionValue, useReducedMotion } from "motion/react";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
/* -------------------------------------------------------------------------------------------------
* Root
* -----------------------------------------------------------------------------------------------*/
type RailContextValue = {
collapsed: boolean;
toggle: (via: "pointer" | "key") => void;
value: string | null;
select: (value: string) => void;
pill: string;
navId: string;
shortcut: string | false;
};
const RailContext = createContext<RailContextValue | null>(null);
function useRail(part: string) {
const ctx = useContext(RailContext);
if (!ctx) throw new Error(`<${part}> must be inside <IconRail>`);
return ctx;
}
// "mod" is ⌘ on Apple devices and Ctrl elsewhere. The server assumes ⌘; the glyph
// only appears inside a tooltip, which never renders before hydration.
const noop = () => () => {};
const readApple = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
function useApple() {
return useSyncExternalStore(noop, readApple, () => true);
}
function matches(e: KeyboardEvent, shortcut: string) {
const parts = shortcut.toLowerCase().split("+");
const key = parts.pop();
const mod = parts.includes("mod");
if (mod !== (e.metaKey || e.ctrlKey)) return false;
if (parts.includes("shift") !== e.shiftKey || parts.includes("alt") !== e.altKey) return false;
return e.key.toLowerCase() === key;
}
export type IconRailProps = Omit<React.ComponentProps<"aside">, "defaultValue" | "onChange"> & {
collapsed?: boolean;
defaultCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
/** The active item's value. */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
/** Keyboard toggle, as "mod+b" or "mod+shift+s". `false` turns it off. Ignored while typing in a field. */
shortcut?: string | false;
/** Widths in px. */
expandedWidth?: number;
collapsedWidth?: number;
};
export function IconRail({
collapsed: collapsedProp,
defaultCollapsed = false,
onCollapsedChange,
value,
defaultValue = null,
onValueChange,
shortcut = "mod+b",
expandedWidth = 220,
collapsedWidth = 48,
className,
style,
children,
...rest
}: IconRailProps) {
const [collapsed, setCollapsed] = useControllableState({ value: collapsedProp, defaultValue: defaultCollapsed, onChange: onCollapsedChange });
const [current, setCurrent] = useControllableState<string | null>({ value, defaultValue, onChange: onValueChange });
// Keyboard toggles land on the same frame; a pointer toggle animates. Motion on a
// shortcut someone presses all day reads as latency.
const [instant, setInstant] = useState(false);
const pill = useId();
const navId = useId();
const toggle = useCallback(
(via: "pointer" | "key") => {
setInstant(via === "key");
setCollapsed((c) => !c);
},
[setCollapsed],
);
useEffect(() => {
if (!shortcut) return;
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.repeat || !matches(e, shortcut)) return;
const t = e.target as HTMLElement | null;
if (t?.closest("input, textarea, select, [contenteditable=''], [contenteditable='true']")) return;
e.preventDefault();
toggle("key");
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [shortcut, toggle]);
const ctx = useMemo(
() => ({ collapsed, toggle, value: current, select: (v: string) => setCurrent(v), pill, navId, shortcut }),
[collapsed, toggle, current, setCurrent, pill, navId, shortcut],
);
return (
<RailContext.Provider value={ctx}>
<Tooltip.Provider delay={400} timeout={400}>
<aside
data-collapsed={collapsed || undefined}
data-instant={instant || undefined}
style={{ width: collapsed ? collapsedWidth : expandedWidth, ...style }}
className={cn(
"group/rail flex h-full min-h-0 shrink-0 flex-col overflow-hidden border-r border-line bg-frame text-fg",
"transition-[width] duration-260 ease-in-out-quart data-instant:duration-0",
className,
)}
{...rest}
>
{children}
</aside>
</Tooltip.Provider>
</RailContext.Provider>
);
}
/* -------------------------------------------------------------------------------------------------
* Shared pieces
* -----------------------------------------------------------------------------------------------*/
// Everything that isn't an icon fades and slides 4px toward the icons as the rail
// narrows, quickly and with no delay; on the way back it waits 70ms so the width
// has room for it before it arrives.
const fadeLabel = cn(
"transition-[opacity,translate] duration-200 delay-70 ease-out-expo",
"group-data-collapsed/rail:-translate-x-1 group-data-collapsed/rail:opacity-0 group-data-collapsed/rail:delay-0 group-data-collapsed/rail:duration-100",
"group-data-instant/rail:transition-none motion-reduce:translate-x-0",
);
const focusRing = "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3";
const tipClass = cn(
"flex items-center gap-2 rounded-lg border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 text-fg shadow-pop outline-none",
"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo",
"data-starting-style:-translate-x-0.5 data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100",
"data-instant:transition-none motion-reduce:translate-x-0 motion-reduce:scale-100",
);
function RailTooltip({ label, detail, disabled, children }: { label: React.ReactNode; detail?: React.ReactNode; disabled: boolean; children: React.ReactElement }) {
return (
<Tooltip.Root disabled={disabled}>
<Tooltip.Trigger render={children} />
<Tooltip.Portal>
<Tooltip.Positioner side="right" sideOffset={10} className="z-(--z-tooltip)">
<Tooltip.Popup className={tipClass}>
{label}
{detail}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
);
}
/* -------------------------------------------------------------------------------------------------
* Header, content, group, footer
* -----------------------------------------------------------------------------------------------*/
export type IconRailHeaderProps = Omit<React.ComponentProps<"div">, "title"> & {
/** A 32px mark that stays put in both states: a logo or a workspace tile. */
mark: React.ReactNode;
/** Shown beside the mark when expanded. */
title?: React.ReactNode;
};
export function IconRailHeader({ mark, title, className, children, ...rest }: IconRailHeaderProps) {
return (
<div className={cn("flex h-12 shrink-0 items-center gap-2 px-2", className)} {...rest}>
<span className="grid size-8 shrink-0 place-items-center">{mark}</span>
{title && <span className={cn("min-w-0 flex-1 truncate whitespace-nowrap text-[13px] font-medium tracking-[-0.01em]", fadeLabel)}>{title}</span>}
{children}
</div>
);
}
type WashContextValue = { show: (el: HTMLElement) => void; hide: () => void };
const WashContext = createContext<WashContextValue | null>(null);
export type IconRailContentProps = React.ComponentProps<"nav">;
/** The labeled nav landmark holding the items. ↑ ↓ Home End move between them. */
export function IconRailContent({ className, children, onKeyDown, "aria-label": label = "Main", ...rest }: IconRailContentProps) {
const { navId } = useRail("IconRailContent");
const reduce = !!useReducedMotion();
const listRef = useRef<HTMLDivElement>(null);
const y = useMotionValue(0);
const [on, setOn] = useState(false);
const shown = useRef(false);
const hiddenAt = useRef(0);
// One wash for the list, as in the sidebar: it glides after the pointer and
// appears in place when the pointer first arrives.
const show = useCallback(
(el: HTMLElement) => {
const list = listRef.current;
if (!list) return;
const top = el.getBoundingClientRect().top - list.getBoundingClientRect().top;
if (!reduce && (shown.current || performance.now() - hiddenAt.current < 150)) animate(y, top, spring.follow);
else y.jump(top);
shown.current = true;
setOn(true);
},
[reduce, y],
);
const hide = useCallback(() => {
if (!shown.current) return;
shown.current = false;
hiddenAt.current = performance.now();
setOn(false);
}, []);
const wash = useMemo(() => ({ show, hide }), [show, hide]);
return (
<WashContext.Provider value={wash}>
<nav
id={navId}
aria-label={label}
className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-2 py-1 [scrollbar-width:none]", className)}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented || !["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
const rows = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-rail-row]") ?? [])].filter((el) => !el.matches(":disabled,[aria-disabled=true]"));
const at = rows.indexOf(e.target as HTMLElement);
if (at < 0) return;
e.preventDefault();
const next = e.key === "Home" ? 0 : e.key === "End" ? rows.length - 1 : Math.min(rows.length - 1, Math.max(0, at + (e.key === "ArrowDown" ? 1 : -1)));
rows[next]?.focus();
}}
{...rest}
>
<div ref={listRef} className="relative flex flex-col" onPointerLeave={hide}>
<motion.span
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-8 rounded-md bg-hover"
style={{ y }}
initial={false}
animate={{ opacity: on ? 1 : 0 }}
transition={{ duration: on ? 0.12 : 0.15, ease: "easeOut" }}
/>
{children}
</div>
</nav>
</WashContext.Provider>
);
}
export type IconRailGroupProps = React.ComponentProps<"div"> & { label?: string };
/**
* A labeled run of items. When the rail narrows, the label gives way to a short
* hairline in the same 28px row, so no icon moves vertically.
*/
export function IconRailGroup({ label, className, children, ...rest }: IconRailGroupProps) {
const id = useId();
return (
<div role="group" aria-labelledby={label ? id : undefined} className={cn("flex flex-col gap-px pt-2 first:pt-0", className)} {...rest}>
{label && (
<div className="relative flex h-7 items-center px-2">
<span id={id} className={cn("truncate whitespace-nowrap font-mono text-[10.5px] uppercase leading-none tracking-[0.08em] text-fg-3", fadeLabel)}>
{label}
</span>
<span
aria-hidden
className="absolute left-2.5 top-1/2 h-px w-3 bg-line-2 opacity-0 transition-opacity duration-150 group-data-collapsed/rail:opacity-100 group-data-collapsed/rail:delay-100 group-data-instant/rail:transition-none"
/>
</div>
)}
{children}
</div>
);
}
export type IconRailFooterProps = React.ComponentProps<"div">;
export function IconRailFooter({ className, ...rest }: IconRailFooterProps) {
return <div className={cn("flex shrink-0 flex-col gap-px border-t border-line p-2", className)} {...rest} />;
}
/* -------------------------------------------------------------------------------------------------
* Item
* -----------------------------------------------------------------------------------------------*/
export type IconRailItemProps = Omit<React.ComponentProps<"a">, "children"> & {
value: string;
/** The 16px icon: the only thing left when the rail is narrow. */
icon: React.ReactNode;
/** The label. Also the tooltip and the accessible name when narrow. */
children: string;
/** Rolls when it changes; becomes a dot on the icon when narrow. */
count?: number;
disabled?: boolean;
/** Your router's link element, e.g. <Link href="/inbox" />. */
render?: React.ReactElement;
};
export function IconRailItem({ value, icon, children, count, disabled = false, href, render, className, onClick, onPointerEnter, ...rest }: IconRailItemProps) {
const { collapsed, value: current, select, pill } = useRail("IconRailItem");
const wash = useContext(WashContext);
const reduce = useReducedMotion();
const active = current === value;
const hasCount = typeof count === "number" && count > 0;
const element = render ?? (href && !disabled ? <a href={href} /> : <button type="button" disabled={disabled} />);
return (
<RailTooltip label={children} detail={hasCount ? <span className="text-[11.5px] tabular text-fg-3">{count}</span> : undefined} disabled={!collapsed || disabled}>
{cloneWith(element, {
...rest,
"aria-current": active ? "page" : undefined,
"aria-disabled": disabled || undefined,
"data-rail-row": "",
"data-active": active || undefined,
"data-disabled": disabled || undefined,
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => {
if (disabled) return e.preventDefault();
onClick?.(e);
select(value);
},
onPointerEnter: (e: React.PointerEvent<HTMLAnchorElement>) => {
onPointerEnter?.(e);
if (e.pointerType === "touch") return;
if (disabled) wash?.hide();
else wash?.show(e.currentTarget);
},
className: cn(
"group/item relative flex h-8 w-full min-w-0 select-none items-center rounded-md pr-2 text-left text-[13px] text-fg-2 no-underline",
"transition-[color,scale,background-color] duration-150 active:scale-[0.97] active:duration-75 hover:text-fg data-active:text-fg",
"pointer-coarse:active:bg-hover data-disabled:pointer-events-none data-disabled:opacity-50",
// Narrow rows are 32px squares; on touch the hit area grows to 44px without changing the drawing.
"before:absolute before:-inset-x-2 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden",
!wash && "hover:bg-hover",
focusRing,
className,
),
children: (
<>
{active && (
<motion.span
layoutId={pill}
aria-hidden
className="absolute inset-0 rounded-md border border-line-2 bg-raised shadow-[var(--shadow)]"
transition={reduce ? { duration: 0 } : spring.snappy}
/>
)}
<span aria-hidden className="relative grid size-8 shrink-0 place-items-center text-fg-3 transition-colors duration-150 group-hover/item:text-fg-2 group-data-active/item:text-fg [&_svg]:size-4">
{icon}
{/* Narrow, the count becomes a dot on the icon; the number moves to the tooltip. */}
<span
className={cn(
"absolute right-1.5 top-1.5 size-1.5 scale-0 rounded-full bg-fg opacity-0 ring-2 ring-frame transition-[scale,opacity] duration-200 ease-out-expo",
hasCount && "group-data-collapsed/rail:scale-100 group-data-collapsed/rail:opacity-100 group-data-collapsed/rail:delay-100",
"group-data-instant/rail:transition-none group-data-active/item:ring-raised",
)}
/>
</span>
<span className={cn("relative min-w-0 flex-1 truncate whitespace-nowrap pl-0.5", fadeLabel)}>{children}</span>
{hasCount && (
<span className={cn("relative shrink-0 text-[11.5px] tabular text-fg-3 group-data-active/item:text-fg-2", fadeLabel)}>
<NumberFlow value={count} />
</span>
)}
</>
),
})}
</RailTooltip>
);
}
// Adds our props and classes to the caller's element (their Link, or our default
// anchor or button). Their own className is kept and ours goes first.
function cloneWith(element: React.ReactElement, props: Record<string, unknown>) {
const own = element.props as Record<string, unknown>;
const Type = element.type as React.ElementType;
return <Type {...own} {...props} className={cn(props.className as string, own.className as string)} />;
}
/* -------------------------------------------------------------------------------------------------
* Toggle
* -----------------------------------------------------------------------------------------------*/
export type IconRailToggleProps = Omit<React.ComponentProps<"button">, "children"> & {
/** Label while expanded. */
collapseLabel?: string;
/** Label while narrow; also its tooltip. */
expandLabel?: string;
};
/** The row that folds the rail. It shows the shortcut in its tooltip when narrow. */
export function IconRailToggle({ collapseLabel = "Collapse", expandLabel = "Expand sidebar", className, onClick, ...rest }: IconRailToggleProps) {
const { collapsed, toggle, navId, shortcut } = useRail("IconRailToggle");
const apple = useApple();
const keys = shortcut ? shortcut.split("+").map((k) => (k === "mod" ? (apple ? "⌘" : "Ctrl") : k === "shift" ? "⇧" : k === "alt" ? (apple ? "⌥" : "Alt") : k.toUpperCase())) : [];
const label = collapsed ? expandLabel : collapseLabel;
return (
<RailTooltip
label={label}
disabled={!collapsed}
detail={
keys.length ? (
<kbd className="flex gap-0.5">
{keys.map((k) => (
<span key={k} className="grid h-[18px] min-w-[18px] place-items-center rounded-[5px] border border-line-2 bg-frame px-1 font-mono text-[10.5px] leading-none text-fg-2">
{k}
</span>
))}
</kbd>
) : undefined
}
>
<button
type="button"
aria-label={label}
aria-expanded={!collapsed}
aria-controls={navId}
aria-keyshortcuts={shortcut ? shortcut.replace(/mod/i, apple ? "Meta" : "Control").replace(/\b\w/g, (c) => c.toUpperCase()) : undefined}
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) toggle("pointer");
}}
className={cn(
"group/item relative flex h-8 w-full min-w-0 select-none items-center rounded-md pr-2 text-left text-[13px] text-fg-3",
"transition-[color,scale,background-color] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75",
"before:absolute before:-inset-x-2 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden",
focusRing,
className,
)}
{...rest}
>
<span aria-hidden className="grid size-8 shrink-0 place-items-center">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-[rotate] duration-260 ease-in-out-quart group-data-collapsed/rail:rotate-180 group-data-instant/rail:transition-none"
>
<path d="M8.25 4.5 4.75 8l3.5 3.5M11.75 4.5 8.25 8l3.5 3.5" />
</svg>
</span>
<span aria-hidden className={cn("min-w-0 flex-1 truncate whitespace-nowrap pl-0.5", fadeLabel)}>
{collapseLabel}
</span>
{keys.length > 0 && (
<kbd aria-hidden className={cn("flex shrink-0 gap-0.5 font-mono text-[10.5px] text-fg-4 pointer-coarse:hidden", fadeLabel)}>
{keys.map((k) => (
<span key={k}>{k}</span>
))}
</kbd>
)}
</button>
</RailTooltip>
);
}05Props
IconRail
| Prop | Type | Default | Description |
|---|---|---|---|
| collapsed | boolean | — | Controlled narrow state. |
| defaultCollapsed | boolean | false | Initial narrow state when uncontrolled. |
| onCollapsedChange | (collapsed: boolean) => void | — | Called when the toggle or the shortcut folds the rail. Persist it here if you want it remembered. |
| value | string | null | — | The active item's value, usually the current route. |
| defaultValue | string | null | null | The active item when uncontrolled. |
| onValueChange | (value: string | null) => void | — | Called when an item is pressed. |
| shortcut | string | false | "mod+b" | Keyboard toggle; mod is ⌘ on Apple devices and Ctrl elsewhere. Ignored while typing in a field. |
| expandedWidth | number | 220 | Width in px with labels. |
| collapsedWidth | number | 48 | Width in px with icons only. |
IconRailItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | Identifies the item; it is active when it equals the rail's value. |
| icon* | ReactNode | — | The 16px icon, all that remains when narrow. |
| children* | string | — | The label, which also becomes the tooltip and accessible name when narrow. |
| count | number | — | Rolls when it changes. Narrow, it becomes a dot on the icon and the number moves into the tooltip. |
| href | string | — | Renders a link instead of a button. |
| render | ReactElement | — | Your router's link, e.g. <Link href />. |
| disabled | boolean | false | Dims the item and removes it from the tab order and arrow keys. |
IconRailHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| mark* | ReactNode | — | A logo or workspace tile, centered in a 32px cell that never moves. |
| title | ReactNode | — | Shown beside the mark when expanded. |
IconRailGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Section label. Narrow, it becomes a short hairline in the same row. |
IconRailToggle
| Prop | Type | Default | Description |
|---|---|---|---|
| collapseLabel | string | "Collapse" | Visible label while expanded. |
| expandLabel | string | "Expand sidebar" | Accessible name and tooltip while narrow. |
IconRailContent, IconRailFooter
| Prop | Type | Default | Description |
|---|---|---|---|
| ...props | ComponentProps<'nav' | 'div'> | — | The scrolling nav landmark (name it with aria-label) and the fixed bottom region. |
06Notes
Behavior
- Icons sit in fixed 32px cells and every row keeps its height, so folding only changes the width: nothing moves vertically and the eye stays on the icon it was on.
- Tooltips are disabled while expanded, where the label is already visible, and warm up as a group once narrow: the first waits 400ms, neighbors open on contact.
- The shortcut is ignored in inputs, textareas and contenteditable, and never fires on key repeat. Its keys show in the toggle row and its tooltip; they are hidden on touch.
- Long labels truncate; the tooltip always carries the full label.
Motion
- Width eases over 260ms on the in-out quart. Labels fade and slide 4px toward their icons in 100ms on the way in, and return after a 70ms delay over 200ms so they arrive into space that exists.
- A toggle from the keyboard renders on the same frame; only pointer toggles animate.
- The active pill springs between items (snappy spring, 520/38), the hover wash follows the pointer (650/45), and counts roll with NumberFlow. Reduced motion makes the width change and pill instant.
Accessibility
- The toggle is a button with aria-expanded, aria-controls pointing at the nav, and aria-keyshortcuts. Its name switches between Collapse and Expand sidebar.
- Labels stay in the DOM when narrow, so every item keeps its accessible name; tooltips also open on keyboard focus.
- The active item carries aria-current="page". ↑ ↓ Home End move between items; Tab still reaches each one.