Header transparent vers solid au scroll avec backdrop blur, menu mobile fullscreen clip-path circulaire, hamburger animé vers X
Header premium avec transition transparent vers solid au scroll, navigation desktop, CTA, et menu mobile fullscreen avec animation clip-path circulaire et stagger sur les liens.
"use client";
import { useRef, useState, useEffect } from "react";
import { gsap } from "gsap";
import { useScrollDirection } from "@/hooks/useScrollDirection";
import { NAV_LINKS } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
import { MobileMenu } from "./MobileMenu";
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const headerRef = useRef<HTMLElement>(null);
const { scrollDirection, scrollY } = useScrollDirection();
const isScrolled = scrollY > 50;
const isHidden = scrollDirection === "down" && scrollY > 300 && !isMenuOpen;
// Bloquer le scroll quand le menu est ouvert
useEffect(() => {
if (isMenuOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => { document.body.style.overflow = ""; };
}, [isMenuOpen]);
return (
<>
<header
ref={headerRef}
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-500 ease-out-expo",
isScrolled
? "bg-white/80 dark:bg-neutral-950/80 backdrop-blur-xl shadow-sm border-b border-neutral-200/50 dark:border-neutral-800/50"
: "bg-transparent",
isHidden ? "-translate-y-full" : "translate-y-0"
)}
>
<div className="mx-auto max-w-[var(--container-max)] px-[var(--container-padding)]">
<div className="flex items-center justify-between h-20">
{/* Logo */}
<a href="/" className="relative z-50 flex items-center gap-2">
<span
className={cn(
"font-heading text-xl font-bold transition-colors duration-300",
isScrolled || isMenuOpen
? "text-neutral-900 dark:text-white"
: "text-white"
)}
>
MonSite
</span>
</a>
{/* Navigation Desktop */}
<nav className="hidden lg:flex items-center gap-1">
{NAV_LINKS.map((link) => (
<a
key={link.href}
href={link.href}
className={cn(
"px-4 py-2 text-sm font-medium rounded-lg transition-colors duration-300",
isScrolled
? "text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-neutral-800"
: "text-white/80 hover:text-white hover:bg-white/10"
)}
>
{link.label}
</a>
))}
</nav>
{/* CTA Desktop + Hamburger */}
<div className="flex items-center gap-4">
<div className="hidden lg:block">
<Button
size="sm"
className={cn(
"transition-all duration-300",
!isScrolled &&
"bg-white text-neutral-900 hover:bg-white/90"
)}
>
Nous contacter
</Button>
</div>
{/* Hamburger Mobile */}
<button
className="relative z-50 lg:hidden w-11 h-11 flex items-center justify-center"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? "Fermer le menu" : "Ouvrir le menu"}
aria-expanded={isMenuOpen}
>
<div className="w-6 h-4 flex flex-col justify-between">
<span
className={cn(
"block h-0.5 w-6 rounded-full transition-all duration-500 ease-out-expo origin-center",
isMenuOpen
? "rotate-45 translate-y-[7px] bg-neutral-900 dark:bg-white"
: isScrolled
? "bg-neutral-900 dark:bg-white"
: "bg-white"
)}
/>
<span
className={cn(
"block h-0.5 rounded-full transition-all duration-500 ease-out-expo",
isMenuOpen
? "w-0 opacity-0 bg-neutral-900"
: isScrolled
? "w-6 bg-neutral-900 dark:bg-white"
: "w-6 bg-white"
)}
/>
<span
className={cn(
"block h-0.5 w-6 rounded-full transition-all duration-500 ease-out-expo origin-center",
isMenuOpen
? "-rotate-45 -translate-y-[7px] bg-neutral-900 dark:bg-white"
: isScrolled
? "bg-neutral-900 dark:bg-white"
: "bg-white"
)}
/>
</div>
</button>
</div>
</div>
</div>
</header>
{/* Menu Mobile Fullscreen */}
<MobileMenu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} />
</>
);
}
"use client";
import { useRef, useEffect } from "react";
import { gsap } from "gsap";
import { NAV_LINKS, SOCIAL_LINKS } from "@/lib/constants";
import { Button } from "@/components/ui/Button";
interface MobileMenuProps {
isOpen: boolean;
onClose: () => void;
}
export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const linksRef = useRef<HTMLDivElement>(null);
const footerRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
useEffect(() => {
const ctx = gsap.context(() => {
// Créer la timeline une seule fois
timelineRef.current = gsap.timeline({ paused: true });
timelineRef.current
// Ouverture du fond avec clip-path circulaire
.to(menuRef.current, {
clipPath: "circle(150% at calc(100% - 2.5rem) 2.5rem)",
duration: 0.8,
ease: "expo.inOut",
})
// Stagger des liens
.from(
".mobile-menu__link",
{
y: 60,
opacity: 0,
duration: 0.6,
ease: "expo.out",
stagger: 0.08,
},
"-=0.3"
)
// Footer du menu
.from(
footerRef.current,
{
y: 30,
opacity: 0,
duration: 0.5,
ease: "expo.out",
},
"-=0.3"
);
}, menuRef);
return () => ctx.revert();
}, []);
// Jouer/inverser la timeline
useEffect(() => {
if (!timelineRef.current) return;
if (isOpen) {
timelineRef.current.play();
} else {
timelineRef.current.reverse();
}
}, [isOpen]);
return (
<div
ref={menuRef}
className="fixed inset-0 z-40 bg-white dark:bg-neutral-950 flex flex-col justify-center lg:hidden"
style={{ clipPath: "circle(0% at calc(100% - 2.5rem) 2.5rem)" }}
aria-hidden={!isOpen}
>
<div className="px-[var(--container-padding)] py-20">
{/* Liens principaux */}
<nav ref={linksRef} className="flex flex-col gap-2">
{NAV_LINKS.map((link, i) => (
<a
key={link.href}
href={link.href}
onClick={onClose}
className="mobile-menu__link group flex items-center justify-between py-4 border-b border-neutral-200 dark:border-neutral-800"
>
<span className="font-heading text-4xl sm:text-5xl font-bold text-neutral-900 dark:text-white group-hover:text-primary-600 transition-colors duration-300">
{link.label}
</span>
<span className="text-sm text-neutral-400 font-mono">
{String(i + 1).padStart(2, "0")}
</span>
</a>
))}
</nav>
{/* Footer du menu */}
<div ref={footerRef} className="mt-12 space-y-6">
<Button size="lg" className="w-full" onClick={onClose}>
Nous contacter
</Button>
<div className="flex gap-6">
{SOCIAL_LINKS.map((social) => (
<a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
{social.label}
</a>
))}
</div>
<p className="text-sm text-neutral-400">
[email protected]
<br />
+33 1 23 45 67 89
</p>
</div>
</div>
</div>
);
}
"use client";
import { useEffect, useRef } from "react";
import Lenis from "lenis";
export function LenisProvider({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<Lenis | null>(null);
useEffect(() => {
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
touchMultiplier: 2,
});
lenisRef.current = lenis;
function raf(time: number) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
return () => {
lenis.destroy();
};
}, []);
return <>{children}</>;
}
// Pour les sites avec beaucoup de sous-pages
function MegaMenuDropdown({ items, isOpen }: { items: SubMenuItem[]; isOpen: boolean }) {
return (
<div
className={cn(
"absolute top-full left-0 right-0 bg-white dark:bg-neutral-950 border-b border-neutral-200 dark:border-neutral-800 shadow-xl transition-all duration-300",
isOpen
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 -translate-y-2 pointer-events-none"
)}
>
<div className="mx-auto max-w-[var(--container-max)] px-[var(--container-padding)] py-8">
<div className="grid grid-cols-3 gap-8">
{items.map((item) => (
<a
key={item.href}
href={item.href}
className="group flex gap-4 p-4 rounded-xl hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors"
>
<div className="shrink-0 w-12 h-12 rounded-lg bg-primary-50 dark:bg-primary-950 flex items-center justify-center text-primary-600">
{item.icon}
</div>
<div>
<p className="font-medium text-neutral-900 dark:text-white group-hover:text-primary-600 transition-colors">
{item.title}
</p>
<p className="text-sm text-neutral-500 mt-1">{item.description}</p>
</div>
</a>
))}
</div>
</div>
</div>
);
}