Skip to content

Cool Mode

A wrapper that spawns a burst of colorful particles from buttons, links, and other elements while they are pressed and held.

View source on GitHub

Installation

Copy and paste the following code into your project:

vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";

export interface CoolParticleOptions {
  particle?: string;
  size?: number;
  particleCount?: number;
  speedHorz?: number;
  speedUp?: number;
}

interface CoolParticle {
  element: HTMLElement | SVGSVGElement;
  left: number;
  size: number;
  top: number;
  direction: number;
  speedHorz: number;
  speedUp: number;
  spinSpeed: number;
  spinVal: number;
}

interface CoolModeProps {
  options?: CoolParticleOptions;
}

const props = defineProps<CoolModeProps>();

const containerRef = ref<HTMLSpanElement | null>(null);

let cleanup: (() => void) | null = null;

const SVG_NS = "http://www.w3.org/2000/svg";

function getContainer(): HTMLElement {
  const container = document.createElement("div");
  container.setAttribute("data-cool-mode-effect", "");
  container.setAttribute(
    "style",
    "overflow:hidden; position:fixed; height:100%; top:0; left:0; right:0; bottom:0; pointer-events:none; z-index:2147483647",
  );

  document.body.appendChild(container);

  return container;
}

function applyParticleEffect(element: HTMLElement): () => void {
  const sizes = [15, 20, 25, 35, 45];

  let particles: CoolParticle[] = [];
  let autoAddParticle = false;
  let mouseX = 0;
  let mouseY = 0;

  const container = getContainer();

  const appendCircleParticle = (particle: HTMLDivElement, size: number) => {
    const circleSVG = document.createElementNS(SVG_NS, "svg");
    const circle = document.createElementNS(SVG_NS, "circle");

    circle.setAttributeNS(null, "cx", (size / 2).toString());
    circle.setAttributeNS(null, "cy", (size / 2).toString());
    circle.setAttributeNS(null, "r", (size / 2).toString());
    circle.setAttributeNS(null, "fill", `hsl(${Math.random() * 360}, 70%, 50%)`);

    circleSVG.appendChild(circle);
    circleSVG.setAttribute("width", size.toString());
    circleSVG.setAttribute("height", size.toString());

    particle.appendChild(circleSVG);
  };

  const appendImageParticle = (particle: HTMLDivElement, imageSrc: string, size: number) => {
    const image = document.createElement("img");
    image.src = imageSrc;
    image.width = size;
    image.height = size;
    image.alt = "";
    image.style.borderRadius = "50%";

    particle.appendChild(image);
  };

  const appendTextParticle = (particle: HTMLDivElement, particleContent: string, size: number) => {
    const fontSizeMultiplier = 3;
    const emojiSize = size * fontSizeMultiplier;
    const content = document.createElement("div");

    content.textContent = particleContent;
    content.style.fontSize = `${emojiSize}px`;
    content.style.lineHeight = "1";
    content.style.textAlign = "center";
    content.style.width = `${size}px`;
    content.style.height = `${size}px`;
    content.style.display = "flex";
    content.style.alignItems = "center";
    content.style.justifyContent = "center";
    content.style.transform = `scale(${fontSizeMultiplier})`;
    content.style.transformOrigin = "center";

    particle.appendChild(content);
  };

  function generateParticle() {
    const options = props.options;
    const particleType = options?.particle ?? "circle";
    const size = options?.size ?? sizes[Math.floor(Math.random() * sizes.length)];
    const speedHorz = options?.speedHorz ?? Math.random() * 10;
    const speedUp = options?.speedUp ?? Math.random() * 25;
    const spinVal = Math.random() * 360;
    const spinSpeed = Math.random() * 35 * (Math.random() <= 0.5 ? -1 : 1);
    const top = mouseY - size / 2;
    const left = mouseX - size / 2;
    const direction = Math.random() <= 0.5 ? -1 : 1;

    const particle = document.createElement("div");

    if (particleType === "circle") {
      appendCircleParticle(particle, size);
    } else if (particleType.startsWith("http") || particleType.startsWith("/")) {
      appendImageParticle(particle, particleType, size);
    } else {
      appendTextParticle(particle, particleType, size);
    }

    particle.style.position = "absolute";
    particle.style.transform = `translate3d(${left}px, ${top}px, 0px) rotate(${spinVal}deg)`;

    container.appendChild(particle);

    particles.push({
      direction,
      element: particle,
      left,
      size,
      speedHorz,
      speedUp,
      spinSpeed,
      spinVal,
      top,
    });
  }

  function refreshParticles() {
    particles.forEach((p) => {
      p.left = p.left - p.speedHorz * p.direction;
      p.top = p.top - p.speedUp;
      p.speedUp = Math.min(p.size, p.speedUp - 1);
      p.spinVal = p.spinVal + p.spinSpeed;

      if (p.top >= Math.max(window.innerHeight, document.body.clientHeight) + p.size) {
        particles = particles.filter((o) => o !== p);
        p.element.remove();
      }

      p.element.setAttribute(
        "style",
        [
          "position:absolute",
          "will-change:transform",
          `top:${p.top}px`,
          `left:${p.left}px`,
          `transform:rotate(${p.spinVal}deg)`,
        ].join(";"),
      );
    });
  }

  let animationFrame: number | undefined;

  let lastParticleTimestamp = 0;
  const particleGenerationDelay = 30;

  function loop() {
    const currentTime = performance.now();
    if (
      autoAddParticle &&
      particles.length < (props.options?.particleCount ?? 45) &&
      currentTime - lastParticleTimestamp > particleGenerationDelay
    ) {
      generateParticle();
      lastParticleTimestamp = currentTime;
    }

    refreshParticles();
    animationFrame = requestAnimationFrame(loop);
  }

  loop();

  const updateMousePosition = (e: PointerEvent) => {
    mouseX = e.clientX;
    mouseY = e.clientY;
  };

  const tapHandler = (e: PointerEvent) => {
    updateMousePosition(e);
    autoAddParticle = true;
  };

  const disableAutoAddParticle = () => {
    autoAddParticle = false;
  };

  element.addEventListener("pointermove", updateMousePosition, { passive: true });
  element.addEventListener("pointerdown", tapHandler, { passive: true });
  element.addEventListener("pointerleave", disableAutoAddParticle);
  window.addEventListener("pointerup", disableAutoAddParticle);
  window.addEventListener("pointercancel", disableAutoAddParticle);
  window.addEventListener("blur", disableAutoAddParticle);

  return () => {
    autoAddParticle = false;
    element.removeEventListener("pointermove", updateMousePosition);
    element.removeEventListener("pointerdown", tapHandler);
    element.removeEventListener("pointerleave", disableAutoAddParticle);
    window.removeEventListener("pointerup", disableAutoAddParticle);
    window.removeEventListener("pointercancel", disableAutoAddParticle);
    window.removeEventListener("blur", disableAutoAddParticle);
    if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
    particles = [];
    container.remove();
  };
}

