Line Draw
Контур прорисовывается штрихом и следом наливается цветом. Длина берётся у самого пути, поэтому приём работает с любым SVG без правки чисел, а заливка идёт с нахлёстом — без мёртвой паузы после линии.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| d? | string | DEFAULT_D | Данные контура (атрибут `d` у `<path>`). По умолчанию — нейтральный знак. |
| viewBox? | string | "0 0 300 160" | Система координат контура. |
| duration? | number | 1.6 | Длительность прорисовки, с. |
| fill? | boolean | true | Заливать контур после прорисовки. |
| caption? | string | "штрих выезжает из ниоткуда, потом конту… | |
| replay? | boolean | true |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/line-draw.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
Файл
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 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>
);
}