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

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.