← каталог
Fullscreen Nav
Полноэкранная креативная навигация: ссылки-outline входят stagger-ом и заливаются на ховере, рядом превью-панель пункта.
gsapclickhoverrevealstaggeradvanced~23 КБreduced-motion ✓
Установка
npx shadcn@latest add https://motiva.pages.dev/r/fullscreen-nav.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
const LINKS = [
{ label: "Работы", art: "linear-gradient(150deg, #7c3aed 0%, #171422 80%)" },
{ label: "Студия", art: "linear-gradient(150deg, #0ea5e9 0%, #082f49 80%)" },
{ label: "Журнал", art: "linear-gradient(150deg, #f97316 0%, #1c1917 80%)" },
{ label: "Контакты", art: "linear-gradient(150deg, #34d399 0%, #052e16 80%)" },
];
/**
* Полноэкранная креативная навигация: бургер раскрывает оверлей, ссылки-outline входят
* stagger-ом и заливаются на ховере, справа — превью-панель наведённого пункта.
* Esc закрывает. Reduced-motion/data-shot — открытие/закрытие без анимации.
*/
export function FullscreenNav() {
const [open, setOpen] = useState(false);
const [hovered, setHovered] = useState(0);
const overlay = useRef<HTMLDivElement>(null);
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
useEffect(() => {
const el = overlay.current;
if (!el) return;
const links = el.querySelectorAll(".fn-link");
const art = el.querySelector(".fn-art");
if (open) {
if (frozen()) {
gsap.set(el, { autoAlpha: 1 });
gsap.set([...links, art], { autoAlpha: 1, y: 0, clearProps: "transform" });
return;
}
gsap
.timeline()
.to(el, { autoAlpha: 1, duration: 0.35, ease: "power2.out" })
.fromTo(
links,
{ y: 46, autoAlpha: 0 },
{ y: 0, autoAlpha: 1, duration: 0.55, ease: "power3.out", stagger: 0.07 },
"-=0.1",
)
.fromTo(art, { autoAlpha: 0, scale: 0.94 }, { autoAlpha: 1, scale: 1, duration: 0.4 }, "-=0.35");
} else if (frozen()) {
gsap.set(el, { autoAlpha: 0 }); // set — мгновенно, не ждёт тика (reduced-motion)
} else {
gsap.to(el, { autoAlpha: 0, duration: 0.3, ease: "power2.in" });
}
}, [open]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const link = LINKS[hovered] ?? LINKS[0]!;
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", color: "oklch(0.92 0.01 265)" }}>
<style>{`
.fn-link {
font-size: clamp(1.7rem, 1rem + 3.4vw, 3rem);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.15;
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
color: transparent;
-webkit-text-stroke: 1.5px oklch(0.75 0.02 265);
transition: color 0.25s, -webkit-text-stroke-color 0.25s;
}
.fn-link:hover, .fn-link:focus-visible {
color: oklch(0.95 0.01 265);
-webkit-text-stroke-color: transparent;
}
`}</style>
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
<div style={{ textAlign: "center", opacity: 0.6 }}>
<div style={{ fontSize: "1.4rem", fontWeight: 700 }}>Страница под меню</div>
<div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem" }}>жми ☰ справа сверху</div>
</div>
</div>
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={open ? "Закрыть меню" : "Открыть меню"}
style={{
position: "absolute",
top: 14,
right: 14,
zIndex: 12,
width: 44,
height: 44,
borderRadius: 12,
border: "1px solid oklch(0.34 0.02 270)",
background: "oklch(0.22 0.02 270 / 0.85)",
color: "oklch(0.92 0.01 265)",
fontSize: 18,
cursor: "pointer",
display: "grid",
placeItems: "center",
}}
>
{open ? "✕" : "☰"}
</button>
<div
ref={overlay}
style={{
position: "absolute",
inset: 0,
zIndex: 10,
opacity: 0,
visibility: "hidden",
background: "oklch(0.14 0.02 275 / 0.98)",
display: "grid",
gridTemplateColumns: "1.2fr 1fr",
alignItems: "center",
padding: "0 6vw",
gap: "4vw",
}}
>
<nav style={{ display: "grid", gap: "0.6em" }} aria-label="Полноэкранное меню">
{LINKS.map((l, i) => (
<button
key={l.label}
className="fn-link"
onPointerEnter={() => setHovered(i)}
onFocus={() => setHovered(i)}
onClick={() => setOpen(false)}
>
{l.label}
</button>
))}
</nav>
<div
className="fn-art"
aria-hidden
style={{
height: "56%",
borderRadius: 18,
border: "1px solid oklch(0.3 0.02 270)",
background: link.art,
transition: "background 0.35s",
display: "flex",
alignItems: "flex-end",
padding: "1rem",
}}
>
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.8 }}>{link.label}</span>
</div>
</div>
</div>
);
}
import gsap from "gsap";
/**
* Fullscreen Nav — vanilla JS/GSAP (без React). Бургер раскрывает полноэкранный оверлей:
* ссылки входят stagger-ом, превью-панель меняется по ховеру пункта, Esc закрывает.
*
* <button data-fn-toggle aria-expanded="false">☰</button>
* <div data-fn-overlay style="position:fixed;inset:0;opacity:0;visibility:hidden">
* <nav>
* <button data-fn-link data-art="url('work.jpg') center/cover">Работы</button>
* <button data-fn-link data-art="url('studio.jpg') center/cover">Студия</button>
* </nav>
* <div data-fn-art></div>
* </div>
* <script type="module">
* import { initFullscreenNav } from './fullscreen-nav.vanilla.js';
* const destroy = initFullscreenNav(); // ищет data-атрибуты в документе
* </script>
*
* @param {{ root?: Document|HTMLElement }} [options]
* @returns {() => void} destroy
*/
export function initFullscreenNav({ root = document } = {}) {
const toggle = root.querySelector("[data-fn-toggle]");
const overlay = root.querySelector("[data-fn-overlay]");
if (!toggle || !overlay) return () => {};
const links = [...overlay.querySelectorAll("[data-fn-link]")];
const art = overlay.querySelector("[data-fn-art]");
let open = false;
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const setOpen = (next) => {
open = next;
toggle.setAttribute("aria-expanded", String(open));
if (open) {
if (frozen()) {
gsap.set(overlay, { autoAlpha: 1 });
gsap.set([...links, art].filter(Boolean), { autoAlpha: 1, y: 0, clearProps: "transform" });
return;
}
const tl = gsap
.timeline()
.to(overlay, { autoAlpha: 1, duration: 0.35, ease: "power2.out" })
.fromTo(links, { y: 46, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.55, ease: "power3.out", stagger: 0.07 }, "-=0.1");
if (art) tl.fromTo(art, { autoAlpha: 0, scale: 0.94 }, { autoAlpha: 1, scale: 1, duration: 0.4 }, "-=0.35");
} else if (frozen()) {
gsap.set(overlay, { autoAlpha: 0 });
} else {
gsap.to(overlay, { autoAlpha: 0, duration: 0.3, ease: "power2.in" });
}
};
const onToggle = () => setOpen(!open);
const onKey = (e) => e.key === "Escape" && open && setOpen(false);
const hovers = links.map((link) => {
const h = () => {
if (art && link.dataset.art) art.style.background = link.dataset.art;
};
link.addEventListener("pointerenter", h);
link.addEventListener("focus", h);
return h;
});
toggle.addEventListener("click", onToggle);
window.addEventListener("keydown", onKey);
return () => {
toggle.removeEventListener("click", onToggle);
window.removeEventListener("keydown", onKey);
links.forEach((link, i) => {
link.removeEventListener("pointerenter", hovers[i]);
link.removeEventListener("focus", hovers[i]);
});
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (порт-идея из fb2025/astro-boilerplate юзера, pm-menu)v1.0.0