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

Elastic Line

Линия-струна: курсор оттягивает разделитель, отпустил — пружинит и дозванивает. Один SVG-путь с квадратичной кривой, контрольная точка на пружине; цикл спит, когда струна дозвенела. На тач и под reduced-motion — прямая линия.

cssmouse-movespringmagneticintermediate~2 КБreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
height?number140Высота зоны, px (линия посередине).
amplitude?number110Максимальное оттягивание от середины, px.
stiffness?number0.085Жёсткость пружины: больше — резче возврат.
damping?number0.88Затухание (0—1): меньше — дольше дрожит.
thickness?number2Толщина линии, px.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/elastic-line.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";
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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); приём «эластичная линия» — классика awwwards-сайтов, реализация свояv1.0.0