Когда перед фронтендером встает задача сделать выразительную интерактивную открытку или промо-лендинг, рука автоматически тянется к привычной тяжелой артиллерии: Three.js, Pixi.js, сложным Canvas-сценам или видеоролику на пару десятков мегабайт.
Я сам долгое время считал, что создать живой эффект рисования — сначала быстрый карандашный набросок контура, а затем растекающуюся по волокнам бумаги акварель с шуршанием кисти — невозможно без шейдеров WebGL или видеофайлов.
Но проект разработчика Boris0525 (репозиторий apostol0525/for-you) доказывает обратное. Весь этот визуальный спектакль реализуем на чистых веб-стандартах: декларативном SVG, математических фильтрах искажения, SMIL и процедурном синтезе звука через Web Audio API. При этом размер разметки и стилей укладывается в несколько килобайтов, сохраняя векторную четкость на любом дисплее.
Давай разберем эту механику по винтикам.
Анатомия сцены: два слоя и точная синхронизация
Графическая сцена состоит из двух слоев, наложенных друг на друга в общем контейнере:
- Нижний слой (акварель): растровое цветное изображение рисунка (
<image>), скрытое динамической SVG-маскойmask="url(#wc-mask)". - Верхний слой (карандашный контур): векторный набор путей
<path>без заливки (fill: none), прорисовывающий линии штриха поверх фоновой заливки.
Чтобы векторные линии не съехали относительно цветных пятен при ресайзе окна, оба слоя помещаются в единый viewBox="0 0 1410 752" с общим атрибутом масштабирования:
<svg viewBox="0 0 1410 752" preserveAspectRatio="xMidYMid slice" class="stage">
<!-- Нижний слой с маской заливки -->
<image href="color.webp" width="1410" height="752"
preserveAspectRatio="xMidYMid slice"
mask="url(#wc-mask)" />
<!-- Верхний слой векторных линий -->
<g class="lines" preserveAspectRatio="xMidYMid slice">
<!-- Набор контурных путей <path> -->
</g>
</svg>
Значение xMidYMid slice работает в SVG аналогично CSS-свойству object-fit: cover: оно центрирует графику и масштабирует ее с сохранением пропорций без искажений.
Трюк с pathLength: прорисовка штрихов без лишнего JavaScript
Классический способ анимировать рисование линий в SVG — использовать свойства stroke-dasharray и stroke-dashoffset. Но чтобы узнать длину каждого отдельного пути, разработчикам обычно приходится писать скрипты на JS, перебирать теги <path> и вызывать path.getTotalLength().
Автор проекта применил элегантный трюк из SVG 2 — атрибут pathLength="1":
<path d="M120 340 C 150 200, 280 180, 310 320 ..." pathLength="1" />
Указание pathLength="1" нормализует всю длину кривой к единице, независимо от ее реального размера в пикселях. Благодаря этому вся анимация штриха переносится в чистый CSS:
.lines path {
fill: none;
stroke: #5C4A38;
stroke-width: 1.2;
stroke-linecap: round;
stroke-dasharray: 1;
stroke-dashoffset: 1; /* Линия скрыта */
}
.lines.draw path {
animation: drawStroke 2s cubic-bezier(0.45, 0, 0.25, 1) forwards;
}
@keyframes drawStroke {
to { stroke-dashoffset: 0; }
}
Чтобы набросок не рисовался механически весь сразу, каждому штриху в цикле JavaScript назначается небольшая задержка style.animationDelay.
Органический край акварели: магия feTurbulence и feDisplacementMap
Когда набросок завершен, начинается проявление акварели. Жидкая краска впитывается в неровную текстуру бумаги, образуя живые рваные границы.

Маска рисунка строится на скользящем градиентном блоке, контур которого деформируется шумом Перлина через фильтры feTurbulence и feDisplacementMap:
<defs>
<!-- Фильтр органического края акварели -->
<filter id="wc-edge" x="-35%" y="-35%" width="170%" height="170%"
color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.010 0.016"
numOctaves="4" seed="11" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="150"
xChannelSelector="R" yChannelSelector="G" result="displaced" />
<feGaussianBlur in="displaced" stdDeviation="7" />
</filter>
<mask id="wc-mask">
<g filter="url(#wc-edge)">
<rect x="0" y="0" width="1410" height="752" fill="white">
<animate id="a-color" attributeName="x" from="-1410" to="0"
dur="2.5s" begin="indefinite" fill="freeze"
calcMode="spline" keyTimes="0; 1"
keySplines="0.45 0 0.25 1" />
</rect>
</g>
</mask>
</defs>
Механика проста:
feTurbulenceсоздает статичную матрицу фрактального шума. ПараметрыbaseFrequency="0.010 0.016"имитируют волокна хлопковой бумаги.- Белый прямоугольник маски двигается слева направо по команде SMIL
<animate>. feDisplacementMapсмещает границу движущегося прямоугольника по каналам шума на величину до 150 пикселей (scale="150"). Край непрерывно изгибается в реальном времени.
Параметр color-interpolation-filters="sRGB" критичен: без него браузеры считают фильтры в linearRGB, из-за чего края становятся блеклыми.
Процедурный звук кисти на Web Audio API
Вместо внешних MP3-файлов с задержками предзагрузки звук шуршания кисти синтезируется процедурно прямо в браузере при касании экрана:
function playBrushSound(audioCtx) {
const now = audioCtx.currentTime;
const duration = 1.4;
// 1. Буфер белого шума
const buffer = audioCtx.createBuffer(1, audioCtx.sampleRate * duration, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = audioCtx.createBufferSource();
source.buffer = buffer;
// 2. Низкочастотный фильтр с имитацией нажима
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(360, now);
filter.frequency.linearRampToValueAtTime(1500, now + duration * 0.45);
filter.frequency.linearRampToValueAtTime(480, now + duration);
// 3. Огибающая громкости
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(0.0001, now);
gain.gain.linearRampToValueAtTime(0.075, now + 0.4);
gain.gain.linearRampToValueAtTime(0.0001, now + duration);
source.connect(filter).connect(gain).connect(audioCtx.destination);
source.start(now);
source.stop(now + duration);
}
Функция не расходует сетевой трафик, стартует мгновенно в обработчике pointerdown и точно передает ощущение скольжения кисти по бумаге.
Производительность и доступность
Фильтры feTurbulence и feDisplacementMap рассчитываются попиксельно. Чтобы сохранить 60 кадров в секунду на смартфонах:
- Держи
numOctavesне выше 4, иначе вычисления удваиваются без заметного прироста качества; - Скрывай отработавшие слои через
display: noneпосле окончания анимации; - Для перезапуска в Chrome клонируй узел
<animate>черезcloneNode().
Не забудь о доступности: отключи анимацию для пользователей с настройкой @media (prefers-reduced-motion: reduce), показывая сразу готовый рисунок.
Этот проект наглядно показывает: возможности нативных веб-стандартов огромны, если грамотно задействовать декларативный SVG и фильтры платформы.
