Elastic Line
Линия-струна: курсор оттягивает разделитель, отпустил — пружинит и дозванивает. Один SVG-путь с квадратичной кривой, контрольная точка на пружине; цикл спит, когда струна дозвенела. На тач и под reduced-motion — прямая линия.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| height? | number | 140 | Высота зоны, px (линия посередине). |
| amplitude? | number | 110 | Максимальное оттягивание от середины, px. |
| stiffness? | number | 0.085 | Жёсткость пружины: больше — резче возврат. |
| damping? | number | 0.88 | Затухание (0—1): меньше — дольше дрожит. |
| thickness? | number | 2 | Толщина линии, px. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/elastic-line.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";
import type { CSSProperties } from "react";
const css = `.ell {
position: relative;
width: 100%;
height: var(--ell-h, 140px);
/* зона захвата шире линии: тянуть струну «пинцетом по пикселю» — мучение */
touch-action: pan-y;
}
.ell svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.ell path {
fill: none;
stroke: var(--ds-accent, oklch(0.72 0.16 300));
stroke-width: var(--ell-w, 2px);
stroke-linecap: round;
}
`;
export interface ElasticLineProps {
/** Высота зоны, px (линия посередине). */
height?: number;
/** Максимальное оттягивание от середины, px. */
amplitude?: number;
/** Жёсткость пружины: больше — резче возврат. */
stiffness?: number;
/** Затухание (0—1): меньше — дольше дрожит. */
damping?: number;
/** Толщина линии, px. */
thickness?: number;
}
/**
* ЛИНИЯ-СТРУНА: курсор оттягивает разделитель, отпустил — пружинит и дозванивает.
* Классика студийных сайтов; здесь — один SVG-путь с квадратичной кривой,
* контрольная точка живёт на пружине (v += (цель − тек) · k; v *= затухание).
*
* Дисциплина цикла — паттерн курсорных записей каталога: обработчик только
* пишет координаты и будит rAF, цикл засыпает, когда струна дозвенела
* (|v| и |смещение| под порогом) и курсор ушёл. На тач-устройствах и под
* reduced-motion струна остаётся прямой линией — жест «повести мышью вдоль»
* пальцем не воспроизводится, а ложная интерактивность хуже её отсутствия.
*/
export function ElasticLine({
height = 140,
amplitude = 110,
stiffness = 0.085,
damping = 0.88,
thickness = 2,
}: ElasticLineProps) {
const host = useRef<HTMLDivElement>(null);
const pathEl = useRef<SVGPathElement>(null);
useEffect(() => {
const el = host.current;
const path = pathEl.current;
if (!el || !path) return;
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let w = el.clientWidth;
const mid = height / 2;
const flat = () => path.setAttribute("d", `M 0 ${mid} Q ${w / 2} ${mid} ${w} ${mid}`);
flat();
if (!fine || frozen) {
const ro = new ResizeObserver(() => { w = el.clientWidth; flat(); });
ro.observe(el);
return () => ro.disconnect();
}
// пружина контрольной точки; целевая точка — курсор, пока он в зоне
let cx = w / 2;
let cy = mid;
let vy = 0;
let vx = 0;
let tx = w / 2;
let ty = mid;
let inside = false;
let rafId = 0;
let running = false;
const tick = () => {
// цель по вертикали ограничена амплитудой — струна тянется, а не рвётся
const goalY = inside ? Math.max(mid - amplitude, Math.min(mid + amplitude, ty)) : mid;
const goalX = inside ? tx : w / 2;
vy = (vy + (goalY - cy) * stiffness) * damping;
vx = (vx + (goalX - cx) * stiffness) * damping;
cy += vy;
cx += vx;
path.setAttribute("d", `M 0 ${mid} Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${w} ${mid}`);
const still =
!inside &&
Math.abs(vy) < 0.05 && Math.abs(vx) < 0.05 &&
Math.abs(cy - mid) < 0.15 && Math.abs(cx - w / 2) < 0.15;
if (still) {
running = false;
cy = mid; cx = w / 2; vy = 0; vx = 0;
flat();
return;
}
rafId = requestAnimationFrame(tick);
};
const wake = () => {
if (!running) { running = true; rafId = requestAnimationFrame(tick); }
};
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
tx = e.clientX - r.left;
ty = e.clientY - r.top;
inside = true;
wake();
};
const onLeave = () => { inside = false; wake(); };
const ro = new ResizeObserver(() => {
w = el.clientWidth;
if (!running) flat();
});
ro.observe(el);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
return () => {
cancelAnimationFrame(rafId);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
ro.disconnect();
};
}, [height, amplitude, stiffness, damping]);
return (
<div
ref={host}
className="ell"
style={{
["--ell-h" as string]: `${height}px`,
["--ell-w" as string]: `${thickness}px`,
} as CSSProperties}
>
<style href="motiva-elastic-line" precedence="medium">{css}</style>
<svg aria-hidden="true">
<path ref={pathEl} d="" />
</svg>
</div>
);
}