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

Line Draw

Контур прорисовывается штрихом и следом наливается цветом. Длина берётся у самого пути, поэтому приём работает с любым SVG без правки чисел, а заливка идёт с нахлёстом — без мёртвой паузы после линии.

gsapscrollclickrevealintermediate~23 КБreduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его

Пропсы

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

ПропТипПо умолчаниюЧто это
d?stringDEFAULT_DДанные контура (атрибут `d` у `<path>`). По умолчанию — нейтральный знак.
viewBox?string"0 0 300 160"Система координат контура.
duration?number1.6Длительность прорисовки, с.
fill?booleantrueЗаливать контур после прорисовки.
caption?string"штрих выезжает из ниоткуда, потом конту…
replay?booleantrue

Установка

npx shadcn@latest add https://motiva.pages.dev/r/line-draw.json

зависимости: gsap@^3.15.0

Что нужно в проекте до установки
  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 gsap from "gsap";
const css = `/* Обводка рисуется штрихом, потом наливается цветом. */

.lnd {
  width: 100%;
  display: grid;
  justify-items: center;
  gap: var(--ds-4, 18px);
  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));
}

.lnd-svg {
  display: block;
  width: min(100%, 320px);
  height: auto;
  overflow: visible; /* толстый штрих иначе срезается по краю вьюбокса */
}

.lnd-path {
  fill: none;
  stroke: var(--ds-accent, oklch(0.72 0.16 300));
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.lnd-caption {
  margin: 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));
  text-align: center;
}

.lnd-replay {
  padding: 7px 14px;
  border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
  border-radius: 999px;
  background: transparent;
  color: var(--ds-muted, oklch(0.7 0.02 265));
  font-family: inherit;
  font-size: var(--ds-text-sm, 0.82rem);
  cursor: pointer;
}

.lnd-replay:hover {
  color: var(--ds-fg, oklch(0.92 0.01 265));
  border-color: var(--ds-accent, oklch(0.72 0.16 300));
}
`;

export interface LineDrawProps {
  /** Данные контура (атрибут `d` у `<path>`). По умолчанию — нейтральный знак. */
  d?: string;
  /** Система координат контура. */
  viewBox?: string;
  /** Длительность прорисовки, с. */
  duration?: number;
  /** Заливать контур после прорисовки. */
  fill?: boolean;
  /** Цвет заливки; по умолчанию — акцент дизайн-системы. */
  fillColor?: string;
  caption?: string;
  replay?: boolean;
}

/** Нейтральный замкнутый знак: волна в скруглённой рамке — заливать есть что. */
const DEFAULT_D =
  "M30 20 H270 A20 20 0 0 1 290 40 V120 A20 20 0 0 1 270 140 H30 A20 20 0 0 1 10 120 V40 A20 20 0 0 1 30 20 Z " +
  "M40 96 C 80 44, 120 148, 160 96 S 240 44, 280 96";

/**
 * Обводка контура по `stroke-dashoffset` с последующей заливкой.
 *
 * Приём держится на одном свойстве: длина штриха ставится равной длине контура, а
 * `dashoffset` гонится от неё до нуля — штрих будто выезжает из ниоткуда. Длина берётся
 * у самого пути (`getTotalLength()`), а не задаётся числом: при другом `d` или другом
 * масштабе зашитая константа даёт либо недорисованный контур, либо паузу в начале.
 *
 * Заливка включается ПОСЛЕ прорисовки и с нахлёстом (−0.35 с): если ждать конца штриха,
 * между линией и цветом появляется мёртвая пауза, а если пустить сразу — заливка
 * обгоняет контур и приём разваливается.
 *
 * Крючок — `IntersectionObserver`, а не ScrollTrigger: последний держит цикл кадров
 * живым и в покое, а нужен он ровно на один раз.
 */
export function LineDraw({
  d = DEFAULT_D,
  viewBox = "0 0 300 160",
  duration = 1.6,
  fill = true,
  fillColor = "var(--ds-accent, oklch(0.72 0.16 300))",
  caption = "штрих выезжает из ниоткуда, потом контур наливается цветом",
  replay = true,
}: LineDrawProps) {
  const pathRef = useRef<SVGPathElement>(null);
  const play = useRef<() => void>(() => {});

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

    const len = path.getTotalLength();
    gsap.set(path, { strokeDasharray: len, strokeDashoffset: reduced ? 0 : len });
    if (fill) gsap.set(path, { fill: fillColor, fillOpacity: reduced ? 1 : 0 });

    play.current = () => {
      const tl = gsap.timeline({ overwrite: true });
      if (fill) tl.set(path, { fillOpacity: 0 });
      tl.fromTo(path, { strokeDashoffset: len }, { strokeDashoffset: 0, duration, ease: "power2.inOut" });
      // нахлёст: заливка догоняет штрих, а не ждёт его конца
      if (fill) tl.to(path, { fillOpacity: 1, duration: 0.6, ease: "power2.out" }, "-=0.35");
      return tl;
    };

    if (reduced) return;
    const io = new IntersectionObserver(
      (entries) => {
        for (const e of entries) {
          if (!e.isIntersecting) continue;
          io.unobserve(e.target);
          play.current();
        }
      },
      { rootMargin: "0px 0px -25% 0px" }, // тот же порог, что был у `start: "top 75%"`
    );
    io.observe(path);

    return () => {
      io.disconnect();
      gsap.killTweensOf(path);
    };
  }, [d, duration, fill, fillColor]);

  return (
    <div className="lnd">
      <style href="motiva-line-draw" precedence="medium">{css}</style>

      <svg className="lnd-svg" viewBox={viewBox} role="img" aria-label="Контур, который прорисовывается штрихом">
        <path className="lnd-path" ref={pathRef} d={d} />
      </svg>

      {caption ? <p className="lnd-caption">{caption}</p> : null}
      {replay ? (
        <button type="button" className="lnd-replay" onClick={() => play.current()}>
          ↻ нарисовать заново
        </button>
      ) : null}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0