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

Что получится в конце:
Контурный рисунок прорисовывается линия за линией.
По клику снизу вверх поднимается акварель, край у неё неровный и «живой».
Вместе с фронтом краски летят золотые искры, а звук кисти синтезируется прямо в браузере.
Интерактивное демо с ползунками, где можно покрутить параметры края: [Sketch → Watercolor Reveal (SVG feTurbulence mask)
Нужны две версии одного рисунка:
цветная — обычная картинка (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: картинка заполняет экран и обрезается по краям, а не сжимается. У обоих слоёв он должен быть одинаковый, иначе линии разъедутся с цветом на экранах с другим соотношением сторон.
Классический приём через 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).
Самое интересное. Цветная картинка висит под маской. Маска в 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(); // краска пошла });
На этом этапе получается ровная «шторка» с размытым краем. Работает, но выглядит как слайд из презентации, а не как краска.
Весь эффект акварели держится на трёх строках фильтра:
<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, остальные стартуют сами со своей задержкой.

Чтобы заливка ощущалась событием, а не просто сменой картинки, вдоль фронта поднимаются золотые искры. Это обычные <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. Те, что стартуют позже, оказываются выше — и получается, что они «едут» на фронте заливки. Ручной синхронизации по таймерам не нужно.
Маленькая деталь, которая сильно добавляет ощущения: в момент заливки звучит мягкий «шорох кисти». Это не 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, все права на них принадлежат правообладателям.