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

Sidebar colapsable

Barra lateral estilo Linear que se pliega a iconos con la tecla [, con un indicador que se desliza y tooltips al plegarla.

Ajustes

Código

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: "Bandeja", icon: Inbox, badge: 3 },
  { label: "Mis tareas", icon: CircleDot },
  { label: "Proyectos", icon: Box },
  { label: "Vistas", icon: Layers },
];

const SIDEBAR_TEAMS = [
  { label: "Diseño", color: "#f2994a" },
  { label: "Ingeniería", color: "#26b5ce" },
];

const SIDEBAR_ISSUES = [
  { id: "AIM-142", title: "Revisar el onboarding", status: "progress" },
  { id: "AIM-139", title: "Nuevo icono de la app", status: "todo" },
  { id: "AIM-131", title: "Migrar pagos a la v2", status: "done" },
  { id: "AIM-128", title: "Modo oscuro en ajustes", status: "progress" },
  { id: "AIM-120", title: "Limpiar tokens de color", status: "todo" },
  { id: "AIM-117", title: "Atajos de teclado", 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("Bandeja");

  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="Espacio de trabajo" 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]"
            >
              Equipos
            </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 ? "Expandir barra lateral" : "Contraer barra lateral"}
          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"
          >
            Contraer
            <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 para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Navegación

Dónde usarlo

Esta barra lateral colapsable en React está pensada para herramientas en las que la gente pasa el día: un gestor de proyectos, un CRM, el panel de administración de un SaaS. Plegada deja solo los iconos y da aire al contenido; el contador de pendientes se convierte en un puntito y cada entrada enseña su nombre en un tooltip.

El atajo [ escucha en toda la página, así que comprueba que tu app no use ya esa tecla. Si tu gente trabaja sobre todo en portátil, arráncala plegada. En el móvil una sidebar rara vez es la respuesta; suele ir mejor una barra inferior o un cajón.