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.

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

let instanceCounter = 0;

function getContainer(): HTMLElement {
  const id = "_coolMode_effect";
  const existingContainer = document.getElementById(id);

  if (existingContainer) {
    return existingContainer;
  }

  const container = document.createElement("div");
  container.setAttribute("id", id);
  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, options?: CoolParticleOptions): () => void {
  instanceCounter++;

  const defaultParticle = "circle";
  const particleType = options?.particle || defaultParticle;
  const sizes = [15, 20, 25, 35, 45];
  const limit = 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 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 < limit &&
      currentTime - lastParticleTimestamp > particleGenerationDelay
    ) {
      generateParticle();
      lastParticleTimestamp = currentTime;
    }

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

  loop();

  const isTouchInteraction = "ontouchstart" in window;

  const tap = isTouchInteraction ? "touchstart" : "mousedown";
  const tapEnd = isTouchInteraction ? "touchend" : "mouseup";
  const move = isTouchInteraction ? "touchmove" : "mousemove";

  const updateMousePosition = (e: MouseEvent | TouchEvent) => {
    if ("touches" in e) {
      mouseX = e.touches?.[0].clientX;
      mouseY = e.touches?.[0].clientY;
    } else {
      mouseX = e.clientX;
      mouseY = e.clientY;
    }
  };

  const tapHandler = (e: MouseEvent | TouchEvent) => {
    updateMousePosition(e);
    autoAddParticle = true;
  };

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

  element.addEventListener(move, updateMousePosition as EventListener, { passive: true });
  element.addEventListener(tap, tapHandler as EventListener, { passive: true });
  element.addEventListener(tapEnd, disableAutoAddParticle, { passive: true });
  element.addEventListener("mouseleave", disableAutoAddParticle, {
    passive: true,
  });

  return () => {
    element.removeEventListener(move, updateMousePosition as EventListener);
    element.removeEventListener(tap, tapHandler as EventListener);
    element.removeEventListener(tapEnd, disableAutoAddParticle);
    element.removeEventListener("mouseleave", disableAutoAddParticle);

    const interval = setInterval(() => {
      if (animationFrame && particles.length === 0) {
        cancelAnimationFrame(animationFrame);
        clearInterval(interval);

        if (--instanceCounter === 0) {
          container.remove();
        }
      }
    }, 500);
  };
}

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

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.

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.
particleCountnumberVariesThe number of particles to generate.
speedHorznumberVariesHorizontal speed of the particles.
speedUpnumberVariesUpward speed of the particles.

Slots

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

Credits

Released under the MIT License.