Bandeja4
  • Revisar el onboarding
  • Nuevo icono de la app
  • Migrar pagos a la v2
  • Modo oscuro en ajustes
React + MotionNavigation

Collapsible sidebar

A Linear‑style sidebar that folds down to icons with the [ key, with a sliding active marker and tooltips when folded.

Settings

Code

import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Box, CircleCheck, CircleDashed, CircleDot, Inbox, Layers, PanelLeft } from "lucide-react";
import type { LucideIcon } from "lucide-react";

const SIDEBAR_NAV: { label: string; icon: LucideIcon; badge?: number }[] = [
  { label: "Inbox", icon: Inbox, badge: 3 },
  { label: "My tasks", icon: CircleDot },
  { label: "Projects", icon: Box },
  { label: "Views", icon: Layers },
];

const SIDEBAR_TEAMS = [
  { label: "Design", color: "#f2994a" },
  { label: "Engineering", color: "#26b5ce" },
];

const SIDEBAR_ISSUES = [
  { id: "AIM-142", title: "Review onboarding", status: "progress" },
  { id: "AIM-139", title: "New app icon", status: "todo" },
  { id: "AIM-131", title: "Migrate payments to v2", status: "done" },
  { id: "AIM-128", title: "Dark mode in settings", status: "progress" },
  { id: "AIM-120", title: "Clean up color tokens", status: "todo" },
  { id: "AIM-117", title: "Keyboard shortcuts", status: "done" },
];

const SIDEBAR_SPRING = { type: "spring", stiffness: 420, damping: 38 } as const;

interface SidebarItemProps {
  label: string;
  icon?: LucideIcon;
  dot?: string;
  badge?: number;
  active: boolean;
  collapsed: boolean;
  accent: string;
  onSelect: () => void;
}

function SidebarItem({
  label,
  icon: Icon,
  dot,
  badge,
  active,
  collapsed,
  accent,
  onSelect,
}: SidebarItemProps) {
  const [tip, setTip] = useState(false);
  return (
    <div
      className="relative"
      onPointerEnter={() => setTip(true)}
      onPointerLeave={() => setTip(false)}
    >
      <button
        type="button"
        onClick={onSelect}
        onFocus={() => setTip(true)}
        onBlur={() => setTip(false)}
        aria-label={collapsed ? label : undefined}
        aria-current={active ? "page" : undefined}
        className={
          "relative flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-[13px] outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-black/15 active:scale-[0.98] dark:focus-visible:ring-white/20 " +
          (active
            ? "font-medium text-[#1d1d1f] dark:text-[#f5f5f7]"
            : "text-[#5f5f66] hover:bg-black/[0.04] hover:text-[#1d1d1f] dark:text-[#a1a1aa] dark:hover:bg-white/[0.05] dark:hover:text-[#f5f5f7]")
        }
      >
        {active && (
          <motion.span
            layoutId="linear-sidebar-active"
            className="absolute inset-0 rounded-lg bg-white shadow-[0_1px_2px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.04)] dark:bg-white/[0.08] dark:shadow-none"
            transition={SIDEBAR_SPRING}
          />
        )}
        {Icon ? (
          <Icon
            className="relative h-4 w-4 shrink-0"
            strokeWidth={1.5}
            style={{ color: active ? accent : undefined }}
          />
        ) : (
          <span
            className="relative mx-[3px] h-2.5 w-2.5 shrink-0 rounded-[3px]"
            style={{ backgroundColor: dot }}
          />
        )}
        <motion.span
          initial={false}
          animate={{ opacity: collapsed ? 0 : 1 }}
          transition={{ duration: 0.15 }}
          className="relative flex-1 truncate whitespace-nowrap text-left"
        >
          {label}
        </motion.span>
        {badge ? (
          <>
            <motion.span
              initial={false}
              animate={{ opacity: collapsed ? 0 : 1, scale: collapsed ? 0.5 : 1 }}
              transition={SIDEBAR_SPRING}
              className="relative grid h-[18px] min-w-[18px] place-items-center rounded-full px-1 text-[10px] font-semibold tabular-nums text-white"
              style={{ backgroundColor: accent }}
            >
              {badge}
            </motion.span>
            <motion.span
              aria-hidden
              initial={false}
              animate={{ opacity: collapsed ? 1 : 0, scale: collapsed ? 1 : 0 }}
              transition={SIDEBAR_SPRING}
              className="absolute left-[23px] top-[5px] h-2 w-2 rounded-full ring-2 ring-[#f4f4f5] dark:ring-[#0e0e10]"
              style={{ backgroundColor: accent }}
            />
          </>
        ) : null}
      </button>
      <AnimatePresence>
        {collapsed && tip && (
          <motion.span
            role="tooltip"
            initial={{ opacity: 0, x: -4, y: "-50%", scale: 0.96 }}
            animate={{ opacity: 1, x: 0, y: "-50%", scale: 1 }}
            exit={{ opacity: 0, x: -4, y: "-50%", scale: 0.96 }}
            transition={{ type: "spring", stiffness: 600, damping: 36 }}
            className="pointer-events-none absolute left-full top-1/2 z-30 ml-3 flex items-center gap-1.5 whitespace-nowrap rounded-lg bg-[#1d1d1f] px-2.5 py-1.5 text-[12px] font-medium text-white shadow-[0_8px_24px_rgba(0,0,0,0.18)] dark:bg-[#f5f5f7] dark:text-[#1d1d1f]"
          >
            {label}
            {badge ? <span className="opacity-50">{badge}</span> : null}
          </motion.span>
        )}
      </AnimatePresence>
    </div>
  );
}

