Click Spark
Creates particle spark bursts at click position.
pnpm dlx shadcn@latest add @leo/click-sparkProps
| Name | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | - | |
| className | string | undefined | - | |
| duration | number | undefined | 400 | |
| easing | undefined | "linear" | "ease-in" | "ease-out" | "ease-in-out" | ease-out | |
| extraScale | number | undefined | 1 | |
| sparkColor | string | undefined | #fff | |
| sparkCount | number | undefined | 8 | |
| sparkRadius | number | undefined | 15 | |
| sparkSize | number | undefined | 10 |
Source
"use client";
/*
* Ported from react-bits (MIT, © David Haz): src/ts-tailwind/Animations/ClickSpark/ClickSpark.tsx @ 9d0270d
*
* MIT License
*
* Copyright (c) 2026 David Haz
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import type React from "react";
import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
export interface ClickSparkProps {
className?: string;
sparkColor?: string;
sparkSize?: number;
sparkRadius?: number;
sparkCount?: number;
duration?: number;
easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out";
extraScale?: number;
children?: React.ReactNode;
}
interface Spark {
x: number;
y: number;
angle: number;
startTime: number;
}
const ClickSpark: React.FC<ClickSparkProps> = ({
sparkColor = "#fff",
sparkSize = 10,
sparkRadius = 15,
sparkCount = 8,
duration = 400,
easing = "ease-out",
extraScale = 1.0,
children,
className,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const sparksRef = useRef<Spark[]>([]);
const startTimeRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement;
if (!parent) return;
let resizeTimeout: ReturnType<typeof setTimeout>;
const resizeCanvas = () => {
const { width, height } = parent.getBoundingClientRect();
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
};
const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(resizeCanvas, 100);
};
const ro = new ResizeObserver(handleResize);
ro.observe(parent);
resizeCanvas();
return () => {
ro.disconnect();
clearTimeout(resizeTimeout);
};
}, []);
const easeFunc = useCallback(
(t: number) => {
switch (easing) {
case "linear":
return t;
case "ease-in":
return t * t;
case "ease-in-out":
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
default:
return t * (2 - t);
}
},
[easing],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: sparkCount re-runs the effect on purpose
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationId: number;
const draw = (timestamp: number) => {
if (!startTimeRef.current) {
startTimeRef.current = timestamp;
}
ctx?.clearRect(0, 0, canvas.width, canvas.height);
sparksRef.current = sparksRef.current.filter((spark: Spark) => {
const elapsed = timestamp - spark.startTime;
if (elapsed >= duration) {
return false;
}
const progress = elapsed / duration;
const eased = easeFunc(progress);
const distance = eased * sparkRadius * extraScale;
const lineLength = sparkSize * (1 - eased);
const x1 = spark.x + distance * Math.cos(spark.angle);
const y1 = spark.y + distance * Math.sin(spark.angle);
const x2 = spark.x + (distance + lineLength) * Math.cos(spark.angle);
const y2 = spark.y + (distance + lineLength) * Math.sin(spark.angle);
ctx.strokeStyle = sparkColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
return true;
});
animationId = requestAnimationFrame(draw);
};
animationId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animationId);
};
}, [sparkColor, sparkSize, sparkRadius, sparkCount, duration, easeFunc, extraScale]);
const handleClick = (e: React.MouseEvent<HTMLDivElement>): void => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const now = performance.now();
const newSparks: Spark[] = Array.from({ length: sparkCount }, (_, i) => ({
x,
y,
angle: (2 * Math.PI * i) / sparkCount,
startTime: now,
}));
sparksRef.current.push(...newSparks);
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: click position is only used to spawn decorative sparks
// biome-ignore lint/a11y/useKeyWithClickEvents: the wrapper is not an interactive control, children handle keyboard input
<div className={cn("relative w-full h-full", className)} onClick={handleClick}>
<canvas ref={canvasRef} className="absolute inset-0 pointer-events-none" />
{children}
</div>
);
};
export { ClickSpark };
export default ClickSpark;