Sidebar Collapse
A navigation rail narrows to icons, with labels leaving before the width closes so text is never squeezed.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Sidebar Collapse
*
* A navigation rail that narrows to icons and back. The labels fade out
* *before* the width starts closing, and fade back in only once it has
* finished opening — so text is never caught being squeezed by its own
* container.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the rail reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `defaultCollapsed`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SidebarCollapseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Rail state on first render. */
defaultCollapsed?: boolean;
/** Notified with the new collapsed state. */
onCollapsedChange?: (collapsed: boolean) => void;
};
type VariantConfig = {
/** Seconds for the width tween. */
width: number;
/** Seconds for the label fade that brackets it. */
labelFade: number;
chevron: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: width is the one property here that genuinely has to
// animate — it *is* the motion — so it runs as a short eased tween rather
// than a spring, because a spring on width would overshoot the rail past
// its own resting size. The chevron spring stays at/above a 0.8 damping
// ratio. Variants change the pace, never the wobble.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely noticeable — for chrome that gets toggled all day.
subtle: {
width: 0.2,
labelFade: 0.07,
chevron: { type: "spring", stiffness: 580, damping: 48 },
},
// The all-purpose setting: fast enough to feel instant, slow enough
// to read as one object changing size.
default: {
width: 0.3,
labelFade: 0.1,
chevron: { type: "spring", stiffness: 440, damping: 38 },
},
// A touch more travel time so the rail reads as a deliberate gesture.
playful: {
width: 0.4,
labelFade: 0.13,
chevron: { type: "spring", stiffness: 380, damping: 33 },
},
};
const EXPANDED_WIDTH = 186;
const COLLAPSED_WIDTH = 62;
/** Chosen so a 17px icon lands on the centre line of the collapsed rail:
* 22 + 17/2 ≈ 62/2. Every row uses it, so nothing shifts sideways when
* the width changes — the icons are the fixed point the eye holds on to. */
const RAIL_PADDING = 22;
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type IconName = "overview" | "documents" | "analytics" | "customers" | "settings";
function NavIcon({ name }: { name: IconName }) {
const common = {
width: 17,
height: 17,
viewBox: "0 0 20 20",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
if (name === "overview") {
return (
<svg {...common}>
<rect x="2.5" y="2.5" width="6" height="6" rx="1.5" />
<rect x="11.5" y="2.5" width="6" height="6" rx="1.5" />
<rect x="2.5" y="11.5" width="6" height="6" rx="1.5" />
<rect x="11.5" y="11.5" width="6" height="6" rx="1.5" />
</svg>
);
}
if (name === "documents") {
return (
<svg {...common}>
<path d="M11.5 2.5H5.5a1.5 1.5 0 0 0-1.5 1.5v12a1.5 1.5 0 0 0 1.5 1.5h9a1.5 1.5 0 0 0 1.5-1.5V7z" />
<path d="M11.5 2.5V7H16" />
</svg>
);
}
if (name === "analytics") {
return (
<svg {...common}>
<path d="M3 16.5h14" />
<path d="M6 16.5V10" />
<path d="M10 16.5V4.5" />
<path d="M14 16.5v-4" />
</svg>
);
}
if (name === "customers") {
return (
<svg {...common}>
<circle cx="10" cy="7" r="3" />
<path d="M4 16.5c0-2.8 2.7-4.5 6-4.5s6 1.7 6 4.5" />
</svg>
);
}
return (
<svg {...common}>
<path d="M3 6h14" />
<path d="M3 14h14" />
<circle cx="7.5" cy="6" r="2" />
<circle cx="12.5" cy="14" r="2" />
</svg>
);
}
const NAV: readonly { label: string; icon: IconName; badge?: string }[] = [
{ label: "Overview", icon: "overview" },
{ label: "Documents", icon: "documents", badge: "12" },
{ label: "Analytics", icon: "analytics" },
{ label: "Customers", icon: "customers" },
{ label: "Settings", icon: "settings" },
];
const ROWS: readonly { name: string; meta: string }[] = [
{ name: "Q3 revenue summary", meta: "Edited 4m ago" },
{ name: "Vendor agreement v4", meta: "Edited yesterday" },
{ name: "Support volume report", meta: "Edited Mon" },
];
export default function SidebarCollapse({
variant = "default",
defaultCollapsed = false,
onCollapsedChange,
}: SidebarCollapseProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const [active, setActive] = useState(1);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The whole pattern is this pair of transitions. Collapsing: labels
// fade first, then the width closes behind them. Expanding: the width
// opens first, labels arrive after. Either way the glyphs are at zero
// opacity for every frame in which their container is the wrong size,
// so text is never seen squeezing, wrapping or clipping.
const widthTransition = reduceMotion
? { duration: 0 }
: {
duration: cfg.width,
ease: [0.32, 0.72, 0, 1] as const,
delay: collapsed ? cfg.labelFade : 0,
};
const labelTransition = reduceMotion
? { duration: 0 }
: {
duration: cfg.labelFade,
ease: "easeOut" as const,
delay: collapsed ? 0 : cfg.width * 0.6,
};
const toggle = () => {
const next = !collapsed;
setCollapsed(next);
onCollapsedChange?.(next);
};
const label = (text: string, extra?: CSSProperties) => (
<motion.span
initial={false}
animate={{ opacity: collapsed ? 0 : 1 }}
transition={labelTransition}
// Opacity only — and only opacity. Toggling `visibility` or
// unmounting the label would cut it instead of fading it, which is
// the exact artifact this pattern exists to avoid; sliding or
// scaling it would put moving text on screen, which is the other
// one. Once faded, the label is clipped by the rail's own overflow,
// and each row carries an aria-label so the icon keeps its name.
style={{
whiteSpace: "nowrap",
pointerEvents: collapsed ? "none" : "auto",
...extra,
}}
>
{text}
</motion.span>
);
return (
<div
style={{
display: "flex",
width: 424,
height: 262,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 10px 28px rgba(0,0,0,0.16)",
overflow: "hidden",
}}
>
<motion.nav
aria-label="Workspace"
initial={false}
animate={{ width: collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH }}
transition={widthTransition}
style={{
flexShrink: 0,
display: "flex",
flexDirection: "column",
gap: 2,
padding: "14px 0",
borderRight: `1px solid ${tone(12)}`,
background: tone(4),
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: `0 ${RAIL_PADDING}px`,
height: 30,
marginBottom: 8,
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: 17,
height: 17,
borderRadius: 5,
background: ACCENT,
}}
/>
{label("Northwind", { fontSize: 13, fontWeight: 650 })}
</div>
{NAV.map((item, index) => {
const isActive = index === active;
return (
<button
key={item.label}
type="button"
onClick={() => setActive(index)}
aria-current={isActive ? "page" : undefined}
// The label is hidden at narrow width, so the icon-only row
// still needs a name.
aria-label={item.label}
title={collapsed ? item.label : undefined}
style={{
display: "flex",
alignItems: "center",
gap: 11,
height: 32,
margin: "0 8px",
padding: `0 ${RAIL_PADDING - 8}px`,
borderRadius: 8,
border: 0,
background: isActive ? tone(10) : "transparent",
color: isActive ? ACCENT : "inherit",
opacity: isActive ? 1 : 0.72,
fontSize: 12.5,
fontWeight: isActive ? 600 : 500,
fontFamily: "inherit",
textAlign: "left",
cursor: "pointer",
}}
>
<span style={{ flexShrink: 0, display: "grid" }}>
<NavIcon name={item.icon} />
</span>
{label(item.label, { flex: 1 })}
{item.badge
? label(item.badge, { fontSize: 11, opacity: 0.55 })
: null}
</button>
);
})}
</motion.nav>
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0 14px",
height: 46,
borderBottom: `1px solid ${tone(12)}`,
}}
>
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
cursor: "pointer",
}}
>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: collapsed ? 180 : 0 }}
transition={reduceMotion ? { duration: 0 } : cfg.chevron}
style={{ display: "grid" }}
>
<svg
width="14"
height="14"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11.5 5 6.5 10l5 5" />
<path d="M15 4.5v11" opacity="0.45" />
</svg>
</motion.span>
</button>
<div style={{ fontSize: 13, fontWeight: 650 }}>{NAV[active].label}</div>
</div>
<div style={{ padding: "6px 6px", overflow: "hidden" }}>
{ROWS.map((row) => (
<div
key={row.name}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 10px",
borderRadius: 10,
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: 24,
height: 24,
borderRadius: 7,
background: tone(10),
display: "grid",
placeItems: "center",
}}
>
<NavIcon name="documents" />
</span>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 12.5,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.name}
</span>
<span
style={{
display: "block",
fontSize: 11,
opacity: 0.5,
whiteSpace: "nowrap",
}}
>
{row.meta}
</span>
</span>
</div>
))}
</div>
</div>
</div>
);
}About this pattern
The rail that gives a workspace back its horizontal space. The mechanism is ordering, not easing: collapsing fades the labels out first and only then closes the width; expanding opens the width first and brings the labels back after. That single delay is the difference between a rail that feels engineered and one where words visibly compress, wrap and clip on the way in. Width is the one property that genuinely has to animate, so it runs as a short eased tween rather than a spring — a spring would overshoot the rail past its own resting size — while the icons hold the same centre line throughout, giving the eye a fixed anchor as everything else moves.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
The left navigation collapses to a narrow icon rail and back.
Related patterns
- Nested Menu DrillA submenu pushes the parent list aside inside the same panel, and the panel resizes to fit it.
- Split View ResizeDragging the divider resizes both panes live, and releasing it settles the split on the nearest stop.
- Accordion ExpandOne section opens as the previous one closes, both heights easing over the same beat so the list never jumps.