Cursor Tooltip
Поповер, который едет за курсором: у краёв экрана переворачивается на другую сторону, а на прокрутке прячется — иначе он залипает, потому что курсор относительно вьюпорта не движется и события не летят.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | CursorTooltipItem[] | DEFAULT_ITEMS | |
| offset? | number | 18 | Отступ поповера от острия курсора, px. |
CursorTooltipItem — что внутри
- label: string
- hint?: string— Мелкая подпись в карточке — видна всегда, в отличие от поповера.
- title: string
- text: string
Установка
npx shadcn@latest add https://motiva.pages.dev/r/cursor-tooltip.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
const css = `/* Поповер, следующий за курсором. */
.ctt {
--ctt-dur: 0.16s;
position: relative;
width: 100%;
font-family: var(--ds-font, system-ui, -apple-system, sans-serif);
color: var(--ds-fg, oklch(0.92 0.01 265));
}
.ctt-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--ds-3, 12px);
max-width: 640px;
margin: 0 auto;
padding: 0;
list-style: none;
}
.ctt-item {
display: grid;
gap: 4px;
padding: 16px 14px;
border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
border-radius: var(--ds-radius, 14px);
background: var(--ds-surface, oklch(0.17 0.02 270));
cursor: help;
transition: border-color var(--ctt-dur) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
}
.ctt-item[data-on="1"] {
border-color: var(--ds-accent, oklch(0.72 0.16 300));
}
.ctt-label {
font-size: var(--ds-text-md, 0.98rem);
font-weight: 600;
letter-spacing: var(--ds-tracking-tight, -0.02em);
}
.ctt-hint {
font-size: var(--ds-text-sm, 0.82rem);
color: var(--ds-muted, oklch(0.65 0.02 265));
}
/* Сам поповер. \`position: fixed\` — координаты приходят от курсора, а они и есть
координаты вьюпорта; любой позиционированный предок увёл бы отсчёт к себе.
\`pointer-events: none\` обязателен: поповер едет ПОД курсором, и без него он
перехватывал бы наведение у собственного триггера — курсор «терял» бы карточку. */
.ctt-pop {
position: fixed;
top: 0;
left: 0;
z-index: 60;
max-width: 264px;
padding: 10px 12px;
border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
border-radius: 10px;
background: var(--ds-surface-2, oklch(0.22 0.025 275));
box-shadow: var(--ds-shadow-lg, 0 26px 60px oklch(0 0 0 / 0.6));
pointer-events: none;
opacity: 0;
visibility: hidden;
transition:
opacity var(--ctt-dur) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
visibility 0s linear var(--ctt-dur);
}
.ctt-pop[data-on="1"] {
opacity: 1;
visibility: visible;
transition-delay: 0s, 0s;
}
.ctt-pop-title {
margin: 0 0 3px;
font-size: var(--ds-text-sm, 0.84rem);
font-weight: 650;
letter-spacing: var(--ds-tracking-tight, -0.02em);
}
.ctt-pop-text {
margin: 0;
font-size: var(--ds-text-sm, 0.8rem);
line-height: 1.45;
color: var(--ds-muted, oklch(0.72 0.02 265));
}
@media (prefers-reduced-motion: reduce) {
.ctt-pop,
.ctt-item {
transition: none;
}
}
html[data-shot] .ctt-pop,
html[data-shot] .ctt-item {
transition: none;
}
`;
export interface CursorTooltipItem {
label: string;
/** Мелкая подпись в карточке — видна всегда, в отличие от поповера. */
hint?: string;
title: string;
text: string;
}
export interface CursorTooltipProps {
items?: CursorTooltipItem[];
/** Отступ поповера от острия курсора, px. */
offset?: number;
}
const DEFAULT_ITEMS: CursorTooltipItem[] = [
{ label: "Пункт 01", hint: "наведи", title: "Заголовок подсказки",
text: "Поповер появляется под курсором и едет за ним, пока указатель внутри карточки." },
{ label: "Пункт 02", hint: "наведи", title: "Заголовок подсказки",
text: "У правого и нижнего края окна он переворачивается на другую сторону курсора и не вылезает за экран." },
{ label: "Пункт 03", hint: "наведи", title: "Заголовок подсказки",
text: "На прокрутке прячется: курсор относительно окна не движется, событий нет, и иначе поповер залипал бы на месте." },
{ label: "Пункт 04", hint: "наведи", title: "Заголовок подсказки",
text: "Текст любой длины — ширина ограничена, перенос обычный. Здесь стоит нейтральная рыба." },
];
/**
* Поповер, следующий за курсором: переворачивается у краёв экрана и прячется на скролле.
*
* Слушателей ТРИ на весь блок, а не по паре на карточку: `pointermove` делегирован корню
* и сам разбирает, над чем курсор (`closest("[data-tip]")`). Он же закрывает случай
* «ушли в промежуток между карточками» — отдельный `pointerleave` для этого не нужен.
*
* Грабля, ради которой всё и затевалось: поповер привязан к КУРСОРУ, а при прокрутке
* курсор относительно вьюпорта НЕ ДВИЖЕТСЯ. `pointermove` не летит, координаты остаются
* прежними — и поповер залипает на месте, пока содержимое уезжает под ним. Поэтому на
* `scroll` он прячется сразу, а следующее реальное движение мыши возвращает его.
* Слушатель scroll висит на window в фазе ПЕРЕХВАТА: событие прокрутки не всплывает,
* и без capture прокрутка внутреннего контейнера прошла бы мимо.
*
* Размер поповера меряется ОДИН раз на показ, а не в каждом движении: `offsetWidth` —
* принудительный пересчёт раскладки, в `pointermove` он стоил бы кадра на каждый пиксель.
*/
export function CursorTooltip({ items = DEFAULT_ITEMS, offset = 18 }: CursorTooltipProps) {
const rootRef = useRef<HTMLDivElement>(null);
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
const pop = popRef.current;
if (!root || !pop) return;
const titleEl = pop.querySelector<HTMLElement>(".ctt-pop-title");
const textEl = pop.querySelector<HTMLElement>(".ctt-pop-text");
if (!titleEl || !textEl) return;
// Настоящее наведение мышью. На тач-экране поповер за курсором смысла не имеет:
// «курсора» нет, а :hover после тапа залипает.
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)");
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let active: HTMLElement | null = null;
let w = 0;
let h = 0;
const place = (cx: number, cy: number) => {
const pad = 12;
let x = cx + offset;
let y = cy + offset;
// переворот: у правого/нижнего края уводим поповер на другую сторону курсора,
// иначе он вылезет за экран и часть текста будет недоступна
if (x + w + pad > window.innerWidth) x = cx - w - offset;
if (y + h + pad > window.innerHeight) y = cy - h - offset;
pop.style.transform = `translate(${Math.max(pad, x)}px, ${Math.max(pad, y)}px)`;
};
const hide = () => {
if (!active) return;
active.dataset.on = "0";
active = null;
pop.dataset.on = "0";
};
const onMove = (e: PointerEvent) => {
if (!canHover.matches || frozen()) return;
const item = (e.target as Element | null)?.closest<HTMLElement>("[data-tip]") ?? null;
if (!item || !root.contains(item)) {
hide();
return;
}
if (item !== active) {
if (active) active.dataset.on = "0";
active = item;
item.dataset.on = "1";
titleEl.textContent = item.dataset.title ?? "";
textEl.textContent = item.dataset.text ?? "";
pop.dataset.on = "1";
w = pop.offsetWidth; // мерим на показ, пока содержимое уже подставлено
h = pop.offsetHeight;
}
place(e.clientX, e.clientY);
};
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", hide);
window.addEventListener("scroll", hide, { passive: true, capture: true });
return () => {
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", hide);
window.removeEventListener("scroll", hide, { capture: true });
};
}, [offset]);
return (
<div className="ctt" ref={rootRef}>
<style href="motiva-cursor-tooltip" precedence="medium">{css}</style>
<ul className="ctt-grid">
{items.map((it) => (
<li className="ctt-item" key={it.label} data-tip data-title={it.title} data-text={it.text}>
<span className="ctt-label">{it.label}</span>
{it.hint ? <span className="ctt-hint">{it.hint}</span> : null}
</li>
))}
</ul>
{/* Живёт один на блок и переиспользуется: своя копия у каждой карточки — это N узлов
в DOM и N наборов стилей ради одного видимого элемента. */}
<div className="ctt-pop" ref={popRef} role="tooltip" aria-hidden="true" data-on="0">
<p className="ctt-pop-title" />
<p className="ctt-pop-text" />
</div>
</div>
);
}