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

Hover Reveal

Список ссылок: при наведении под курсором всплывает изображение и следует за ним с инерцией и наклоном по скорости.

gsaphovermouse-moverevealintermediate~23 КБreduced-motion ✓

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
items?RevealItem[]ITEMS
RevealItem — что внутри
  • label: string
  • hint: string
  • art: string— CSS-фон «изображения» (записи без внешних ассетов — градиентные заглушки, замени на url()).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/hover-reveal.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, type PointerEvent } from "react";
import gsap from "gsap";

export interface RevealItem {
  label: string;
  hint: string;
  /** CSS-фон «изображения» (записи без внешних ассетов — градиентные заглушки, замени на url()). */
  art: string;
}

const ITEMS: RevealItem[] = [
  { label: "Aurora", hint: "01", art: "linear-gradient(135deg, #38bdf8 0%, #8b5cf6 55%, #171422 100%)" },
  { label: "Dune", hint: "02", art: "linear-gradient(135deg, #fbbf24 0%, #f97316 45%, #7f1d1d 100%)" },
  { label: "Reef", hint: "03", art: "linear-gradient(135deg, #34d399 0%, #0ea5e9 60%, #082f49 100%)" },
  { label: "Nebula", hint: "04", art: "radial-gradient(circle at 30% 25%, #f472b6 0%, #7c3aed 55%, #171422 100%)" },
];

/**
 * Список ссылок: при наведении под курсором всплывает изображение и следует за ним
 * с инерцией (GSAP quickTo) и наклоном по скорости. Уважает reduced-motion / data-shot.
 */
export function HoverReveal({ items = ITEMS }: { items?: RevealItem[] }) {
  const root = useRef<HTMLDivElement>(null);
  const card = useRef<HTMLDivElement>(null);
  const move = useRef<{ x?: gsap.QuickToFunc; y?: gsap.QuickToFunc; r?: gsap.QuickToFunc }>({});
  const lastX = useRef(0);

  useEffect(() => {
    const el = card.current;
    if (!el) return;
    move.current = {
      x: gsap.quickTo(el, "x", { duration: 0.5, ease: "power3.out" }),
      y: gsap.quickTo(el, "y", { duration: 0.5, ease: "power3.out" }),
      r: gsap.quickTo(el, "rotation", { duration: 0.6, ease: "power2.out" }),
    };
  }, []);

  const frozen = () =>
    document.documentElement.hasAttribute("data-shot") ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  const onMove = (e: PointerEvent<HTMLDivElement>) => {
    const r = root.current?.getBoundingClientRect();
    if (!r || frozen()) return;
    const x = e.clientX - r.left;
    move.current.x?.(x + 24);
    move.current.y?.(e.clientY - r.top - 70);
    move.current.r?.(gsap.utils.clamp(-14, 14, (x - lastX.current) * 0.8));
    lastX.current = x;
  };

  const show = (art: string) => {
    const el = card.current;
    if (!el || frozen()) return;
    el.style.background = art;
    gsap.to(el, { opacity: 1, scale: 1, duration: 0.35, ease: "power2.out" });
  };

  const hide = () => {
    const el = card.current;
    if (!el) return;
    gsap.to(el, { opacity: 0, scale: 0.85, duration: 0.3, ease: "power2.in" });
  };

  return (
    <div
      ref={root}
      onPointerMove={onMove}
      onPointerLeave={hide}
      style={{ position: "relative", width: "min(380px, 86vw)" }}
    >
      <style>{`
        .hr-item { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
          padding: 0.9rem 0.25rem; border-bottom: 1px solid oklch(0.3 0.02 270); cursor: pointer;
          color: oklch(0.7 0.02 265); transition: color 0.25s, padding-left 0.25s; }
        .hr-item:hover { color: oklch(0.96 0.01 265); padding-left: 0.7rem; }
      `}</style>
      {items.map((it) => (
        <div key={it.label} className="hr-item" onPointerEnter={() => show(it.art)}>
          <span style={{ fontSize: "1.35rem", fontWeight: 650, letterSpacing: "-0.02em" }}>{it.label}</span>
          <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.55 }}>{it.hint}</span>
        </div>
      ))}
      <div
        ref={card}
        aria-hidden
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          width: 168,
          height: 116,
          borderRadius: 14,
          opacity: 0,
          transform: "scale(0.85)",
          pointerEvents: "none",
          zIndex: 2,
          boxShadow: "0 18px 40px -12px rgb(0 0 0 / 0.55)",
        }}
      />
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Codrops hover revealv1.0.0