Motiva
Мои коллекции
← каталог

Fullscreen Nav

Полноэкранная креативная навигация: ссылки-outline входят stagger-ом и заливаются на ховере, рядом превью-панель пункта.

gsapclickhoverrevealstaggeradvanced~23 КБreduced-motion ✓

Установка

npx shadcn@latest add https://motiva.pages.dev/r/fullscreen-nav.json

зависимости: gsap@^3.15.0

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (порт-идея из fb2025/astro-boilerplate юзера, pm-menu)v1.0.0