Vanilla Disintegratev1.6.1
RU
На этой странице

Свой эффект — это две анимации: одна на удаление элемента, вторая на его восстановление. Анимацию и звук пишете вы. Библиотека снимает элемент, измеряет его и берёт на себя отмену и уборку.

Настроить частицы через плейграунд

Плейграунд на главной создаёт готовый пользовательский эффект с отдельными настройками частиц и звука для удаления и восстановления. Например, такой код сочетает удаление 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 или restore
  • element — сам элемент
  • bounds — где он на экране, как DOMRectReadOnly
  • layer — пустой оверлей, уже поставленный поверх этих границ
  • snapshot — снятый канвас или null
  • visual — канвас в полный размер с нарисованным снимком, создаётся при первом обращении
  • 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 продолжаются.

Редактировать страницу на GitHub