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

Cursor Tooltip

Поповер, который едет за курсором: у краёв экрана переворачивается на другую сторону, а на прокрутке прячется — иначе он залипает, потому что курсор относительно вьюпорта не движется и события не летят.

cssmouse-moveintermediate~1 КБreduced-motion ✓
↗

Пропсы

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

ПропТипПо умолчаниюЧто это
items?CursorTooltipItem[]DEFAULT_ITEMS
offset?number18Отступ поповера от острия курсора, px.
CursorTooltipItem — что внутри
  • label: string
  • hint?: string— Мелкая подпись в карточке — видна всегда, в отличие от поповера.
  • title: string
  • text: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/cursor-tooltip.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0