Ajoute une couche de micro-animations sur tout le site
Deploy / deploy (push) Successful in 1m13s

Deux courbes partagées dans lib/motion.ts, pour que le CSS et Framer
aient le même feeling, et quatre primitives que Tailwind ne sait pas
exprimer : reflet de bouton, halo qui suit le curseur, soulignement qui
se dessine, secousse d'erreur.

Nouveaux composants : Stagger (les collections se distribuent une par
une), Spotlight (trouve son parent tout seul, donc les cartes restent
des composants serveur), ScrollProgress, BackToTop, Parallax, Spinner,
CheckMark. Reveal accepte maintenant une direction.

Côté rendu : le trait de nav glisse d'un onglet à l'autre, les cartes
se soulèvent, le hero réagit au scroll, les piliers entrent en zig-zag,
la lightbox glisse dans le sens demandé et les formulaires répondent.

prefers-reduced-motion est respecté partout : Framer coupe via
useReducedMotion, le CSS via la media query déjà en place.

Le commit embarque aussi la réorganisation des composants par domaine
qui était en cours dans l'arbre de travail — les deux étaient trop
imbriquées pour être séparées proprement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 16:04:41 +02:00
parent 8fd9ca88cc
commit 9b6638eb00
66 changed files with 3938 additions and 1469 deletions
+203
View File
@@ -0,0 +1,203 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Logo } from "../ui/Logo";
import { ScrollProgress } from "../ui/ScrollProgress";
import { NAV_LINKS, STUDIO } from "@/lib/site";
import { EASE_OUT_EXPO } from "@/lib/motion";
export function Navbar() {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
// The drawer stores the route it was opened on, so any navigation closes it
// by derivation — no effect syncing state to the router.
const [openedOn, setOpenedOn] = useState<string | null>(null);
const open = openedOn === pathname;
const close = () => setOpenedOn(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Lock the page and allow Escape to dismiss while the drawer is open.
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpenedOn(null);
};
document.addEventListener("keydown", onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [open]);
const isActive = (href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
scrolled || open
? "border-b border-border bg-background/85 backdrop-blur-md"
: "border-b border-transparent bg-transparent"
}`}
>
<nav className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 lg:h-20 lg:px-10">
<Link
href="/"
aria-label={`${STUDIO.name} home`}
className="group flex items-center gap-3"
>
<Logo
width={44}
priority
className="transition-transform duration-300 ease-out group-hover:-translate-y-0.5 group-hover:scale-105"
/>
<span className="hidden font-display text-xl tracking-wider transition-colors group-hover:text-accent sm:block">
{STUDIO.shortName}
</span>
</Link>
<ul className="hidden items-center gap-1 md:flex">
{NAV_LINKS.map((link) => {
const active = isActive(link.href);
return (
<li key={link.href}>
<Link
href={link.href}
aria-current={active ? "page" : undefined}
className={`group/nav relative px-3 py-2 text-sm tracking-wide transition-colors ${
active
? "text-accent"
: "text-foreground-muted hover:text-foreground"
}`}
>
{link.label}
{/* Hover hairline for the pages you are not on. Sits under
the active marker, so the two never stack visibly. */}
{!active && (
<span
aria-hidden
className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-foreground-muted/60 transition-transform duration-300 ease-out group-hover/nav:scale-x-100"
/>
)}
{/* One shared element for every link: Framer moves it from
the old tab to the new one instead of cross-fading two
separate underlines. */}
{active && (
<motion.span
layoutId="nav-active"
aria-hidden
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className="absolute inset-x-3 -bottom-0.5 h-px bg-accent"
/>
)}
</Link>
</li>
);
})}
</ul>
<button
type="button"
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
aria-controls="mobile-menu"
onClick={() => setOpenedOn(open ? null : pathname)}
className="relative flex h-10 w-10 items-center justify-center text-foreground transition-transform duration-200 hover:text-accent active:scale-90 md:hidden"
>
<span className="relative flex h-4 w-6 flex-col justify-between">
<motion.span
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
transition={{ duration: 0.25 }}
className="block h-px origin-center bg-current"
/>
<motion.span
animate={{ opacity: open ? 0 : 1 }}
transition={{ duration: 0.15 }}
className="block h-px bg-current"
/>
<motion.span
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
transition={{ duration: 0.25 }}
className="block h-px origin-center bg-current"
/>
</span>
</button>
</nav>
<AnimatePresence>
{open && (
<motion.div
id="mobile-menu"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.25, ease: EASE_OUT_EXPO }}
className="border-t border-border bg-background/95 backdrop-blur-md md:hidden"
>
{/* The rows deal out one by one. The drawer is the only place on
the site where a whole navigation appears at once, and a block
of four identical lines arriving together reads as a flash. */}
<motion.ul
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.05 } },
}}
className="flex flex-col px-6 py-4"
>
{NAV_LINKS.map((link) => (
<motion.li
key={link.href}
variants={{
hidden: { opacity: 0, x: -12 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.3, ease: EASE_OUT_EXPO },
},
}}
>
<Link
href={link.href}
onClick={close}
aria-current={isActive(link.href) ? "page" : undefined}
className={`group flex items-center justify-between border-b border-border py-4 font-display text-2xl tracking-wider transition-colors last:border-b-0 ${
isActive(link.href)
? "text-accent"
: "text-foreground hover:text-accent"
}`}
>
{link.label.toUpperCase()}
<span
aria-hidden
className="text-base opacity-0 transition-all duration-300 ease-out group-hover:translate-x-1 group-hover:opacity-100"
>
</span>
</Link>
</motion.li>
))}
</motion.ul>
</motion.div>
)}
</AnimatePresence>
<ScrollProgress />
</header>
);
}