agency-web/app/components/SiteHeader.tsx

145 lines
4.5 KiB
TypeScript

"use client";
/**
* Sticky header.
* - Condenses (smaller, blurred, bordered) after the hero via scroll listener.
* - Top scroll-progress bar reads the --scroll-progress CSS var fed by Lenis.
* - Mobile: accessible disclosure menu (real <button aria-expanded>, Esc to
* close, focus returns to the toggle, body scroll locked while open).
*/
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Magnetic from "./Magnetic";
import { SITE } from "../content";
const NAV = [
{ href: "#services", label: "Services" },
{ href: "#work", label: "Work" },
{ href: "#process", label: "About" },
{ href: "#faq", label: "FAQ" },
];
export default function SiteHeader() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const [active, setActive] = useState<string>("");
const toggleRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Scroll-spy: highlight the nav item for the section currently in view, so
// location is always legible (navigation best practice + a touch of life).
useEffect(() => {
const ids = NAV.map((n) => n.href.slice(1));
const sections = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => !!el);
if (!sections.length) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) setActive(`#${e.target.id}`);
});
},
{ rootMargin: "-45% 0px -50% 0px", threshold: 0 }
);
sections.forEach((s) => io.observe(s));
return () => io.disconnect();
}, []);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
toggleRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<header className={`site-head ${scrolled ? "is-scrolled" : ""}`}>
<div className="progress-bar" aria-hidden="true" />
<div className="site-head__inner">
<Link href="#main" className="brand" aria-label={`${SITE.name} — home`}>
<span className="brand__mark" aria-hidden="true">
<span className="brand__dot" />
</span>
<span className="brand__name">Feedback&nbsp;Studios</span>
</Link>
<nav className="site-nav" aria-label="Primary">
<ul>
{NAV.map((n) => {
const isActive = active === n.href;
return (
<li key={n.href}>
<a
href={n.href}
className={isActive ? "is-active" : ""}
aria-current={isActive ? "true" : undefined}
>
{n.label}
</a>
</li>
);
})}
</ul>
</nav>
<div className="site-head__cta">
<Magnetic strength={0.5}>
<Link href={SITE.booking} className="btn btn--accent btn--sm" data-cursor="Let's talk">
Get a growth audit
</Link>
</Magnetic>
</div>
<button
ref={toggleRef}
className={`burger ${open ? "is-open" : ""}`}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label={open ? "Close menu" : "Open menu"}
onClick={() => setOpen((v) => !v)}
>
<span /><span /><span />
</button>
</div>
<div id="mobile-menu" className={`mobile-menu ${open ? "is-open" : ""}`} hidden={!open}>
<nav aria-label="Mobile">
<ul>
{NAV.map((n) => (
<li key={n.href}>
<a href={n.href} onClick={() => setOpen(false)}>
{n.label}
</a>
</li>
))}
<li>
<Link
href={SITE.booking}
className="btn btn--accent"
onClick={() => setOpen(false)}
>
Get a growth audit
</Link>
</li>
</ul>
</nav>
</div>
</header>
);
}