Vanilla Disintegratev1.6.1
EN
On this page

A custom effect is two animations: one for removing an element, one for putting it back. You write the animation and pick the sound. The library captures the element, measures it, and takes care of cancelling and cleaning up.

Configure particles with the playground

The playground on the home page generates a complete custom effect with independent particle and sound settings for removal and restoration. For example, this output combines scatter removal with vapor restoration:

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() uses the built-in particle renderer. Configure both phases in the playground, copy the generated code, and use it without writing a Canvas or WebGL renderer. The next section is only needed when you want to implement the rendering itself.

See the Particle API for every motion option, its default, accepted range, and the low-level phase factories.

Control particle render quality

renderQuality controls the resolution of the built-in WebGL renderer, independently from the capture adapter. The default auto mode protects memory and GPU work by limiting the renderer’s source texture to 2,000,000 pixels and 2,048 pixels per side, and its expanded animation surface to 4,000,000 pixels.

Use exact when preserving the capture’s full resolution is more important than those software limits:

const exactEffect = createParticleEffect({
  remove: { ...particlePresets.vapor, renderQuality: 'exact' },
  restore: { ...particlePresets.scatter, renderQuality: 'exact' },
});

Or set an explicit budget:

const boundedEffect = createParticleEffect({
  remove: {
    ...particlePresets.vapor,
    renderQuality: {
      maxSourcePixels: 4_000_000,
      maxSourceDimension: 4096,
      maxRenderPixels: 8_000_000,
    },
  },
});

exact disables software downscaling, but it cannot bypass the device’s WebGL texture and viewport limits. An unsupported exact surface is reported through onError instead of being silently degraded. Prefer auto for unknown or large elements; full resolution grows roughly with the square of the device pixel ratio.

Raise or lower the WebGL context ceiling

import { configureParticleContexts } from 'vanilla-disintegrate/snapdom';

configureParticleContexts({ maxContexts: 2, maxIdleContexts: 1 });

The particle renderer keeps at most 4 WebGL2 contexts alive at once across the whole page, with up to 2 kept warm for reuse after an effect finishes. This ceiling is page-wide rather than per-effect: browsers already cap how many live contexts a page may hold, and asking two effects for different values would leave no single correct answer.

An effect that cannot acquire a context because the page has reached the ceiling follows the same path as a browser with no WebGL2 at all: remove() still detaches the element and restore() still reveals it. A configured fallback runs instead; without one, the operation resolves with status skipped and onError receives a generic “did not create an animation” error rather than one naming contention as the cause. Call configureParticleContexts() once, before any effect runs, to change the ceiling for the rest of the page’s lifetime. Lower it on memory-constrained devices; raise it only up to what the browser itself allows, since exceeding that just moves where contexts get discarded.

Build a particle effect from a snapshot

This example reads the opaque pixels out of snapshot, turns them into Canvas particles, and spins them through a vortex. The effect uses a snapshot, and needsSnapshot: true is already the default, so the option can be omitted.

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,
});

Save the code above as particle-vortex.ts, then import particleVortex and pass it to the constructor:

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);

The demo above and the code block come from the same file, so what you see running is what you get.

needsSnapshot: false fits an effect that animates DOM, SVG or anything else that does not need the pixels. That phase then skips capture entirely.

A fallback without WebGL2

For a phase with requires: 'webgl2', pass a matching remove/restore pair as the constructor’s fallback. It may use CSS, the Web Animations API, Canvas 2D, SVG, or a custom renderer, but not WebGL2. It runs when WebGL2 is unavailable or the built-in particle renderer cannot start; with unavailable WebGL2, the library selects it before capture. It runs without built-in sound. See the full contract and example in the API.

Use the snapshotless core

When both phases set needsSnapshot: false, import /core: no capture adapter, particle renderer, or built-in audio enters the module graph.

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 });

Animation context

The factory receives:

  • operation — remove or restore
  • element — the element itself
  • bounds — where it sits on screen, as a DOMRectReadOnly
  • layer — an empty overlay already positioned over those bounds
  • snapshot — the captured canvas, or null
  • visual — a full-size canvas with the snapshot drawn on it, created on first use
  • signal — aborts when the operation is cancelled or the instance is destroyed
  • random() — random numbers for this operation, seeded so a run can be reproduced
  • reducedMotion — always false while a factory runs; reduced-motion requests skip before it is called
  • addCleanup(fn) — whatever you register here runs however the operation ends

On restore, bounds is the new place where you inserted the element, not where it was removed from.

Supported results

An animation factory may return:

type AnimationResult =
  | Animation
  | PromiseLike<unknown>
  | {
      element?: HTMLElement;
      finished: PromiseLike<unknown>;
      duration?: number;
      layoutDelay?: number;
      cancel?(): void;
      dispose?(): void;
    }
  | null;

So a Web Animation, a promise, a Canvas or WebGL loop, or a wrapper around another engine all fit. cancel() stops playback. dispose() frees whatever your renderer allocated, and it runs whether the animation finished, was cancelled, or failed.

Create your own preset

A custom effect is silent by default. When animation and audio should travel together as one reusable choice, wrap them in a complete preset:

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);

The built-in preset names are registered by the root and /snapdom entries. /core only sees presets supplied through presets or passed inline through preset.

Add custom audio

Keep audio separate from visual phases. The constructor accepts independent remove and restore definitions; omit sound when the custom effect should be silent.

const effects = new Disintegrator({
  effect: particleVortex,
  sound: {
    remove: { src: '/sounds/remove.ogg', volume: 0.35 },
    restore: ({ signal }) => startMyAudioEngine(signal),
  },
});

src also accepts a bundled name, URL, local Blob/File, ArrayBuffer, typed-array view, or AudioBuffer. If audio fails, the error goes to onError and the animation and DOM change continue.

Edit this page on GitHub