← All particles

Ribbon Fall

Curled ribbons falling with real twist, showing their faces and edges in turn.

celebrationplayfulwarm14 particles · light · canvas-2d · automatic · finite
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.

339 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Ribbon Fall
 *
 * Thin curled ribbons falling, turning as they go and showing their
 * faces and their edges in turn.
 *
 * The technique: one twist phase that advances along the ribbon's own
 * length, and everything else is read off it. Its width at a point is
 * the cosine of the phase there, so the strip narrows to a line and back
 * three or four times down its length instead of being a rectangle that
 * rotates. Which face is showing is the sign of that same cosine, so the
 * lit side and the shaded side alternate along the ribbon with no
 * separate lighting pass. And how much air it catches is the magnitude
 * of the cosine at its head, so a ribbon lying flat to the airflow drags
 * and slows and a ribbon turning edge-on drops through — which is what
 * makes the descent flutter rather than glide, from the same number
 * again.
 *
 * A twist that only varies with time gives you a rotating rectangle. It
 * has to vary along the arc for the strip to be a helix, and a helix is
 * the whole difference between a curled ribbon and a strip of paper.
 *
 * One detail borrowed from the coin: at edge-on the strip keeps its own
 * thickness rather than vanishing to nothing, so it reads as a ribbon
 * seen edgewise instead of blinking out twice per turn.
 *
 * One fall, finite. It ends by running out — emission follows a decaying
 * envelope, most ribbons leave in the first third of a second — and the
 * loop cancels itself once the last of them is past the bottom edge.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `colors`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RibbonFallProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Ribbons in the fall. Overrides the variant's count. */
  count?: number;
  /** Ribbon face colours, sampled per ribbon. */
  colors?: string[];
  /** Fires once the last ribbon has left the frame. */
  onSettled?: () => void;
};

type VariantConfig = {
  /** Ribbons in the fall. */
  count: number;
  /** Scales gravity, so the whole descent is quicker or slower. */
  fall: number;
  /** Half-turns of twist visible along one ribbon. */
  turns: number;
  /** Ribbon length in px at the reference height. */
  length: number;
  /** Ribbon half-width in px at the reference height. */
  width: number;
  /** Amplitude of the ribbon's curl, in px. */
  curl: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A few short ribbons taking their time.
  subtle: { count: 9, fall: 0.75, turns: 1.1, length: 44, width: 3.2, curl: 5 },
  // A handful of proper streamers. All-purpose.
  default: { count: 14, fall: 1, turns: 1.5, length: 54, width: 3.8, curl: 7 },
  // Longer, tighter-curled ribbons falling faster.
  playful: { count: 20, fall: 1.3, turns: 2.1, length: 66, width: 4.4, curl: 9 },
};

/** Box height the tuned numbers were measured at. */
const REFERENCE = 240;
/** Samples along a ribbon. Enough for three half-turns to read. */
const SEGMENTS = 12;
/** Drag coefficient at full face-on. Edge-on gets a fraction of it. */
const DRAG = 3.2;

const DEFAULT_COLORS = ["#E8B84B", "#5FB6C9", "#E0714F", "#8E7BD4", "#7FC08A"];

type Ribbon = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  /** Direction the ribbon's length runs, 0 being straight down. */
  angle: number;
  length: number;
  halfWidth: number;
  /** Twist at the head. Advancing this in time turns the whole strip. */
  twist: number;
  spin: number;
  /** Twist per px along the strip — this is what makes it a helix. */
  rate: number;
  curl: number;
  curlRate: number;
  phase: number;
  front: string;
  back: string;
  delay: number;
};

/** Multiply a `#RRGGBB` toward black, for the ribbon's shaded face. */
function shade(hex: string, amount: number) {
  const value = hex.replace("#", "");
  const r = Math.round(parseInt(value.slice(0, 2), 16) * amount);
  const g = Math.round(parseInt(value.slice(2, 4), 16) * amount);
  const b = Math.round(parseInt(value.slice(4, 6), 16) * amount);
  return `rgb(${r},${g},${b})`;
}