function IssueStatus({ status, accent }: { status: string; accent: string }) {
  if (status === "done")
    return <CircleCheck className="h-4 w-4 shrink-0" strokeWidth={1.5} style={{ color: accent }} />;
  if (status === "progress")
    return <CircleDot className="h-4 w-4 shrink-0 text-[#e2a93b]" strokeWidth={1.5} />;
  return <CircleDashed className="h-4 w-4 shrink-0 text-[#aeaeb2]" strokeWidth={1.5} />;
}

interface LinearSidebarProps {
  accent?: string;
  startCollapsed?: boolean;
}

export default function LinearSidebar({
  accent = "#5e6ad2",
  startCollapsed = false,
}: LinearSidebarProps) {
  const [collapsed, setCollapsed] = useState(startCollapsed);
  const [active, setActive] = useState("Inbox");

  useEffect(() => setCollapsed(startCollapsed), [startCollapsed]);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement | null;
      if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
      if (e.key === "[") setCollapsed((c) => !c);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  const all = [...SIDEBAR_NAV.map((n) => n.label), ...SIDEBAR_TEAMS.map((t) => t.label)];
  const offset = Math.max(0, all.indexOf(active)) % 3;
  const issues = SIDEBAR_ISSUES.slice(offset, offset + 4);

  return (
    <div className="flex h-full w-full overflow-hidden bg-[#f4f4f5] text-[#1d1d1f] dark:bg-[#0e0e10] dark:text-[#f5f5f7]">
      <motion.aside
        initial={false}
        animate={{ width: collapsed ? 52 : 172 }}
        transition={SIDEBAR_SPRING}
        className="relative z-10 flex h-full shrink-0 flex-col gap-0.5 p-2"
      >
        <div className="mb-3 flex h-9 items-center gap-2 overflow-hidden px-1.5">
          <span
            className="grid h-6 w-6 shrink-0 place-items-center rounded-[7px] text-[12px] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.25)]"
            style={{ backgroundColor: accent }}
          >
            A
          </span>
          <motion.span
            initial={false}
            animate={{ opacity: collapsed ? 0 : 1 }}
            transition={{ duration: 0.15 }}
            className="whitespace-nowrap text-[13px] font-semibold tracking-[-0.01em]"
          >
            Aimran
          </motion.span>
        </div>

        <nav aria-label="Workspace" className="flex flex-col gap-0.5">
          {SIDEBAR_NAV.map((item) => (
            <SidebarItem
              key={item.label}
              label={item.label}
              icon={item.icon}
              {...(item.badge ? { badge: item.badge } : {})}
              active={active === item.label}
              collapsed={collapsed}
              accent={accent}
              onSelect={() => setActive(item.label)}
            />
          ))}
          <div className="relative mb-1 mt-4 h-4 px-2.5">
            <motion.p
              initial={false}
              animate={{ opacity: collapsed ? 0 : 1 }}
              className="whitespace-nowrap text-[11px] font-medium text-[#8e8e93]"
            >
              Teams
            </motion.p>
            <motion.div
              initial={false}
              animate={{ opacity: collapsed ? 1 : 0 }}
              className="absolute inset-x-2.5 top-2 h-px bg-black/[0.08] dark:bg-white/[0.08]"
            />
          </div>
          {SIDEBAR_TEAMS.map((team) => (
            <SidebarItem
              key={team.label}
              label={team.label}
              dot={team.color}
              active={active === team.label}
              collapsed={collapsed}
              accent={accent}
              onSelect={() => setActive(team.label)}
            />
          ))}
        </nav>

        <button
          type="button"
          onClick={() => setCollapsed((c) => !c)}
          aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
          aria-expanded={!collapsed}
          className="mt-auto flex h-8 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 text-[12px] text-[#8e8e93] outline-none transition-colors hover:bg-black/[0.04] hover:text-[#1d1d1f] focus-visible:ring-2 focus-visible:ring-black/15 dark:hover:bg-white/[0.05] dark:hover:text-[#f5f5f7] dark:focus-visible:ring-white/20"
        >
          <PanelLeft className="h-4 w-4 shrink-0" strokeWidth={1.5} />
          <motion.span
            initial={false}
            animate={{ opacity: collapsed ? 0 : 1 }}
            transition={{ duration: 0.15 }}
            className="flex flex-1 items-center justify-between whitespace-nowrap"
          >
            Collapse
            <kbd className="rounded-[5px] border border-black/10 bg-white px-1.5 font-mono text-[10px] leading-4 dark:border-white/10 dark:bg-white/5">
              [
            </kbd>
          </motion.span>
        </button>
      </motion.aside>

      <main className="my-2 mr-2 flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-white shadow-[0_1px_2px_rgba(0,0,0,0.05),0_0_0_1px_rgba(0,0,0,0.04)] dark:bg-[#18181b] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.06)]">
        <header className="flex h-11 shrink-0 items-center gap-2 border-b border-black/[0.06] px-4 text-[13px] font-medium dark:border-white/[0.06]">
          <span className="truncate">{active}</span>
          <span className="rounded-md bg-black/[0.05] px-1.5 text-[11px] tabular-nums text-[#8e8e93] dark:bg-white/10">
            {issues.length}
          </span>
        </header>
        <AnimatePresence mode="wait" initial={false}>
          <motion.ul
            key={active}
            initial={{ opacity: 0, y: 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -4 }}
            transition={{ duration: 0.18 }}
            className="flex-1 overflow-y-auto"
          >
            {issues.map((issue) => (
              <li
                key={issue.id}
                className="flex items-center gap-3 border-b border-black/[0.04] px-4 py-2.5 text-[13px] transition-colors hover:bg-black/[0.02] dark:border-white/[0.04] dark:hover:bg-white/[0.02]"
              >
                <IssueStatus status={issue.status} accent={accent} />
                <span className="truncate">{issue.title}</span>
              </li>
            ))}
          </motion.ul>
        </AnimatePresence>
      </main>
    </div>
  );
}

Prompt for AI

Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.

More in Navigation

Where to use it

This collapsible sidebar in React is made for tools people spend the whole day in: a project tracker, a CRM, the admin panel of a SaaS. Folded, it leaves just the icons and gives the content room; the unread badge turns into a small dot and every entry shows its name in a tooltip.

The [ shortcut listens on the whole page, so check your app does not already use that key. Start folded if your users mostly work on laptops. On phones a sidebar is rarely the answer; a bottom bar or a drawer usually works better.