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