All patterns

Sidebar Collapse

A navigation rail narrows to icons, with labels leaving before the width closes so text is never squeezed.

navigationminimalpremiuminteraction · finite · intermediate · ~0.4s
Interactive · click to play
Variant

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.

416 lines · react + motion only
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.

Workspace navigation railDashboard side navigationAdmin console chromeDocument app sidebar

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Issue tracker

    The left navigation collapses to a narrow icon rail and back.

Related patterns