- Revisar el onboarding
- Nuevo icono de la app
- Migrar pagos a la v2
- Modo oscuro en ajustes
Einklappbare Sidebar
Eine Sidebar im Linear-Stil, die sich mit der Taste [ auf Icons zusammenklappt, mit gleitender Markierung und Tooltips im eingeklappten Zustand.
Einstellungen
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: "Eingang", icon: Inbox, badge: 3 },
{ label: "Meine Aufgaben", icon: CircleDot },
{ label: "Projekte", icon: Box },
{ label: "Ansichten", icon: Layers },
];
const SIDEBAR_TEAMS = [
{ label: "Design", color: "#f2994a" },
{ label: "Engineering", color: "#26b5ce" },
];
const SIDEBAR_ISSUES = [
{ id: "AIM-142", title: "Onboarding prüfen", status: "progress" },
{ id: "AIM-139", title: "Neues App-Symbol", status: "todo" },
{ id: "AIM-131", title: "Zahlungen auf v2 migrieren", status: "done" },
{ id: "AIM-128", title: "Dunkelmodus in den Einstellungen", status: "progress" },
{ id: "AIM-120", title: "Farb-Tokens aufräumen", status: "todo" },
{ id: "AIM-117", title: "Tastaturkurzbefehle", 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("Eingang");
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 ? "Seitenleiste erweitern" : "Seitenleiste einklappen"}
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"
>
Einklappen
<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 für KI
Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.
Mehr aus Navigation
Wofür du ihn nutzen kannst
Diese einklappbare Sidebar in React ist für Tools gedacht, in denen man den ganzen Tag arbeitet: ein Projekt-Tracker, ein CRM, das Admin-Panel einer SaaS. Eingeklappt bleiben nur die Icons und der Inhalt bekommt Platz; das Badge für Ungelesenes wird zu einem kleinen Punkt und jeder Eintrag zeigt seinen Namen im Tooltip.
Das Kürzel [ hört auf die ganze Seite, prüf also, ob deine App die Taste schon benutzt. Arbeiten deine Nutzer meist am Laptop, starte eingeklappt. Am Handy ist eine Sidebar selten die Lösung, eine Leiste unten oder eine Schublade funktioniert meist besser.