- Revisar el onboarding
- Nuevo icono de la app
- Migrar pagos a la v2
- Modo oscuro en ajustes
Barre latérale rétractable
Une barre latérale façon Linear qui se replie en icônes avec la touche [ et affiche alors des infobulles.
Réglages
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: "Boîte de réception", icon: Inbox, badge: 3 },
{ label: "Mes tâches", icon: CircleDot },
{ label: "Projets", icon: Box },
{ label: "Vues", icon: Layers },
];
const SIDEBAR_TEAMS = [
{ label: "Design", color: "#f2994a" },
{ label: "Ingénierie", color: "#26b5ce" },
];
const SIDEBAR_ISSUES = [
{ id: "AIM-142", title: "Revoir l’onboarding", status: "progress" },
{ id: "AIM-139", title: "Nouvelle icône d’app", status: "todo" },
{ id: "AIM-131", title: "Migrer les paiements vers la v2", status: "done" },
{ id: "AIM-128", title: "Mode sombre dans les réglages", status: "progress" },
{ id: "AIM-120", title: "Nettoyer les tokens de couleur", status: "todo" },
{ id: "AIM-117", title: "Raccourcis clavier", 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("Boîte de réception");
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="Espace de travail" 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]"
>
Équipes
</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 ? "Développer la barre latérale" : "Réduire la barre latérale"}
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"
>
Réduire
<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 pour l’IA
Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.
Plus dans Navigation
Où l’utiliser
Cette sidebar rétractable en React est pensée pour les outils où l’on passe la journée : un gestionnaire de projets, un CRM, le back-office d’un SaaS. Repliée, elle ne garde que les icônes et laisse de la place au contenu ; le badge des non-lus devient un petit point et chaque entrée affiche son nom en infobulle.
Le raccourci [ écoute toute la page, vérifiez donc que votre application n’utilise pas déjà cette touche. Si vos utilisateurs travaillent surtout sur portable, démarrez-la repliée. Sur mobile, une barre latérale est rarement la bonne réponse ; une barre en bas ou un tiroir marche mieux.