onMounted(() => {
  if (containerRef.value) {
    cleanup = applyParticleEffect(containerRef.value);
  }
});

onUnmounted(() => {
  if (cleanup) {
    cleanup();
    cleanup = null;
  }
});
</script>

<template>
  <span ref="containerRef">
    <slot />
  </span>
</template>

Usage

vue
<script setup lang="ts">
import CoolMode from "@/components/spark-ui/cool-mode/cool-mode.vue";
</script>

<template>
  <CoolMode>
    <button>Click me</button>
  </CoolMode>
</template>

Examples

Custom Particle

Pass an image URL through the particle option to spray a custom particle instead of the default circles.

View source on GitHub

Options are read when particles are generated, so updates apply without remounting. Each wrapper owns its overlay and removes it immediately on unmount.

Props

PropTypeDefaultDescription
optionsCoolParticleOptionsConfiguration object for the particle effect.

CoolParticleOptions

FieldTypeDefaultDescription
particlestring"circle"Particle to render. A URL renders an image; any other string renders as text/emoji.
sizenumberVariesSize of the particle in pixels.
particleCountnumber45Maximum live particles per instance; 0 disables generation.
speedHorznumberVariesHorizontal speed; 0 prevents sideways movement.
speedUpnumberVariesInitial upward speed; 0 starts with gravity only.

Slots

SlotDescription
defaultThe element (button, link, etc.) that triggers the effect.

Credits

Released under the MIT License.