export default function RibbonFall({
  variant = "default",
  count,
  colors = DEFAULT_COLORS,
  onSettled,
}: RibbonFallProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The parent's callback is read through a ref so an inline arrow can't
  // re-fire the whole shower on every render — written in an effect.
  const settledRef = useRef(onSettled);
  useEffect(() => {
    settledRef.current = onSettled;
  }, [onSettled]);

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

    const config = VARIANTS[variant];
    const wanted = Math.max(3, Math.round(count ?? config.count));
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let scale = 1;
    let gravity = 520;
    let ribbons: Ribbon[] = [];

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

    const spawn = (index: number): Ribbon => {
      const length = config.length * scale * random(0.75, 1.3);
      const tone = colors[index % colors.length];
      return {
        x: random(width * 0.08, width * 0.92),
        y: random(-height * 0.5, -config.length * scale),
        vx: random(-30, 30) * scale,
        vy: random(20, 70) * scale,
        angle: random(-0.5, 0.5),
        length,
        halfWidth: config.width * scale * random(0.8, 1.25),
        twist: random(0, Math.PI * 2),
        spin: random(1.4, 3.2) * (Math.random() < 0.5 ? -1 : 1),
        // Half-turns spread over the strip's own length: this is the
        // number that makes it a helix rather than a rotating rectangle.
        rate: (config.turns * Math.PI) / length,
        curl: config.curl * scale * random(0.5, 1.4),
        curlRate: random(0.03, 0.075) / scale,
        phase: random(0, Math.PI * 2),
        front: tone,
        back: shade(tone, 0.42),
        // Most leave at once, a few trail in — the shower ends by
        // running out rather than by being cut off.
        delay: Math.pow(Math.random(), 2.4) * 0.9,
      };
    };

    const layout = () => {
      const rect = canvas.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      scale = height / REFERENCE;
      gravity = 520 * scale * config.fall;
    };

    const drawRibbon = (ribbon: Ribbon) => {
      const along = Math.sin(ribbon.angle);
      const down = Math.cos(ribbon.angle);
      let previousX = 0;
      let previousY = 0;

      for (let index = 0; index <= SEGMENTS; index++) {
        const s = (index / SEGMENTS) * ribbon.length;
        const lateral = ribbon.curl * Math.sin(s * ribbon.curlRate + ribbon.phase);
        const px = ribbon.x + along * s + down * lateral;
        const py = ribbon.y + down * s - along * lateral;

        if (index > 0) {
          const midS = s - ribbon.length / (SEGMENTS * 2);
          const turn = Math.cos(ribbon.twist + midS * ribbon.rate);
          const dx = px - previousX;
          const dy = py - previousY;
          const span = Math.hypot(dx, dy) || 1;
          // Edge-on the strip keeps its own thickness rather than
          // vanishing, so it reads as a ribbon seen edgewise.
          const half = Math.max(0.4 * scale, ribbon.halfWidth * Math.abs(turn));
          const nx = (-dy / span) * half;
          const ny = (dx / span) * half;

          context.fillStyle = turn >= 0 ? ribbon.front : ribbon.back;
          // The same cosine again: a face square to the light is bright
          // and a face turning away loses it.
          context.globalAlpha = turn >= 0 ? 0.55 + Math.abs(turn) * 0.45 : 0.5 + Math.abs(turn) * 0.35;
          context.beginPath();
          context.moveTo(previousX + nx, previousY + ny);
          context.lineTo(px + nx, py + ny);
          context.lineTo(px - nx, py - ny);
          context.lineTo(previousX - nx, previousY - ny);
          context.closePath();
          context.fill();
        }

        previousX = px;
        previousY = py;
      }
    };

    const render = () => {
      context.clearRect(0, 0, width, height);
      for (const ribbon of ribbons) drawRibbon(ribbon);
      context.globalAlpha = 1;
    };

    const step = (delta: number, elapsed: number) => {
      // One shared current, so the fall drifts as a body.
      const draught = Math.sin(elapsed * 0.7) * 26 * scale;
      let live = 0;

      for (const ribbon of ribbons) {
        if (elapsed < ribbon.delay) {
          live++;
          continue;
        }
        ribbon.twist += ribbon.spin * delta;
        // Face-on catches the air; edge-on slices through it. Drag,
        // width and shading are all this one cosine.
        const face = 0.35 + 0.65 * Math.abs(Math.cos(ribbon.twist));

        ribbon.vy += (gravity - DRAG * face * ribbon.vy) * delta;
        ribbon.vx +=
          (draught + Math.sin(ribbon.twist) * 130 * scale * face - DRAG * face * ribbon.vx) *
          delta;
        ribbon.x += ribbon.vx * delta;
        ribbon.y += ribbon.vy * delta;

        // The strip trails its own travel, so it banks into the swing
        // instead of spinning independently of it.
        const target = Math.atan2(ribbon.vx, Math.max(1, ribbon.vy)) * 0.75;
        ribbon.angle += (target - ribbon.angle) * Math.min(1, 3.4 * delta);

        if (ribbon.y < height + ribbon.length) live++;
      }
      return live;
    };

    layout();
    ribbons = Array.from({ length: wanted }, (_, index) => spawn(index));

    // Reduced motion: the ribbons held part-way down, each at its own
    // point in its turn — some face-on and bright, some edged and thin,
    // some showing their shaded backs. The twist is the subject and it
    // is all there in one frame.
    if (reduced) {
      const still = () => {
        ribbons = Array.from({ length: wanted }, (_, index) => {
          const ribbon = spawn(index);
          ribbon.y = random(height * 0.05, height * 0.8);
          ribbon.angle = random(-0.35, 0.35);
          ribbon.delay = 0;
          return ribbon;
        });
        render();
      };
      still();
      settledRef.current?.();
      const onResizeStill = () => {
        layout();
        still();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

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

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

      const live = step(delta, elapsed);
      render();

      if (live === 0) {
        // The last ribbon is past the bottom edge. Nothing is left to
        // draw, so the loop ends rather than running for ever.
        context.clearRect(0, 0, width, height);
        settledRef.current?.();
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      layout();
      for (const ribbon of ribbons) {
        ribbon.x = Math.min(ribbon.x, width);
      }
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, count, colors]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

A celebration with weight to it — an order placed, a plan upgraded, a long project closed. One twist phase advances along each ribbon's own length and everything else is read off it. The strip's width at a point is the cosine of the phase there, so it narrows to a line and back several times down its length instead of being a rectangle that rotates. Which face is showing is the sign of that same cosine, so the lit side and the shaded side alternate along the ribbon with no separate lighting pass. And how much air it catches is the magnitude of the cosine at its head, so a ribbon lying flat drags and slows while one turning edge-on drops through, which is what makes the descent flutter rather than glide. A twist that varies only with time gives a rotating rectangle; it has to vary along the arc for the strip to be a helix, and that is the whole difference between a curled ribbon and a strip of paper. At edge-on the strip keeps its own thickness rather than vanishing, so it reads as seen edgewise instead of blinking out twice a turn. One fall, finite: emission follows a decaying envelope so it ends by running out, and the loop cancels itself once the last ribbon is past the bottom.

Order placed or payment receivedPlan upgradedLong project completedMilestone or anniversary

Related effects