# Click Spark

Short lines radiate from every click and fade as they fly out. Draws only while a burst is alive.

**Category:** Cursor  
**Exports:** `ClickSpark`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/click-spark.json
```
The file lands at `components/ui/click-spark.tsx`; import from `@/components/ui/click-spark`.

## API — Click Spark
Click the empty space or the buttons.

| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — |  |
| `count` | `number` | `8` | Spark lines per click. |
| `length` | `number` | `22` | Length of each line in px. |
| `duration` | `number` | `0.45` | Seconds for a burst to fly out and fade. |
| `color` | `string` | `"var(--ember)"` |  |
| `className` | `string` | — |  |

## Example — Click Spark

```tsx
import { ClickSpark } from "@/components/ui/click-spark";
import { Button } from "@/components/ui/button";

export default function ClickSparkDemo({
  count = 8,
  length = 22,
  duration = 0.45,
  color = "var(--ember)",
}: {
  count?: number;
  length?: number;
  duration?: number;
  color?: string;
}) {
  return (
    <ClickSpark count={count} length={length} duration={duration} color={color} className="grid h-80 w-full place-items-center overflow-hidden rounded-xl border bg-background">
      <div className="text-center">
        <p className="text-3xl font-semibold tracking-tight">Click anywhere</p>
        <p className="mt-2 text-muted-foreground">Sparks fly from the click, buttons included.</p>
        <div className="mt-6 flex items-center justify-center gap-3">
          <Button variant="outline">Click</Button>
          <Button variant="ember">And this</Button>
        </div>
      </div>
    </ClickSpark>
  );
}
```

## Source — components/ui/click-spark.tsx

```tsx
"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type ClickSparkProps = {
  children?: React.ReactNode;
  /** Spark lines per click. */
  count?: number;
  /** Length of each line in px. */
  length?: number;
  /** Seconds for a burst to fly out and fade. */
  duration?: number;
  /** Any CSS colour; defaults to the theme's --ember. */
  color?: string;
  className?: string;
};

type Burst = { x: number; y: number; start: number; seed: number };

/** Resolves any CSS colour, including `var(--token)`, to "r,g,b" through a 1px canvas. */
function rgb(el: HTMLElement, color: string, fallback = "#0285f7") {
  const m = color.match(/^var\((--[\w-]+)\)$/);
  const raw = m ? getComputedStyle(el).getPropertyValue(m[1]).trim() || fallback : color;
  const c = document.createElement("canvas").getContext("2d")!;
  c.fillStyle = raw;
  c.fillRect(0, 0, 1, 1);
  return Array.from(c.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(",");
}

/**
 * Short lines radiate from every click and fade as they fly out. The canvas only draws while a
 * burst is alive, so it is free the rest of the time. Mouse only; nothing under reduced motion.
 */
export function ClickSpark({ children, count = 8, length = 22, duration = 0.45, color = "var(--ember)", className }: ClickSparkProps) {
  const wrap = React.useRef<HTMLDivElement>(null);
  const ref = React.useRef<HTMLCanvasElement>(null);
  const reduce = useReducedMotion();

  React.useEffect(() => {
    if (reduce) return;
    const canvas = ref.current!;
    const ctx = canvas.getContext("2d")!;
    const host = wrap.current!;
    let w = 0, h = 0, raf = 0, running = false;
    let col = rgb(canvas, color);
    const bursts: Burst[] = [];

    const resize = () => {
      const dpr = Math.min(2, devicePixelRatio || 1);
      w = canvas.clientWidth;
      h = canvas.clientHeight;
      canvas.width = w * dpr;
      canvas.height = h * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };

    const loop = (now: number) => {
      ctx.clearRect(0, 0, w, h);
      ctx.lineCap = "round";
      for (let i = bursts.length - 1; i >= 0; i--) {
        const b = bursts[i];
        const p = (now - b.start) / (duration * 1000);
        if (p >= 1) {
          bursts.splice(i, 1);
          continue;
        }
        const ease = 1 - (1 - p) ** 3;
        // The line's inner end leaves the click point first and its outer end lags, so each spark shrinks as it flies.
        const inner = ease * length;
        const outer = inner + length * 0.6 * (1 - ease);
        ctx.strokeStyle = `rgba(${col},${1 - p})`;
        ctx.lineWidth = 2 * (1 - p * 0.5);
        for (let k = 0; k < count; k++) {
          const a = (k / count) * Math.PI * 2 + b.seed;
          ctx.beginPath();
          ctx.moveTo(b.x + Math.cos(a) * inner, b.y + Math.sin(a) * inner);
          ctx.lineTo(b.x + Math.cos(a) * outer, b.y + Math.sin(a) * outer);
          ctx.stroke();
        }
      }
      if (bursts.length) raf = requestAnimationFrame(loop);
      else running = false;
    };

    const onDown = (e: PointerEvent) => {
      if (e.pointerType !== "mouse") return;
      const r = canvas.getBoundingClientRect();
      bursts.push({ x: e.clientX - r.left, y: e.clientY - r.top, start: performance.now(), seed: Math.random() * Math.PI });
      if (!running) {
        running = true;
        raf = requestAnimationFrame(loop);
      }
    };

    resize();
    host.addEventListener("pointerdown", onDown);
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    const mo = new MutationObserver(() => (col = rgb(canvas, color)));
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      mo.disconnect();
      host.removeEventListener("pointerdown", onDown);
    };
  }, [count, length, duration, color, reduce]);

  return (
    <div ref={wrap} className={cn("relative", className)}>
      {children}
      {!reduce && <canvas ref={ref} aria-hidden className="pointer-events-none absolute inset-0 size-full" />}
    </div>
  );
}
```