Карандашный набросок, который заливается акварелью: разбираю эффект на SVG без библиотек

Делал интерактивную открытку на день рождения и хотел, чтобы картинка не просто появлялась, а «рисовалась»: сначала карандашные линии, потом по касанию её заливает акварель, причём с рваным краем, как у краски, которая впитывается в бумагу.
Готовых решений под такое не нашёл. Видео не подходило: нужна была реакция на тап и чёткость на любом экране. В итоге всё собралось на чистом SVG, двух фильтрах и пятидесяти строках JS. Ниже разбираю по шагам, с кодом и граблями, на которые наступил.

Что получится в конце:
Контурный рисунок прорисовывается линия за линией.
По клику снизу вверх поднимается акварель, край у неё неровный и «живой».
Вместе с фронтом краски летят золотые искры, а звук кисти синтезируется прямо в браузере.
Интерактивное демо с ползунками, где можно покрутить параметры края: [Sketch → Watercolor Reveal (SVG feTurbulence mask)
Шаг 1. Два слоя одной картинки
Нужны две версии одного рисунка:
цветная — обычная картинка (JPG/WebP), сама акварель;
контурная — SVG, где каждая линия рисунка отдельный
<path>без заливки, только соstroke.
Контурную версию я получил трассировкой цветного рисунка в векторном редакторе. Важно, чтобы на выходе были именно линии (stroke), а не залитые фигуры, иначе прорисовывать будет нечего.
Слои кладутся друг на друга:
<div class="stage">
<!-- нижний слой: цветная картинка под маской -->
<svg class="layer" viewBox="0 0 1410 752" preserveAspectRatio="xMidYMid slice">
<rect width="1410" height="752" fill="#DDCEC7"/> <!-- цвет «бумаги» -->
<image href="color.jpg" width="1410" height="752"
preserveAspectRatio="xMidYMid slice" mask="url(#wc-mask)"/>
</svg>
<!-- верхний слой: контуры -->
<div class="layer lines"></div>
</div>preserveAspectRatio="xMidYMid slice" — это SVG‑аналог object-fit: cover: картинка заполняет экран и обрезается по краям, а не сжимается. У обоих слоёв он должен быть одинаковый, иначе линии разъедутся с цветом на экранах с другим соотношением сторон.
Шаг 2. Линии, которые рисуются сами
Классический приём через stroke-dasharray и stroke-dashoffset. Делаем пунктир, у которого один штрих длиной во всю линию, а потом сдвигаем его так, чтобы штрих ушёл за пределы линии. Линия становится невидимой. Анимируем сдвиг обратно к нулю — и линия «прорисовывается».
Обычно длину каждой линии считают через getTotalLength(). Но есть трюк проще: атрибут pathLength="1" говорит браузеру «считай, что длина этой линии равна 1». Тогда для всех линий подходят одни и те же значения:
.lines path {
fill: none;
stroke: #5C4A38;
stroke-width: 1.2;
stroke-linecap: round;
stroke-dasharray: 1;
stroke-dashoffset: 1; /* линия спрятана */
}
.lines.draw path {
animation: draw 2s cubic-bezier(.45, 0, .25, 1) forwards;
}
@keyframes draw {
to { stroke-dashoffset: 0; }
}е линии одновременно, картинка просто «проявится» целиком, и магии не будет. Нужен разнобой по времени: каждой линии своя задержка.
async function drawLines(box, dur = 2, stagger = 1) {
box.innerHTML = await fetch('lines.svg').then(r => r.text());
const paths = box.querySelectorAll('path');
paths.forEach((p, i) => {
p.setAttribute('pathLength', '1');
p.style.animationDuration = dur + 's';
p.style.animationDelay = (i / paths.length * stagger).toFixed(3) + 's';
});
box.classList.add('draw');
return new Promise(r => setTimeout(r, (dur + stagger) * 1000));
}SVG вставляется в DOM через fetch + innerHTML, а не через <img>: к линиям внутри <img> не достучаться ни из CSS, ни из JS.
Порядок линий в файле задаёт порядок прорисовки. После трассировки он обычно хаотичный, и это даже хорошо: картинка проступает по всей площади сразу, как набросок. Если хочется, чтобы рисунок шёл сверху вниз, можно сортировать задержку по координате Y начала линии (её легко достать регуляркой из атрибута d).
Шаг 3. Акварель: маска с рваным краем
Самое интересное. Цветная картинка висит под маской. Маска в SVG работает по яркости: где маска белая — картинка видна, где чёрная — нет.
Поднимающаяся заливка
Внутри маски кладём большой прямоугольник с градиентом: сверху чёрный, ниже белый. В начале он стоит под картинкой, а анимация двигает его вверх.
<defs>
<linearGradient id="wc-band" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#000"/>
<stop offset="0.30" stop-color="#fff"/> <!-- мягкая кромка -->
<stop offset="1" stop-color="#fff"/>
</linearGradient>
<mask id="wc-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="1410" height="752">
<g filter="url(#wc-edge)">
<rect x="-260" y="752" width="1930" height="1300" fill="url(#wc-band)">
<animate id="a-color" attributeName="y" begin="indefinite" dur="2.6s"
from="752" to="-460" fill="freeze"
calcMode="spline" keyTimes="0;1" keySplines="0.33 0 0.15 1"/>
</rect>
</g>
</mask>
</defs>Прямоугольник шире картинки (x="-260", width="1930") специально: на следующем шаге фильтр будет его искажать, и без запаса по бокам появятся пустые углы.
Анимация сделана на SMIL (<animate>), а не на CSS: SVG‑атрибут y внутри маски так двигать надёжнее всего во всех браузерах. begin="indefinite" значит «не стартовать самой», запускаем вручную из JS:
stage.addEventListener('pointerdown', () => {
lines.classList.add('fade'); // линии растворяются
document.getElementById('a-color').beginElement(); // краска пошла
});На этом этапе получается ровная «шторка» с размытым краем. Работает, но выглядит как слайд из презентации, а не как краска.
Рваный край: feTurbulence + feDisplacementMap
Весь эффект акварели держится на трёх строках фильтра:
<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="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="150"
xChannelSelector="R" yChannelSelector="G" result="d"/>
<feGaussianBlur in="d" stdDeviation="7"/>
</filter>Что тут происходит:
feTurbulenceгенерирует фрактальный шум — облачную текстуру, похожую на разводы.feDisplacementMapсдвигает каждый пиксель прямоугольника на величину, которую берёт из шума: красный канал шума отвечает за сдвиг по X, зелёный — по Y. Ровный край прямоугольника превращается в рваную береговую линию.feGaussianBlurнемного смягчает результат, чтобы край был «мокрым», а не пиксельным.
Ключевой момент: шум неподвижен, а прямоугольник едет сквозь него. Поэтому край не просто рваный, он ещё и меняет форму по мере движения: в одних местах краска «забегает» вперёд, в других отстаёт. Именно это и выглядит как краска, которая впитывается в бумагу.

Параметры, которые стоит покрутить:
Параметр | Что меняет |
|---|---|
| Насколько рваный край. 0 — ровная шторка, 150 — акварель, 300 — уже хаос |
| Размер «пятен». Меньше — крупные плавные разводы, больше — мелкая рябь |
Два значения в | Разная частота по X и Y. У меня по вертикали чуть выше, пятна вытянуты горизонтально, как мазки широкой кистью |
| Другой рисунок шума. Перебирал, пока край не лёг красиво на конкретную картинку |
| Детализация. Больше 4–5 на глаз почти не видно, а считать дороже |
Именно поэтому в демо на CodePen вынесены ползунки: подбирать эти значения на глаз гораздо быстрее, чем в коде.
Та же маска, другая форма: пятно краски
Для портрета я использовал тот же фильтр, но вместо поднимающегося прямоугольника — несколько кругов, которые растут из разных точек с небольшим сдвигом по времени. Получается, будто на бумагу капнули краской и она растекается:
<mask id="face-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="1025" height="1025">
<g filter="url(#face-wc)">
<circle cx="505" cy="460" r="1" fill="url(#face-blot)">
<animate id="a-face" attributeName="r" begin="indefinite" dur="1.8s"
from="1" to="1050" fill="freeze"
calcMode="spline" keySplines="0.16 0.6 0.2 1"/>
</circle>
<circle cx="280" cy="730" r="1" fill="url(#face-blot)">
<animate attributeName="r" begin="a-face.begin+0.25s" dur="1.5s"
from="1" to="720" fill="freeze"
calcMode="spline" keySplines="0.16 0.6 0.2 1"/>
</circle>
<!-- ... -->
</g>
</mask>Обратите внимание на begin="a-face.begin+0.25s": в SMIL одну анимацию можно привязать к старту другой. Запускаем первую из JS, остальные стартуют сами со своей задержкой.

Шаг 4. Искры на фронте краски
Чтобы заливка ощущалась событием, а не просто сменой картинки, вдоль фронта поднимаются золотые искры. Это обычные <circle> с радиальным градиентом, которые генерируются из JS и летят по кривой через <animateMotion>:
for (let i = 0; i < 70; i++) {
const x = 30 + Math.random() * 1350;
const y = 752 - Math.random() * 90; // старт у нижнего края
const rise = 190 + Math.random() * 460;
const sway = (Math.random() * 2 - 1) * 90; // покачивание в сторону
const dur = (1.1 + Math.random() * 1.3).toFixed(2) + 's';
const begin = 'a-color.begin+' + (Math.random() * 2).toFixed(2) + 's';
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('r', (2 + Math.random() * 3.4).toFixed(1));
c.setAttribute('fill', 'url(#spark)');
c.setAttribute('opacity', '0');
c.innerHTML = `
<animateMotion dur="${dur}" begin="${begin}" calcMode="spline"
keyPoints="0;1" keyTimes="0;1" keySplines="0.2 0.6 0.3 1"
path="M ${x|0} ${y|0} q ${sway|0} ${-rise/2|0} ${sway*0.4|0} ${-rise|0}"/>
<animate attributeName="opacity" begin="${begin}" dur="${dur}"
values="0;1;1;0" keyTimes="0;0.18;0.5;1"/>`;
fx.appendChild(c);
}Искры привязаны к старту краски через a-color.begin+N s. Те, что стартуют позже, оказываются выше — и получается, что они «едут» на фронте заливки. Ручной синхронизации по таймерам не нужно.
Шаг 5. Звук кисти без аудиофайла
Маленькая деталь, которая сильно добавляет ощущения: в момент заливки звучит мягкий «шорох кисти». Это не mp3, а белый шум, пропущенный через фильтр низких частот в Web Audio API:
function paintSound(ctx) {
const t = ctx.currentTime, dur = 1.4;
const buf = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; // белый шум
const src = ctx.createBufferSource();
src.buffer = buf;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(360, t);
lp.frequency.linearRampToValueAtTime(1500, t + dur * 0.45); // звук «раскрывается»
lp.frequency.linearRampToValueAtTime(480, t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.075, t + 0.4);
g.gain.linearRampToValueAtTime(0.0001, t + dur);
src.connect(lp).connect(g).connect(ctx.destination);
src.start(t);
src.stop(t + dur);
}Фильтр сначала приоткрывается (360 → 1500 Гц), потом закрывается обратно. На слух это похоже на влажный мазок кистью. Ноль килобайт ассетов.
AudioContext браузеры разрешают создавать только после действия пользователя, поэтому создаю его лениво — при первом тапе.
Грабли
1. Перезапуск SMIL‑анимации. Для кнопки «ещё раз» нужно вернуть маску в начало. Первая мысль — beginElement() и сразу endElement(). В Chrome это не работает: с fill="freeze" анимация остаётся в конечном состоянии, и картинка видна целиком. Рабочий способ — заменить элемент анимации его свежей копией. У новой копии нет истории, и прямоугольник возвращается к исходному y:
const fresh = aColor.cloneNode(true);
aColor.replaceWith(fresh);
aColor = fresh;2. Обрезанный фильтр. По умолчанию область фильтра всего на 10% больше элемента. feDisplacementMap сдвигает пиксели дальше, и край обрезается ровной линией — весь эффект пропадает. Лечится расширением области: x="-35%" y="-35%" width="170%" height="170%".
3. Цвета «плывут». Фильтры SVG по умолчанию считают в цветовом пространстве linearRGB, из‑за этого полупрозрачные области маски получаются светлее ожидаемого и край выглядит блёклым. color-interpolation-filters="sRGB" возвращает привычное поведение.
4. Производительность. feTurbulence пересчитывается на каждом кадре, пока под фильтром что‑то движется. На полноэкранной картинке на слабых телефонах это заметно. Помогло: не ставить numOctaves больше 4, держать анимацию короткой (2–3 секунды) и после её окончания убирать лишнее (у меня слой с линиями после заливки получает display: none).
5. Доступность. Тем, у кого включено «уменьшение движения», показываю сразу финальное состояние:
@media (prefers-reduced-motion: reduce) {
.lines.draw path { animation-duration: .01s; animation-delay: 0s !important; }
}И раз картинка запускается по тапу, у сцены есть tabindex="0" и обработчик Enter/Space — с клавиатуры тоже можно «покрасить».
Итог
Весь эффект — это:
stroke-dashoffset+pathLength="1"для прорисовки линий;маска с движущейся фигурой для заливки;
feTurbulence+feDisplacementMapдля рваного акварельного края;SMIL для синхронизации всего этого без таймеров в JS.
Никаких библиотек, canvas и видео, и всё остаётся векторным и резким на любом экране.
Попробовать эффект и покрутить параметры: [Sketch → Watercolor Reveal (SVG feTurbulence mask)] Открытка целиком (лучше с телефона): https://gifts‑happy‑birthday.vercel.app/ Код: https://github.com/apostol0525/for‑you
Это некоммерческий фан‑проект, рисунки по мотивам «Ведьминой службы доставки» Studio Ghibli, все права на них принадлежат правообладателям.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.