9b6638eb00
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>
204 lines
7.7 KiB
TypeScript
204 lines
7.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|