← каталог
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
Что нужно в проекте до установки
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, 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>
);
}
import gsap from "gsap";
/**
* Hover Reveal — vanilla JS/GSAP (без React). Список ссылок: под курсором всплывает
* изображение и следует за ним с инерцией. Плавающая карточка создаётся автоматически.
*
* <div id="list" style="position:relative">
* <a class="row" data-art="url('a.jpg') center/cover">Aurora</a>
* <a class="row" data-art="linear-gradient(135deg,#fbbf24,#7f1d1d)">Dune</a>
* </div>
* <script type="module">
* import { initHoverReveal } from './hover-reveal.vanilla.js';
* const destroy = initHoverReveal(document.querySelector('#list'), { rowSelector: '.row' });
* </script>
*
* @param {HTMLElement} root — контейнер (position:relative); строки несут data-art (CSS background)
* @param {{ rowSelector?: string, width?: number, height?: number }} [options]
* @returns {() => void} destroy
*/
export function initHoverReveal(root, { rowSelector = "[data-art]", width = 168, height = 116 } = {}) {
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const card = document.createElement("div");
card.style.cssText = `position:absolute;left:0;top:0;width:${width}px;height:${height}px;border-radius:14px;
opacity:0;transform:scale(0.85);pointer-events:none;z-index:2;box-shadow:0 18px 40px -12px rgb(0 0 0 / 0.55);`;
root.appendChild(card);
const x = gsap.quickTo(card, "x", { duration: 0.5, ease: "power3.out" });
const y = gsap.quickTo(card, "y", { duration: 0.5, ease: "power3.out" });
const r = gsap.quickTo(card, "rotation", { duration: 0.6, ease: "power2.out" });
let lastX = 0;
const onMove = (e) => {
if (frozen()) return;
const rect = root.getBoundingClientRect();
const cx = e.clientX - rect.left;
x(cx + 24);
y(e.clientY - rect.top - height * 0.6);
r(gsap.utils.clamp(-14, 14, (cx - lastX) * 0.8));
lastX = cx;
};
const hide = () => gsap.to(card, { opacity: 0, scale: 0.85, duration: 0.3, ease: "power2.in" });
const rows = [...root.querySelectorAll(rowSelector)];
const enters = rows.map((row) => {
const h = () => {
if (frozen()) return;
card.style.background = row.dataset.art ?? "";
gsap.to(card, { opacity: 1, scale: 1, duration: 0.35, ease: "power2.out" });
};
row.addEventListener("pointerenter", h);
return h;
});
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", hide);
return () => {
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", hide);
rows.forEach((row, i) => row.removeEventListener("pointerenter", enters[i]));
card.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Codrops hover revealv1.0.0