- Revisar el onboarding
- Nuevo icono de la app
- Migrar pagos a la v2
- Modo oscuro en ajustes
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.