Bubble Text
Буквы надуваются под курсором и опадают за ним. Спад влияния — smoothstep, поэтому на границе радиуса нет излома; цикл кадров заводится только на движение и засыпает сразу после.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| lines? | string[] | DEFAULT_LINES | Строки текста. Каждая режется на буквы своим слоем. |
| radius? | number | 100 | Радиус влияния курсора, px. |
| scale? | number | 0.45 | Насколько буква в эпицентре крупнее: 0.45 — плюс 45%. |
| color? | boolean | true | Подкрашивать буквы акцентом по мере приближения курсора. |
| hint? | string | "спад — smoothstep, цикл кадров спит без… |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/bubble-text.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 = `/* Буквы надуваются под курсором. */
.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>
);
}