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

Bubble Text

Буквы надуваются под курсором и опадают за ним. Спад влияния — smoothstep, поэтому на границе радиуса нет излома; цикл кадров заводится только на движение и засыпает сразу после.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
lines?string[]DEFAULT_LINESСтроки текста. Каждая режется на буквы своим слоем.
radius?number100Радиус влияния курсора, px.
scale?number0.45Насколько буква в эпицентре крупнее: 0.45 — плюс 45%.
color?booleantrueПодкрашивать буквы акцентом по мере приближения курсора.
hint?string"спад — smoothstep, цикл кадров спит без…

Установка

npx shadcn@latest add https://motiva.pages.dev/r/bubble-text.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 = `/* Буквы надуваются под курсором. */

.bbt {
  width: 100%;
  padding: var(--ds-5, 24px) var(--ds-4, 18px);
  font-family: var(--ds-font, system-ui, -apple-system, sans-serif);
  color: var(--ds-fg, oklch(0.92 0.01 265));
  text-align: center;
}

.bbt-line {
  margin: 0;
  font-size: clamp(1.8rem, 1rem + 4.4vw, 3.6rem);
  font-weight: 680;
  line-height: 1.12;
  letter-spacing: var(--ds-tracking-display, -0.045em);
  /* курсор ведут ПО СТРОКЕ, поэтому она и есть зона слушателя */
  cursor: default;
}

/* Каждая буква — свой слой. \`inline-block\` обязателен: у строчного элемента
   трансформ не применяется вовсе, и эффекта не было бы никакого. */
.bbt-char {
  display: inline-block;
  transform-origin: 50% 60%;
  transition: color 0.2s linear;
  will-change: transform;
}

/* Пробел схлопнулся бы у inline-block без содержимого — держим его явной шириной. */
.bbt-space {
  display: inline-block;
  width: 0.32em;
}

.bbt-hint {
  margin: var(--ds-4, 18px) 0 0;
  font-family: var(--ds-font-mono, ui-monospace, Menlo, monospace);
  font-size: var(--ds-text-sm, 0.78rem);
  color: var(--ds-muted, oklch(0.65 0.02 265));
}

@media (prefers-reduced-motion: reduce) {
  .bbt-char {
    transition: none;
  }
}
`;

export interface BubbleTextProps {
  /** Строки текста. Каждая режется на буквы своим слоем. */
  lines?: string[];
  /** Радиус влияния курсора, px. */
  radius?: number;
  /** Насколько буква в эпицентре крупнее: 0.45 — плюс 45%. */
  scale?: number;
  /** Подъём буквы в эпицентре, px; 0 — выключить. */
  lift?: number;
  /** Подкрашивать буквы акцентом по мере приближения курсора. */
  color?: boolean;
  hint?: string;
}

const DEFAULT_LINES = ["Наведи курсор", "на эту строку"];

/**
 * Буквы надуваются под курсором и опадают за ним.
 *
 * Спад влияния — `smoothstep` (`t²(3−2t)`), а не линейный: у линейного на границе радиуса
 * виден излом, буквы «щёлкают» при входе и выходе. Здесь производная на концах нулевая,
 * и переход от тронутых букв к нетронутым не читается вовсе.
 *
 * Цикл кадров ЗАСЫПАЕТ: `requestAnimationFrame` заводится только на движение курсора и
 * ровно один на кадр (флаг `raf`), а не по кадру на каждое событие. Без флага быстрый
 * `pointermove` ставил бы в очередь по несколько пересчётов на один и тот же кадр, и все,
 * кроме последнего, были бы выброшены.
 *
 * Прямоугольники букв меряются В КАДРЕ, а не на входе: строка переносится по ширине окна,
 * и запомненная раскладка врала бы после любого ресайза.
 */
export function BubbleText({
  lines = DEFAULT_LINES,
  radius = 100,
  scale = 0.45,
  lift = 6,
  color = true,
  hint = "спад — smoothstep, цикл кадров спит без движения",
}: BubbleTextProps) {
  const rootRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    if (
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
      window.matchMedia("(pointer: coarse)").matches
    ) return;

    const chars = Array.from(root.querySelectorAll<HTMLElement>(".bbt-char"));
    if (!chars.length) return;

    let raf = 0;
    let mx = 0;
    let my = 0;

    const apply = () => {
      for (const el of chars) {
        const r = el.getBoundingClientRect();
        const d = Math.hypot(mx - (r.left + r.width / 2), my - (r.top + r.height / 2));
        const t = Math.max(0, 1 - d / radius);
        const e = t * t * (3 - 2 * t); // smoothstep — мягкий спад без излома на границе
        let tr = `scale(${(1 + e * scale).toFixed(3)})`;
        if (lift) tr += ` translateY(${(-e * lift).toFixed(1)}px)`;
        el.style.transform = tr;
        if (color) {
          el.style.color = e > 0.02
            ? `color-mix(in oklch, var(--ds-accent, oklch(0.72 0.16 300)) ${Math.round(e * 100)}%, currentColor)`
            : "";
        }
      }
    };

    const onMove = (ev: PointerEvent) => {
      mx = ev.clientX;
      my = ev.clientY;
      if (raf) return; // один пересчёт на кадр, а не по кадру на событие
      raf = requestAnimationFrame(() => { apply(); raf = 0; });
    };

    const onLeave = () => {
      if (raf) { cancelAnimationFrame(raf); raf = 0; }
      for (const el of chars) {
        el.style.transform = "scale(1)";
        if (color) el.style.color = "";
      }
    };

    root.addEventListener("pointermove", onMove);
    root.addEventListener("pointerleave", onLeave);
    return () => {
      root.removeEventListener("pointermove", onMove);
      root.removeEventListener("pointerleave", onLeave);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [radius, scale, lift, color, lines]);

  return (
    <div className="bbt" ref={rootRef}>
      <style href="motiva-bubble-text" precedence="medium">{css}</style>

      {lines.map((line, li) => (
        // строка режется на буквы, поэтому читалка получает её целиком через aria-label,
        // а сами буквы от неё скрыты: иначе она диктует текст по одному символу
        <p className="bbt-line" key={`${li}-${line}`} aria-label={line}>
          {Array.from(line).map((ch, i) =>
            ch === " "
              ? <span className="bbt-space" key={i} aria-hidden="true" />
              : <span className="bbt-char" key={i} aria-hidden="true">{ch}</span>,
          )}
        </p>
      ))}

      {hint ? <p className="bbt-hint">{hint}</p> : null}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0