mobile animiations
This commit is contained in:
parent
9e7533c7c2
commit
8856d19682
1 changed files with 192 additions and 32 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import gsap from "gsap";
|
||||||
import { Menu, X } from "lucide-react";
|
import { Menu, X } from "lucide-react";
|
||||||
import { site, navLinks, socialLinks } from "../data/site";
|
import { site, navLinks, socialLinks } from "../data/site";
|
||||||
import { Icon } from "@/lib/icons";
|
import { Icon } from "@/lib/icons";
|
||||||
|
|
@ -11,6 +12,18 @@ export default function Navigation() {
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
const linksRef = useRef<HTMLUListElement>(null);
|
||||||
|
const socialsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||||
|
const prefersReducedMotion = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prefersReducedMotion.current = window.matchMedia(
|
||||||
|
"(prefers-reduced-motion: reduce)"
|
||||||
|
).matches;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => setScrolled(window.scrollY > 50);
|
const handleScroll = () => setScrolled(window.scrollY > 50);
|
||||||
|
|
@ -24,6 +37,130 @@ export default function Navigation() {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [mobileOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && mobileOpen) {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [mobileOpen]);
|
||||||
|
|
||||||
|
const animateOpen = useCallback(() => {
|
||||||
|
const menu = menuRef.current;
|
||||||
|
const backdrop = backdropRef.current;
|
||||||
|
const links = linksRef.current;
|
||||||
|
const socials = socialsRef.current;
|
||||||
|
if (!menu || !backdrop || !links || !socials) return;
|
||||||
|
|
||||||
|
if (prefersReducedMotion.current) {
|
||||||
|
gsap.set(menu, { opacity: 1, y: 0 });
|
||||||
|
gsap.set(backdrop, { opacity: 1 });
|
||||||
|
gsap.set([...links.children, ...socials.children], { opacity: 1, y: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
timelineRef.current = tl;
|
||||||
|
|
||||||
|
tl.set(menu, { display: "block" })
|
||||||
|
.fromTo(
|
||||||
|
backdrop,
|
||||||
|
{ opacity: 0 },
|
||||||
|
{ opacity: 1, duration: 0.2, ease: "power2.out" }
|
||||||
|
)
|
||||||
|
.fromTo(
|
||||||
|
menu,
|
||||||
|
{ opacity: 0, y: -10 },
|
||||||
|
{ opacity: 1, y: 0, duration: 0.25, ease: "power2.out" },
|
||||||
|
"<"
|
||||||
|
)
|
||||||
|
.fromTo(
|
||||||
|
links.children,
|
||||||
|
{ opacity: 0, y: 12 },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.3,
|
||||||
|
stagger: 0.04,
|
||||||
|
ease: "power2.out",
|
||||||
|
},
|
||||||
|
"-=0.1"
|
||||||
|
)
|
||||||
|
.fromTo(
|
||||||
|
socials.children,
|
||||||
|
{ opacity: 0, y: 8 },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.25,
|
||||||
|
stagger: 0.04,
|
||||||
|
ease: "power2.out",
|
||||||
|
},
|
||||||
|
"-=0.15"
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const animateClose = useCallback(() => {
|
||||||
|
const menu = menuRef.current;
|
||||||
|
const backdrop = backdropRef.current;
|
||||||
|
const links = linksRef.current;
|
||||||
|
const socials = socialsRef.current;
|
||||||
|
if (!menu || !backdrop || !links || !socials) return;
|
||||||
|
|
||||||
|
if (timelineRef.current) {
|
||||||
|
timelineRef.current.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefersReducedMotion.current) {
|
||||||
|
gsap.set(menu, { display: "none", opacity: 0 });
|
||||||
|
gsap.set(backdrop, { opacity: 0, display: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
gsap.set(menu, { display: "none" });
|
||||||
|
gsap.set(backdrop, { display: "none" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tl.to(links.children, {
|
||||||
|
opacity: 0,
|
||||||
|
y: -6,
|
||||||
|
duration: 0.15,
|
||||||
|
stagger: 0.02,
|
||||||
|
ease: "power2.in",
|
||||||
|
})
|
||||||
|
.to(
|
||||||
|
socials.children,
|
||||||
|
{ opacity: 0, duration: 0.12, stagger: 0.02, ease: "power2.in" },
|
||||||
|
"<"
|
||||||
|
)
|
||||||
|
.to(menu, { opacity: 0, y: -8, duration: 0.2, ease: "power2.in" }, "-=0.08")
|
||||||
|
.to(backdrop, { opacity: 0, duration: 0.2, ease: "power2.in" }, "<");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileOpen) {
|
||||||
|
animateOpen();
|
||||||
|
} else {
|
||||||
|
animateClose();
|
||||||
|
}
|
||||||
|
}, [mobileOpen, animateOpen, animateClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={`fixed top-0 left-0 right-0 z-50 transition-colors duration-300 [will-change:transform] ${
|
className={`fixed top-0 left-0 right-0 z-50 transition-colors duration-300 [will-change:transform] ${
|
||||||
|
|
@ -79,46 +216,69 @@ export default function Navigation() {
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<button
|
<button
|
||||||
className="md:hidden text-text-primary"
|
className="md:hidden relative w-6 h-6"
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
aria-label="Toggle menu"
|
aria-label={mobileOpen ? "Close menu" : "Open menu"}
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
>
|
>
|
||||||
{mobileOpen ? <X size={24} /> : <Menu size={24} />}
|
<Menu
|
||||||
|
size={24}
|
||||||
|
className={`absolute inset-0 text-text-primary transition-opacity duration-200 ease-out ${
|
||||||
|
mobileOpen ? "opacity-0" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<X
|
||||||
|
size={24}
|
||||||
|
className={`absolute inset-0 text-text-primary transition-opacity duration-200 ease-out ${
|
||||||
|
mobileOpen ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
ref={backdropRef}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 md:hidden"
|
||||||
|
style={{ display: "none", opacity: 0 }}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
{mobileOpen && (
|
<div
|
||||||
<div className="md:hidden bg-deep-space/95 border-t border-warm-grey/50 px-[5vw] py-8">
|
ref={menuRef}
|
||||||
<ul className="space-y-4 mb-8">
|
className="md:hidden bg-deep-space/98 border-t border-warm-grey/50 px-[5vw] py-8 z-40"
|
||||||
{navLinks.map((link) => (
|
style={{ display: "none", opacity: 0 }}
|
||||||
<li key={link.href}>
|
>
|
||||||
<Link
|
<ul ref={linksRef} className="space-y-4 mb-8">
|
||||||
href={link.href}
|
{navLinks.map((link) => (
|
||||||
className="block text-lg text-text-primary"
|
<li key={link.href}>
|
||||||
onClick={() => setMobileOpen(false)}
|
<Link
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
{socialLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
key={link.label}
|
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target="_blank"
|
className="block text-lg text-text-primary"
|
||||||
rel="noopener noreferrer"
|
onClick={() => setMobileOpen(false)}
|
||||||
aria-label={link.label}
|
|
||||||
className="text-text-secondary hover:text-amber transition-colors"
|
|
||||||
>
|
>
|
||||||
<Icon name={link.icon} size={20} />
|
{link.label}
|
||||||
</a>
|
</Link>
|
||||||
))}
|
</li>
|
||||||
</div>
|
))}
|
||||||
|
</ul>
|
||||||
|
<div ref={socialsRef} className="flex items-center gap-6">
|
||||||
|
{socialLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.label}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={link.label}
|
||||||
|
className="text-text-secondary hover:text-amber transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name={link.icon} size={20} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue