← All particles

Dissolve Out

An element breaking into pieces that scatter and fade as a sweep removes it.

revealdecisiveclean240 particles · moderate · canvas-2d · interaction · finite
Interactive · try it
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

299 lines · react only
import { useEffect, useRef, type ReactNode } from "react";

/**
 * Vibary · Dissolve Out
 *
 * An element breaking into particles and blowing away. The technique is
 * that both halves are driven by one number. A sweep position moves
 * across the element; a gradient mask clips the element at exactly that
 * line, and the particles are spawned at exactly that line as it
 * passes. So the particles are the material the mask just removed,
 * rather than a particle animation playing on top of an element that
 * happens to be fading. Fade the element on its own clock and the eye
 * catches the mismatch immediately — the thing is gone before its
 * pieces leave, or the pieces leave from somewhere it still is.
 *
 * The pieces are painted in the element's own computed text color:
 * sampling a DOM node's real pixels needs a rasterizer, and this file
 * has no dependencies. One inherited tint is the honest approximation.
 *
 * Self-contained: one file, no dependencies at all.
 * Works with zero props; drive it by toggling `dissolved`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type DissolveOutProps = {
  /** Visual character of the dissolve. */
  variant?: "subtle" | "default" | "playful";
  /** Flip to true to break the element apart, back to false to restore it. */
  dissolved?: boolean;
  /** What dissolves. Sample content is used when nothing is passed. */
  children?: ReactNode;
  /** Particle tint. Defaults to the element's own computed text color. */
  color?: string;
  /** Direction the sweep travels across the element, in degrees. */
  angle?: number;
  /** Fires once the last piece has faded. */
  onDissolved?: () => void;
};

type VariantConfig = {
  /** Seconds for the sweep to cross the element. */
  sweep: number;
  /** Px of one grid cell — the size of a piece, and the density. */
  cell: number;
  /** How far a piece drifts before it is gone, in px per second. */
  drift: number;
  /** Seconds a piece stays visible after it is released. */
  life: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a dissolve: fine pieces, small drift, quickly over.
  subtle: { sweep: 0.42, cell: 7, drift: 26, life: 0.4 },
  // Reads clearly as breaking apart. All-purpose.
  default: { sweep: 0.55, cell: 9, drift: 42, life: 0.55 },
  // Coarser pieces thrown further, with a longer tail.
  playful: { sweep: 0.72, cell: 12, drift: 68, life: 0.75 },
};

type Piece = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  size: number;
  spin: number;
  tilt: number;
  /** Position along the sweep axis, 0–1: when the edge passes, it goes. */
  release: number;
  age: number;
  life: number;
};

/** Canvas overhang in px, so pieces can drift outside the element. */
const BLEED = 26;

export default function DissolveOut({
  variant = "default",
  dissolved = false,
  children,
  color,
  angle = 105,
  onDissolved,
}: DissolveOutProps) {
  const contentRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The callback is held in a ref and updated in its own effect, so an
  // inline arrow from the parent cannot restart the dissolve.
  const doneRef = useRef(onDissolved);
  useEffect(() => {
    doneRef.current = onDissolved;
  }, [onDissolved]);

  useEffect(() => {
    const content = contentRef.current;
    const canvas = canvasRef.current;
    if (!content || !canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const setMask = (value: string) => {
      content.style.setProperty("-webkit-mask-image", value);
      content.style.setProperty("mask-image", value);
    };

    if (!dissolved) {
      // Restored. Clear both halves and leave nothing running.
      setMask("none");
      context.clearRect(0, 0, canvas.width, canvas.height);
      return;
    }

    // Measured once, when the run starts: a dissolve is over in well
    // under a second, and re-measuring mid-run would slide the pieces
    // away from the pixels they were cut from.
    const rect = content.getBoundingClientRect();
    const width = rect.width;
    const height = rect.height;
    const ratio = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.max(1, Math.floor((width + BLEED * 2) * ratio));
    canvas.height = Math.max(1, Math.floor((height + BLEED * 2) * ratio));
    context.setTransform(ratio, 0, 0, ratio, 0, 0);

    // Reduced motion: the element goes, and nothing flies. The outcome
    // is the information; the pieces are only the way it was told.
    if (reduced) {
      setMask("linear-gradient(transparent, transparent)");
      doneRef.current?.();
      return;
    }

    const tint = color ?? getComputedStyle(content).color ?? "#888888";
    // CSS gradient angles run clockwise from "to top", so the sweep
    // vector in screen coordinates is (sin, -cos) — not (cos, sin).
    // Getting this wrong is what makes the mask and the pieces disagree.
    const radians = (angle * Math.PI) / 180;
    const axisX = Math.sin(radians);
    const axisY = -Math.cos(radians);
    // Length of the element's shadow on the sweep axis: the distance the
    // edge has to travel to clear the whole box, whatever the angle.
    const reach = Math.abs(width * axisX) + Math.abs(height * axisY);

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    /** Where a point sits along the sweep, 0 at the first corner to go. */
    const axisPosition = (x: number, y: number) => {
      const origin =
        (axisX < 0 ? width * -axisX : 0) + (axisY < 0 ? height * -axisY : 0);
      return (x * axisX + y * axisY + origin) / (reach || 1);
    };

    const columns = Math.max(1, Math.round(width / config.cell));
    const rows = Math.max(1, Math.round(height / config.cell));
    const cellWidth = width / columns;
    const cellHeight = height / rows;

    const pieces: Piece[] = [];
    for (let column = 0; column < columns; column++) {
      for (let row = 0; row < rows; row++) {
        const x = (column + 0.5) * cellWidth;
        const y = (row + 0.5) * cellHeight;
        pieces.push({
          x,
          y,
          // Off along the sweep, with a little lift and scatter.
          vx: (axisX * 0.8 + random(-0.4, 0.4)) * config.drift,
          vy: (axisY * 0.8 + random(-0.7, -0.1)) * config.drift,
          size: Math.min(cellWidth, cellHeight) * random(0.5, 0.85),
          spin: random(-3, 3),
          tilt: random(0, Math.PI),
          // A little jitter on the release so the edge is ragged rather
          // than a ruler line crossing the element.
          release: axisPosition(x, y) + random(-0.06, 0.06),
          age: 0,
          life: config.life * random(0.7, 1.2),
        });
      }
    }

    let frame = 0;
    let last = performance.now();
    let elapsed = 0;
    let announced = false;

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += delta;

      // The one number, in three forms: how far through the sweep we
      // are, where that puts the mask's edge, and — below — which
      // pieces that edge has now passed.
      const front = elapsed / config.sweep;
      const soft = 6;
      const cut = front * (100 + soft * 2) - soft;
      setMask(
        `linear-gradient(${angle}deg, transparent ${cut}%, black ${cut + soft}%)`
      );
      const edge = (cut + soft / 2) / 100;

      context.clearRect(0, 0, width + BLEED * 2, height + BLEED * 2);
      context.fillStyle = tint;
      let alive = false;

      for (const piece of pieces) {
        if (edge < piece.release) {
          alive = true;
          continue;
        }
        if (piece.age >= piece.life) continue;
        alive = true;
        piece.age += delta;
        piece.x += piece.vx * delta;
        piece.y += piece.vy * delta;
        piece.tilt += piece.spin * delta;

        const t = Math.min(1, piece.age / piece.life);
        const shrink = 1 - t;
        context.save();
        context.translate(piece.x + BLEED, piece.y + BLEED);
        context.rotate(piece.tilt);
        context.globalAlpha = (1 - t) * 0.85;
        const side = piece.size * shrink;
        context.fillRect(-side / 2, -side / 2, side, side);
        context.restore();
      }
      context.globalAlpha = 1;

      if (!alive) {
        // Everything has gone. Stop the loop, leave the element clipped
        // away, and say so once.
        if (!announced) {
          announced = true;
          doneRef.current?.();
        }
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(frame);
    };
  }, [variant, dissolved, color, angle]);

  return (
    <div style={{ position: "relative", display: "inline-block", color: "inherit" }}>
      <div ref={contentRef}>
        {children ?? (
          <div
            style={{
              display: "flex",
              alignItems: "center",
              gap: 11,
              width: 250,
              padding: "13px 15px",
              borderRadius: 12,
              background: "color-mix(in srgb, currentColor 6%, transparent)",
              border: "1px solid color-mix(in srgb, currentColor 12%, transparent)",
            }}
          >
            <div
              style={{
                width: 30,
                height: 30,
                borderRadius: 8,
                flexShrink: 0,
                background: "color-mix(in srgb, currentColor 14%, transparent)",
              }}
            />
            <div style={{ display: "flex", flexDirection: "column", gap: 3, minWidth: 0 }}>
              <div style={{ fontSize: 13, fontWeight: 600 }}>Quarterly report</div>
              <div style={{ fontSize: 11.5, opacity: 0.55 }}>Moved to archive</div>
            </div>
          </div>
        )}
      </div>
      <canvas
        ref={canvasRef}
        aria-hidden
        style={{
          position: "absolute",
          top: -BLEED,
          left: -BLEED,
          width: `calc(100% + ${BLEED * 2}px)`,
          height: `calc(100% + ${BLEED * 2}px)`,
          pointerEvents: "none",
        }}
      />
    </div>
  );
}

About this effect

For removing something and saying so — an archived row, a dismissed card, a deleted draft. One number drives both halves: a sweep position crosses the element, a gradient mask clips the element at exactly that line, and pieces are released at exactly that line as it passes. The pieces are therefore the material the mask just removed, rather than a particle animation playing over an element fading on its own clock — which the eye catches instantly, because the thing is gone before its pieces leave. It takes real children, so it dissolves whatever markup is already there. Pieces are painted in the element's own computed text color: reading a DOM node's real pixels needs a rasterizer, and this file has no dependencies. The grid comes from a 9px cell, so the count follows the element: about 170 pieces for a list row, about 430 for a full card — raise the cell size on anything large.

Archive a rowDismiss a cardDelete a draftClear a notification

Related effects