Свой эффект — это две анимации: одна на удаление элемента, вторая на его восстановление. Анимацию и звук пишете вы. Библиотека снимает элемент, измеряет его и берёт на себя отмену и уборку.
Настроить частицы через плейграунд
Плейграунд на главной создаёт готовый пользовательский эффект с отдельными настройками частиц и звука для удаления и восстановления. Например, такой код сочетает удаление scatter с восстановлением vapor:
import Disintegrator, { createParticleEffect, particlePresets } from 'vanilla-disintegrate/snapdom';
const sharedSoundOptions = {
volume: 0.32,
playbackRate: 1,
delay: 0,
fadeDuration: 0.18,
};
export const disintegrator = new Disintegrator({
effect: createParticleEffect({
remove: particlePresets.scatter,
restore: particlePresets.vapor,
}),
sound: {
remove: {
...sharedSoundOptions,
src: 'scatter',
reverse: false,
},
restore: {
...sharedSoundOptions,
src: 'vapor',
reverse: true,
},
},
});
createParticleEffect() использует встроенный рендерер частиц. Настройте обе фазы в плейграунде, скопируйте полученный код и используйте его без самостоятельной реализации рендерера на Canvas или WebGL. Следующий раздел нужен, только если вы хотите реализовать саму отрисовку.
Все параметры движения, их значения по умолчанию, допустимые диапазоны и низкоуровневые фабрики фаз описаны в API частиц.
Управлять качеством рендеринга частиц
renderQuality управляет разрешением встроенного WebGL-рендерера независимо от адаптера захвата. Режим auto по умолчанию ограничивает расход памяти и нагрузку на GPU: исходная текстура рендерера содержит не больше 2 000 000 пикселей и 2 048 пикселей по одной стороне, а расширенная поверхность анимации — не больше 4 000 000 пикселей.
Используйте exact, если сохранение полного разрешения снимка важнее этих программных лимитов:
const exactEffect = createParticleEffect({
remove: { ...particlePresets.vapor, renderQuality: 'exact' },
restore: { ...particlePresets.scatter, renderQuality: 'exact' },
});
Либо задайте собственный бюджет:
const boundedEffect = createParticleEffect({
remove: {
...particlePresets.vapor,
renderQuality: {
maxSourcePixels: 4_000_000,
maxSourceDimension: 4096,
maxRenderPixels: 8_000_000,
},
},
});
exact отключает программное уменьшение, но не может обойти аппаратные ограничения WebGL на текстуру и viewport. Если устройство не поддерживает требуемую точную поверхность, ошибка попадёт в onError вместо скрытого ухудшения качества. Для неизвестных или больших элементов выбирайте auto: расход при полном разрешении растёт примерно пропорционально квадрату device pixel ratio.
Изменить потолок контекстов WebGL
import { configureParticleContexts } from 'vanilla-disintegrate/snapdom';
configureParticleContexts({ maxContexts: 2, maxIdleContexts: 1 });
Рендерер частиц держит одновременно не больше 4 контекстов WebGL2 на всю страницу, из них до 2 остаются прогретыми для повторного использования после завершения эффекта. Этот потолок общий для страницы, а не для каждого эффекта отдельно: браузер и так ограничивает число живых контекстов на странице, а если бы два эффекта запрашивали разные значения, единственно верного ответа не нашлось бы.
Эффект, которому не хватило контекста из-за упора в потолок, проходит тот же путь, что и браузер без WebGL2: remove() всё равно открепляет элемент, restore() всё равно показывает его. При настроенном fallback запустится он; без него операция завершается со статусом skipped, а в onError попадает общая ошибка «не создал анимацию» — без указания на то, что причина именно в нехватке контекстов. Вызовите configureParticleContexts() один раз, до первого запуска любого эффекта, чтобы изменить потолок на весь срок жизни страницы. Понижайте его на устройствах с ограниченной памятью; повышайте только до предела, который допускает сам браузер, — превышение лишь переносит место, где контексты будут отброшены.
Эффект из снимка и частиц
Пример ниже берёт непрозрачные пиксели из snapshot, превращает их в Canvas-частицы и ведёт через вихрь. Эффект использует снимок, а needsSnapshot: true уже является значением по умолчанию, поэтому этот параметр можно не указывать.
import { defineEffect, type AnimationFactory, type SoundFactory } from 'vanilla-disintegrate';
type VortexPhase = 'collapse' | 'expand';
interface VortexParticle {
readonly angle: number;
readonly color: string;
readonly delay: number;
readonly radius: number;
readonly size: number;
readonly twist: number;
}
const PARTICLE_BUDGET = 900;
const TAU = Math.PI * 2;
function clamp(value: number) {
return Math.min(1, Math.max(0, value));
}
function smoothstep(value: number) {
const progress = clamp(value);
return progress * progress * (3 - 2 * progress);
}
function sampleParticles(
snapshot: HTMLCanvasElement,
sourceX: number,
sourceY: number,
centerX: number,
centerY: number,
random: () => number,
) {
const context = snapshot.getContext('2d', { willReadFrequently: true });
if (context === null) return [];
const pixels = context.getImageData(0, 0, snapshot.width, snapshot.height).data;
const step = Math.max(2, Math.ceil(Math.sqrt((snapshot.width * snapshot.height) / PARTICLE_BUDGET)));
const particles: VortexParticle[] = [];
for (let y = Math.floor(step / 2); y < snapshot.height; y += step) {
for (let x = Math.floor(step / 2); x < snapshot.width; x += step) {
const offset = (y * snapshot.width + x) * 4;
const alpha = pixels[offset + 3] ?? 0;
if (alpha < 20) continue;
const targetX = sourceX + x;
const targetY = sourceY + y;
const deltaX = targetX - centerX;
const deltaY = targetY - centerY;
const direction = random() < 0.5 ? -1 : 1;
particles.push({
angle: Math.atan2(deltaY, deltaX),
color: `rgb(${String(pixels[offset] ?? 0)} ${String(pixels[offset + 1] ?? 0)} ${String(
pixels[offset + 2] ?? 0,
)} / ${String(alpha / 255)})`,
delay: random() * 0.16,
radius: Math.hypot(deltaX, deltaY),
size: step * (0.62 + random() * 0.5),
twist: direction * TAU * (1.35 + random() * 1.65),
});
}
}
return particles;
}
function createVortexParticles(phase: VortexPhase): AnimationFactory {
const duration = phase === 'collapse' ? 720 : 860;
return ({ bounds, random, signal, snapshot }) => {
if (snapshot === null) return null;
const scaleX = snapshot.width / bounds.width;
const scaleY = snapshot.height / bounds.height;
const padding = Math.min(150, Math.max(54, Math.max(bounds.width, bounds.height) * 0.3));
const sourceX = padding * scaleX;
const sourceY = padding * scaleY;
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(snapshot.width + sourceX * 2);
canvas.height = Math.ceil(snapshot.height + sourceY * 2);
Object.assign(canvas.style, {
height: `${bounds.height + padding * 2}px`,
left: `${-padding}px`,
pointerEvents: 'none',
position: 'absolute',
top: `${-padding}px`,
width: `${bounds.width + padding * 2}px`,
});
const context = canvas.getContext('2d');
if (context === null) return null;
const centerX = sourceX + snapshot.width / 2;
const centerY = sourceY + snapshot.height / 2;
const particles = sampleParticles(snapshot, sourceX, sourceY, centerX, centerY, random);
if (particles.length === 0) return null;
const render = (progress: number) => {
context.clearRect(0, 0, canvas.width, canvas.height);
const snapshotAlpha =
phase === 'collapse' ? 1 - smoothstep(progress / 0.28) : smoothstep((progress - 0.68) / 0.32);
const particleBlend =
phase === 'collapse' ? smoothstep(progress / 0.24) : 1 - smoothstep((progress - 0.74) / 0.26);
if (snapshotAlpha > 0) {
context.globalAlpha = snapshotAlpha;
context.drawImage(snapshot, sourceX, sourceY);
context.globalAlpha = 1;
}
const portalStrength = Math.sin(progress * Math.PI);
context.save();
context.globalCompositeOperation = 'lighter';
context.strokeStyle = `rgb(255 94 190 / ${String(portalStrength * 0.72)})`;
context.lineWidth = Math.max(2, 5 * scaleX * portalStrength);
context.beginPath();
context.arc(centerX, centerY, (12 + portalStrength * 38) * scaleX, 0, TAU);
context.stroke();
context.restore();
for (const particle of particles) {
const local = clamp((progress - particle.delay) / (1 - particle.delay));
let radiusFactor: number;
let angle: number;
let alpha: number;
let sizeFactor: number;
if (phase === 'collapse') {
const motion = Math.pow(local, 2.35);
radiusFactor = 1 - motion;
angle = particle.angle + particle.twist * motion;
alpha = 1 - smoothstep((local - 0.78) / 0.22);
sizeFactor = 1 - motion * 0.76;
} else {
const motion = 1 - Math.pow(1 - local, 2.8);
radiusFactor = motion;
angle = particle.angle - particle.twist * (1 - motion);
alpha = smoothstep(local / 0.24);
sizeFactor = 0.24 + motion * 0.76;
}
const x = centerX + Math.cos(angle) * particle.radius * radiusFactor;
const y = centerY + Math.sin(angle) * particle.radius * radiusFactor;
context.globalAlpha = alpha * particleBlend;
context.fillStyle = particle.color;
context.beginPath();
context.arc(x, y, Math.max(0.7, particle.size * sizeFactor * 0.5), 0, TAU);
context.fill();
}
context.globalAlpha = 1;
};
let frame = 0;
let startedAt: number | null = null;
let settled = false;
let disposed = false;
let resolveFinished: () => void = () => undefined;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
const finish = () => {
if (settled) return;
settled = true;
cancelAnimationFrame(frame);
resolveFinished();
};
const tick = (time: number) => {
if (settled) return;
startedAt ??= time;
const progress = clamp((time - startedAt) / duration);
render(progress);
if (progress >= 1) {
finish();
} else {
frame = requestAnimationFrame(tick);
}
};
const cancel = () => finish();
const dispose = () => {
if (disposed) return;
disposed = true;
finish();
signal.removeEventListener('abort', cancel);
canvas.width = 0;
canvas.height = 0;
};
render(0);
signal.addEventListener('abort', cancel, { once: true });
frame = requestAnimationFrame(tick);
return { element: canvas, duration, finished, cancel, dispose };
};
}
const createVortexTone: SoundFactory = ({ operation, signal }) => {
const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();
const remove = operation === 'remove';
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(remove ? 560 : 150, context.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(remove ? 110 : 680, context.currentTime + 0.48);
gain.gain.setValueAtTime(0.0001, context.currentTime);
gain.gain.exponentialRampToValueAtTime(0.075, context.currentTime + 0.035);
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.52);
oscillator.connect(gain).connect(context.destination);
oscillator.start();
oscillator.stop(context.currentTime + 0.54);
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
signal.removeEventListener('abort', dispose);
try {
oscillator.stop();
} catch {
// The oscillator may already have reached its scheduled stop.
}
void context.close();
};
signal.addEventListener('abort', dispose, { once: true });
oscillator.addEventListener('ended', dispose, { once: true });
return { stop: dispose, dispose };
};
export const particleVortex = defineEffect({
remove: {
animate: createVortexParticles('collapse'),
},
restore: {
animate: createVortexParticles('expand'),
},
});
export const particleVortexSounds = Object.freeze({
remove: createVortexTone,
restore: createVortexTone,
});
Сохраните приведённый выше код в файле particle-vortex.ts, затем импортируйте particleVortex и передайте его в конструктор:
import Disintegrator from 'vanilla-disintegrate/snapdom';
import { particleVortex } from './particle-vortex';
const effects = new Disintegrator({ effect: particleVortex });
effects.remove(element, { retain: true });
effects.restore(insertedElement);
Демо выше и блок кода взяты из одного файла, так что работает ровно то, что вы видите.
needsSnapshot: false подходит эффекту, который анимирует DOM, SVG или что-то ещё, чему пиксели не нужны. Тогда для этой фазы захвата не будет вовсе.
Резервный эффект без WebGL2
Для фазы с requires: 'webgl2' можно передать в конструктор fallback с такой же парой remove и restore. В нём разрешены CSS, Web Animations API, Canvas 2D, SVG и собственный рендерер, но не WebGL2. Он сработает, если WebGL2 недоступен или встроенный рендерер частиц не смог начать работу; при недоступном WebGL2 библиотека выберет его до захвата снимка. Он выполняется без встроенного звука. Полный контракт и пример — в API.
Лёгкий вход без снимков
Если обе фазы задают needsSnapshot: false, импортируйте /core: адаптер захвата, рендерер частиц и встроенное аудио не попадут в граф модулей.
import Disintegrator, { defineEffect } from 'vanilla-disintegrate/core';
const fade = defineEffect({
remove: {
needsSnapshot: false,
animate: ({ element, layer }) => {
const visual = element.cloneNode(true) as HTMLElement;
Object.assign(visual.style, {
height: '100%',
inset: '0',
margin: '0',
pointerEvents: 'none',
position: 'absolute',
width: '100%',
});
layer.append(visual);
return visual.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 240 });
},
},
restore: {
needsSnapshot: false,
animate: ({ element, layer }) => {
const visual = element.cloneNode(true) as HTMLElement;
Object.assign(visual.style, {
height: '100%',
inset: '0',
margin: '0',
pointerEvents: 'none',
position: 'absolute',
width: '100%',
});
layer.append(visual);
return visual.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 240 });
},
},
});
const effects = new Disintegrator({ effect: fade });
Контекст анимации
Фабрика получает:
operation—removeилиrestoreelement— сам элементbounds— где он на экране, какDOMRectReadOnlylayer— пустой оверлей, уже поставленный поверх этих границsnapshot— снятый канвас илиnullvisual— канвас в полный размер с нарисованным снимком, создаётся при первом обращенииsignal— сработает при отмене операции или уничтожении экземпляраrandom()— случайные числа для этой операции, с сидом, чтобы прогон можно было повторитьreducedMotion— всегдаfalseвнутри запущенной фабрики; запрос на уменьшение движения пропускает операцию до её вызоваaddCleanup(fn)— что сюда положите, выполнится при любом исходе
При восстановлении bounds — это новое место, куда вы вставили элемент, а не то, откуда его удалили.
Допустимые результаты
Фабрика анимации может вернуть:
type AnimationResult =
| Animation
| PromiseLike<unknown>
| {
element?: HTMLElement;
finished: PromiseLike<unknown>;
duration?: number;
layoutDelay?: number;
cancel?(): void;
dispose?(): void;
}
| null;
Так подойдёт и Web Animation, и промис, и цикл на Canvas или WebGL, и обёртка над чужим движком. cancel() останавливает проигрывание. dispose() освобождает то, что выделил ваш рендерер, и вызывается независимо от того, доиграла анимация, была отменена или упала.
Создать свой пресет
Пользовательский effect по умолчанию бесшумный. Если анимация и аудио должны использоваться вместе как один готовый выбор, объедините их в полный пресет:
import { definePreset } from 'vanilla-disintegrate';
const vortex = definePreset({
effect: particleVortex,
sound: {
remove: { src: 'vapor', volume: 0.35 },
restore: { src: 'vapor', volume: 0.35, reverse: true },
},
});
const effects = new Disintegrator({
preset: 'vortex',
presets: { vortex },
});
effects.remove(element);
Имена встроенных пресетов зарегистрированы в корневом входе и /snapdom. /core видит только пресеты, переданные через presets или инлайном через preset.
Свой звук
Храните звук отдельно от визуальных фаз. Конструктор принимает независимые определения remove и restore; не передавайте sound, если пользовательский эффект должен быть бесшумным.
const effects = new Disintegrator({
effect: particleVortex,
sound: {
remove: { src: '/sounds/remove.ogg', volume: 0.35 },
restore: ({ signal }) => startMyAudioEngine(signal),
},
});
src также принимает встроенное имя, URL, локальный Blob/File, ArrayBuffer, typed-array view или AudioBuffer. Если звук не сложился, ошибка уходит в onError, а анимация и изменение DOM продолжаются.