diff --git a/apps/www/config/docs.ts b/apps/www/config/docs.ts index 92df27973..cb049d1e2 100644 --- a/apps/www/config/docs.ts +++ b/apps/www/config/docs.ts @@ -333,6 +333,12 @@ export const docsConfig: DocsConfig = { { title: "Animations", items: [ + { + title: "Click Spark", + href: `/docs/components/click-spark`, + items: [], + label: "New", + }, { title: "Blur Fade", href: `/docs/components/blur-fade`, diff --git a/apps/www/content/docs/components/click-spark.mdx b/apps/www/content/docs/components/click-spark.mdx new file mode 100644 index 000000000..4a1af615a --- /dev/null +++ b/apps/www/content/docs/components/click-spark.mdx @@ -0,0 +1,53 @@ +--- +title: Click Spark +date: 2026-05-17 +description: A component that renders sparks when clicked. +author: rishabhmishra +published: true +--- + + + +## Installation + + + + + CLI + Manual + + + +```bash +npx shadcn@latest add @magicui/click-spark +``` + + + + + + + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + + + + + + + +## Props + +| Prop | Type | Default | Description | +| ------------- | -------- | ---------- | --------------------------------- | +| `sparkColor` | `string` | `#fff` | Color of the sparks. | +| `sparkSize` | `number` | `10` | Size of the sparks. | +| `sparkRadius` | `number` | `15` | Radius of the sparks. | +| `sparkCount` | `number` | `8` | Number of sparks. | +| `duration` | `number` | `400` | Duration of the animation. | +| `easing` | `string` | `ease-out` | Easing function of the animation. | +| `extraScale` | `number` | `1.0` | Extra scale of the sparks. | diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index df1ee56f4..f0eb2e12f 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -4709,6 +4709,287 @@ export default function Component() { +===== COMPONENT: click-spark ===== +Title: Click Spark +Description: A component that renders sparks when clicked. + +--- file: magicui/click-spark.tsx --- +"use client" + +import React, { useCallback, useEffect, useRef } from "react" + +import { cn } from "@/lib/utils" + +interface Spark { + x: number + y: number + angle: number + startTime: number +} + +interface ClickSparkProps { + /** + * The color of the sparks. + * @default "#fff" + */ + sparkColor?: string + /** + * The size of the sparks. + * @default 10 + */ + sparkSize?: number + /** + * The radius of the spark explosion. + * @default 15 + */ + sparkRadius?: number + /** + * The number of sparks per click. + * @default 8 + */ + sparkCount?: number + /** + * The duration of the spark animation in milliseconds. + * @default 400 + */ + duration?: number + /** + * The easing function for the spark animation. + * @default "ease-out" + */ + easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out" + /** + * Extra scale factor for the spark distance. + * @default 1.0 + */ + extraScale?: number + /** + * The content to wrap. + */ + children?: React.ReactNode + /** + * Additional class names for the wrapper. + */ + className?: string +} + +export function ClickSpark({ + sparkColor = "#fff", + sparkSize = 10, + sparkRadius = 15, + sparkCount = 8, + duration = 400, + easing = "ease-out", + extraScale = 1.0, + children, + className, +}: ClickSparkProps) { + const canvasRef = useRef(null) + const sparksRef = useRef([]) + const animationIdRef = useRef(null) + + // Store animation settings in a ref to keep the draw loop stable + // and avoid exhaustive-deps warnings. + const settingsRef = useRef({ + sparkColor, + sparkSize, + sparkRadius, + duration, + extraScale, + }) + + // Sync refs when props change + useEffect(() => { + settingsRef.current = { + sparkColor, + sparkSize, + sparkRadius, + duration, + extraScale, + } + }, [sparkColor, sparkSize, sparkRadius, duration, extraScale]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const parent = canvas.parentElement + if (!parent) return + + let resizeTimeout: ReturnType + + 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] + ) + + useEffect(() => { + return () => { + if (animationIdRef.current) { + cancelAnimationFrame(animationIdRef.current) + } + } + }, []) + + const draw = useCallback( + (timestamp: number) => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext("2d") + if (!ctx) return + + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // Use settings from Ref to avoid dependency loop + const { + sparkColor: color, + sparkSize: size, + sparkRadius: radius, + duration: drn, + extraScale: scale, + } = settingsRef.current + + sparksRef.current = sparksRef.current.filter((spark: Spark) => { + const elapsed = timestamp - spark.startTime + if (elapsed >= drn) { + return false + } + + const progress = elapsed / drn + const eased = easeFunc(progress) + + const distance = eased * radius * scale + const lineLength = size * (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 = color + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + + return true + }) + + if (sparksRef.current.length > 0) { + animationIdRef.current = requestAnimationFrame(draw) + } else { + animationIdRef.current = null + } + }, + [easeFunc] + ) + + const handleClick = (e: React.MouseEvent): 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() + // sparkCount is only used here to initialize the objects + const newSparks: Spark[] = Array.from({ length: sparkCount }, (_, i) => ({ + x, + y, + angle: (2 * Math.PI * i) / sparkCount, + startTime: now, + })) + + sparksRef.current.push(...newSparks) + + if (animationIdRef.current === null) { + animationIdRef.current = requestAnimationFrame(draw) + } + } + + return ( +
+ + {children} +
+ ) +} + + +===== EXAMPLE: click-spark-demo ===== +Title: Click Spark Demo + +--- file: example/click-spark-demo.tsx --- +import { ClickSpark } from "@/registry/magicui/click-spark" + +export default function ClickSparkDemo() { + return ( +
+ +
+

+ Interactive Spark +

+

+ Click anywhere to experience the effect +

+
+
+
+ ) +} + + + ===== COMPONENT: client-tweet-card ===== Title: Client Tweet Card Description: A client-side version of the tweet card that displays a tweet with the author's name, handle, and profile picture. diff --git a/apps/www/public/llms.txt b/apps/www/public/llms.txt index dd7e1290c..3bf58adcc 100644 --- a/apps/www/public/llms.txt +++ b/apps/www/public/llms.txt @@ -20,6 +20,7 @@ This file provides LLM-friendly entry points to documentation and examples. - [Bento Grid](https://magicui.design/docs/components/bento-grid): Bento grid is a layout used to showcase the features of a product in a simple and elegant way. - [Blur Fade](https://magicui.design/docs/components/blur-fade): Blur fade in and out animation. Used to smoothly fade in and out content. - [Border Beam](https://magicui.design/docs/components/border-beam): An animated beam of light which travels along the border of its container. +- [Click Spark](https://magicui.design/docs/components/click-spark): A component that renders sparks when clicked. - [Client Tweet Card](https://magicui.design/docs/components/client-tweet-card): A client-side version of the tweet card that displays a tweet with the author's name, handle, and profile picture. - [Code Comparison](https://magicui.design/docs/components/code-comparison): A component which compares two code snippets. - [Comic Text](https://magicui.design/docs/components/comic-text): Comic text animation @@ -85,6 +86,7 @@ This file provides LLM-friendly entry points to documentation and examples. ## Examples +- [Click Spark Demo](https://github.com/magicuidesign/magicui/blob/main/example/click-spark-demo.tsx): Example usage - [Magic Card Demo](https://github.com/magicuidesign/magicui/blob/main/example/magic-card-demo.tsx): Example usage - [Magic Card Demo 2](https://github.com/magicuidesign/magicui/blob/main/example/magic-card-demo2.tsx): Example usage - [Android Demo](https://github.com/magicuidesign/magicui/blob/main/example/android-demo.tsx): Example usage diff --git a/apps/www/public/r/click-spark-demo.json b/apps/www/public/r/click-spark-demo.json new file mode 100644 index 000000000..47d3307ae --- /dev/null +++ b/apps/www/public/r/click-spark-demo.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "click-spark-demo", + "type": "registry:example", + "title": "Click Spark Demo", + "description": "Example showing a click spark component.", + "registryDependencies": [ + "@magicui/click-spark" + ], + "files": [ + { + "path": "registry/example/click-spark-demo.tsx", + "content": "import { ClickSpark } from \"@/registry/magicui/click-spark\"\n\nexport default function ClickSparkDemo() {\n return (\n
\n \n
\n

\n Interactive Spark\n

\n

\n Click anywhere to experience the effect\n

\n
\n \n
\n )\n}\n", + "type": "registry:example" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/click-spark.json b/apps/www/public/r/click-spark.json new file mode 100644 index 000000000..3ef0c02d5 --- /dev/null +++ b/apps/www/public/r/click-spark.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "click-spark", + "type": "registry:ui", + "title": "Click Spark", + "description": "A component that renders sparks when clicked.", + "files": [ + { + "path": "registry/magicui/click-spark.tsx", + "content": "\"use client\"\n\nimport React, { useCallback, useEffect, useRef } from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface Spark {\n x: number\n y: number\n angle: number\n startTime: number\n}\n\ninterface ClickSparkProps {\n /**\n * The color of the sparks.\n * @default \"#fff\"\n */\n sparkColor?: string\n /**\n * The size of the sparks.\n * @default 10\n */\n sparkSize?: number\n /**\n * The radius of the spark explosion.\n * @default 15\n */\n sparkRadius?: number\n /**\n * The number of sparks per click.\n * @default 8\n */\n sparkCount?: number\n /**\n * The duration of the spark animation in milliseconds.\n * @default 400\n */\n duration?: number\n /**\n * The easing function for the spark animation.\n * @default \"ease-out\"\n */\n easing?: \"linear\" | \"ease-in\" | \"ease-out\" | \"ease-in-out\"\n /**\n * Extra scale factor for the spark distance.\n * @default 1.0\n */\n extraScale?: number\n /**\n * The content to wrap.\n */\n children?: React.ReactNode\n /**\n * Additional class names for the wrapper.\n */\n className?: string\n}\n\nexport function ClickSpark({\n sparkColor = \"#fff\",\n sparkSize = 10,\n sparkRadius = 15,\n sparkCount = 8,\n duration = 400,\n easing = \"ease-out\",\n extraScale = 1.0,\n children,\n className,\n}: ClickSparkProps) {\n const canvasRef = useRef(null)\n const sparksRef = useRef([])\n const animationIdRef = useRef(null)\n\n // Store animation settings in a ref to keep the draw loop stable\n // and avoid exhaustive-deps warnings.\n const settingsRef = useRef({\n sparkColor,\n sparkSize,\n sparkRadius,\n duration,\n extraScale,\n })\n\n // Sync refs when props change\n useEffect(() => {\n settingsRef.current = {\n sparkColor,\n sparkSize,\n sparkRadius,\n duration,\n extraScale,\n }\n }, [sparkColor, sparkSize, sparkRadius, duration, extraScale])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const parent = canvas.parentElement\n if (!parent) return\n\n let resizeTimeout: ReturnType\n\n const resizeCanvas = () => {\n const { width, height } = parent.getBoundingClientRect()\n if (canvas.width !== width || canvas.height !== height) {\n canvas.width = width\n canvas.height = height\n }\n }\n\n const handleResize = () => {\n clearTimeout(resizeTimeout)\n resizeTimeout = setTimeout(resizeCanvas, 100)\n }\n\n const ro = new ResizeObserver(handleResize)\n ro.observe(parent)\n\n resizeCanvas()\n\n return () => {\n ro.disconnect()\n clearTimeout(resizeTimeout)\n }\n }, [])\n\n const easeFunc = useCallback(\n (t: number) => {\n switch (easing) {\n case \"linear\":\n return t\n case \"ease-in\":\n return t * t\n case \"ease-in-out\":\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t\n default:\n return t * (2 - t)\n }\n },\n [easing]\n )\n\n useEffect(() => {\n return () => {\n if (animationIdRef.current) {\n cancelAnimationFrame(animationIdRef.current)\n }\n }\n }, [])\n\n const draw = useCallback(\n (timestamp: number) => {\n const canvas = canvasRef.current\n if (!canvas) return\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n // Use settings from Ref to avoid dependency loop\n const {\n sparkColor: color,\n sparkSize: size,\n sparkRadius: radius,\n duration: drn,\n extraScale: scale,\n } = settingsRef.current\n\n sparksRef.current = sparksRef.current.filter((spark: Spark) => {\n const elapsed = timestamp - spark.startTime\n if (elapsed >= drn) {\n return false\n }\n\n const progress = elapsed / drn\n const eased = easeFunc(progress)\n\n const distance = eased * radius * scale\n const lineLength = size * (1 - eased)\n\n const x1 = spark.x + distance * Math.cos(spark.angle)\n const y1 = spark.y + distance * Math.sin(spark.angle)\n const x2 = spark.x + (distance + lineLength) * Math.cos(spark.angle)\n const y2 = spark.y + (distance + lineLength) * Math.sin(spark.angle)\n\n ctx.strokeStyle = color\n ctx.lineWidth = 2\n ctx.beginPath()\n ctx.moveTo(x1, y1)\n ctx.lineTo(x2, y2)\n ctx.stroke()\n\n return true\n })\n\n if (sparksRef.current.length > 0) {\n animationIdRef.current = requestAnimationFrame(draw)\n } else {\n animationIdRef.current = null\n }\n },\n [easeFunc]\n )\n\n const handleClick = (e: React.MouseEvent): void => {\n const canvas = canvasRef.current\n if (!canvas) return\n const rect = canvas.getBoundingClientRect()\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n\n const now = performance.now()\n // sparkCount is only used here to initialize the objects\n const newSparks: Spark[] = Array.from({ length: sparkCount }, (_, i) => ({\n x,\n y,\n angle: (2 * Math.PI * i) / sparkCount,\n startTime: now,\n }))\n\n sparksRef.current.push(...newSparks)\n\n if (animationIdRef.current === null) {\n animationIdRef.current = requestAnimationFrame(draw)\n }\n }\n\n return (\n \n \n {children}\n \n )\n}\n", + "type": "registry:ui" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index 499944e16..5e5ecfb2f 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -18,6 +18,18 @@ "files": [], "cssVars": {} }, + { + "name": "click-spark", + "type": "registry:ui", + "title": "Click Spark", + "description": "A component that renders sparks when clicked.", + "files": [ + { + "path": "registry/magicui/click-spark.tsx", + "type": "registry:ui" + } + ] + }, { "name": "magic-card", "type": "registry:ui", @@ -1348,6 +1360,21 @@ } ] }, + { + "name": "click-spark-demo", + "type": "registry:example", + "title": "Click Spark Demo", + "description": "Example showing a click spark component.", + "registryDependencies": [ + "@magicui/click-spark" + ], + "files": [ + { + "path": "registry/example/click-spark-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "magic-card-demo", "type": "registry:example", diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index 499944e16..5e5ecfb2f 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -18,6 +18,18 @@ "files": [], "cssVars": {} }, + { + "name": "click-spark", + "type": "registry:ui", + "title": "Click Spark", + "description": "A component that renders sparks when clicked.", + "files": [ + { + "path": "registry/magicui/click-spark.tsx", + "type": "registry:ui" + } + ] + }, { "name": "magic-card", "type": "registry:ui", @@ -1348,6 +1360,21 @@ } ] }, + { + "name": "click-spark-demo", + "type": "registry:example", + "title": "Click Spark Demo", + "description": "Example showing a click spark component.", + "registryDependencies": [ + "@magicui/click-spark" + ], + "files": [ + { + "path": "registry/example/click-spark-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "magic-card-demo", "type": "registry:example", diff --git a/apps/www/registry.json b/apps/www/registry.json index 499944e16..5e5ecfb2f 100644 --- a/apps/www/registry.json +++ b/apps/www/registry.json @@ -18,6 +18,18 @@ "files": [], "cssVars": {} }, + { + "name": "click-spark", + "type": "registry:ui", + "title": "Click Spark", + "description": "A component that renders sparks when clicked.", + "files": [ + { + "path": "registry/magicui/click-spark.tsx", + "type": "registry:ui" + } + ] + }, { "name": "magic-card", "type": "registry:ui", @@ -1348,6 +1360,21 @@ } ] }, + { + "name": "click-spark-demo", + "type": "registry:example", + "title": "Click Spark Demo", + "description": "Example showing a click spark component.", + "registryDependencies": [ + "@magicui/click-spark" + ], + "files": [ + { + "path": "registry/example/click-spark-demo.tsx", + "type": "registry:example" + } + ] + }, { "name": "magic-card-demo", "type": "registry:example", diff --git a/apps/www/registry/__index__.tsx b/apps/www/registry/__index__.tsx index 8c1517ae8..1d1eb7646 100644 --- a/apps/www/registry/__index__.tsx +++ b/apps/www/registry/__index__.tsx @@ -15,6 +15,23 @@ export const Index: Record = { component: null, meta: undefined, }, + "click-spark": { + name: "click-spark", + description: "A component that renders sparks when clicked.", + type: "registry:ui", + registryDependencies: undefined, + files: [{ + path: "registry/magicui/click-spark.tsx", + type: "registry:ui", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/magicui/click-spark.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') ?? item.name + return { default: mod.default ?? mod[exportName] } + }), + meta: undefined, + }, "magic-card": { name: "magic-card", description: "A spotlight effect that follows your mouse cursor and highlights borders on hover.", @@ -1307,6 +1324,23 @@ export const Index: Record = { }), meta: undefined, }, + "click-spark-demo": { + name: "click-spark-demo", + description: "Example showing a click spark component.", + type: "registry:example", + registryDependencies: ["@magicui/click-spark"], + files: [{ + path: "registry/example/click-spark-demo.tsx", + type: "registry:example", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/example/click-spark-demo.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') ?? item.name + return { default: mod.default ?? mod[exportName] } + }), + meta: undefined, + }, "magic-card-demo": { name: "magic-card-demo", description: "Example showing a spotlight effect that follows your mouse cursor and highlights borders on hover.", diff --git a/apps/www/registry/example/click-spark-demo.tsx b/apps/www/registry/example/click-spark-demo.tsx new file mode 100644 index 000000000..9df205287 --- /dev/null +++ b/apps/www/registry/example/click-spark-demo.tsx @@ -0,0 +1,25 @@ +import { ClickSpark } from "@/registry/magicui/click-spark" + +export default function ClickSparkDemo() { + return ( +
+ +
+

+ Interactive Spark +

+

+ Click anywhere to experience the effect +

+
+
+
+ ) +} diff --git a/apps/www/registry/magicui/click-spark.tsx b/apps/www/registry/magicui/click-spark.tsx new file mode 100644 index 000000000..cac0917bb --- /dev/null +++ b/apps/www/registry/magicui/click-spark.tsx @@ -0,0 +1,242 @@ +"use client" + +import React, { useCallback, useEffect, useRef } from "react" + +import { cn } from "@/lib/utils" + +interface Spark { + x: number + y: number + angle: number + startTime: number +} + +interface ClickSparkProps { + /** + * The color of the sparks. + * @default "#fff" + */ + sparkColor?: string + /** + * The size of the sparks. + * @default 10 + */ + sparkSize?: number + /** + * The radius of the spark explosion. + * @default 15 + */ + sparkRadius?: number + /** + * The number of sparks per click. + * @default 8 + */ + sparkCount?: number + /** + * The duration of the spark animation in milliseconds. + * @default 400 + */ + duration?: number + /** + * The easing function for the spark animation. + * @default "ease-out" + */ + easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out" + /** + * Extra scale factor for the spark distance. + * @default 1.0 + */ + extraScale?: number + /** + * The content to wrap. + */ + children?: React.ReactNode + /** + * Additional class names for the wrapper. + */ + className?: string +} + +export function ClickSpark({ + sparkColor = "#fff", + sparkSize = 10, + sparkRadius = 15, + sparkCount = 8, + duration = 400, + easing = "ease-out", + extraScale = 1.0, + children, + className, +}: ClickSparkProps) { + const canvasRef = useRef(null) + const sparksRef = useRef([]) + const animationIdRef = useRef(null) + + // Store animation settings in a ref to keep the draw loop stable + // and avoid exhaustive-deps warnings. + const settingsRef = useRef({ + sparkColor, + sparkSize, + sparkRadius, + duration, + extraScale, + }) + + // Sync refs when props change + useEffect(() => { + settingsRef.current = { + sparkColor, + sparkSize, + sparkRadius, + duration, + extraScale, + } + }, [sparkColor, sparkSize, sparkRadius, duration, extraScale]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const parent = canvas.parentElement + if (!parent) return + + let resizeTimeout: ReturnType + + 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] + ) + + useEffect(() => { + return () => { + if (animationIdRef.current) { + cancelAnimationFrame(animationIdRef.current) + } + } + }, []) + + const draw = useCallback( + (timestamp: number) => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext("2d") + if (!ctx) return + + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // Use settings from Ref to avoid dependency loop + const { + sparkColor: color, + sparkSize: size, + sparkRadius: radius, + duration: drn, + extraScale: scale, + } = settingsRef.current + + sparksRef.current = sparksRef.current.filter((spark: Spark) => { + const elapsed = timestamp - spark.startTime + if (elapsed >= drn) { + return false + } + + const progress = elapsed / drn + const eased = easeFunc(progress) + + const distance = eased * radius * scale + const lineLength = size * (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 = color + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + + return true + }) + + if (sparksRef.current.length > 0) { + animationIdRef.current = requestAnimationFrame(draw) + } else { + animationIdRef.current = null + } + }, + [easeFunc] + ) + + const handleClick = (e: React.MouseEvent): 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() + // sparkCount is only used here to initialize the objects + const newSparks: Spark[] = Array.from({ length: sparkCount }, (_, i) => ({ + x, + y, + angle: (2 * Math.PI * i) / sparkCount, + startTime: now, + })) + + sparksRef.current.push(...newSparks) + + if (animationIdRef.current === null) { + animationIdRef.current = requestAnimationFrame(draw) + } + } + + return ( +
+ + {children} +
+ ) +} diff --git a/apps/www/registry/registry-examples.ts b/apps/www/registry/registry-examples.ts index a112db472..90a1ae926 100644 --- a/apps/www/registry/registry-examples.ts +++ b/apps/www/registry/registry-examples.ts @@ -1,6 +1,19 @@ import { type Registry } from "shadcn/schema" export const examples: Registry["items"] = [ + { + name: "click-spark-demo", + type: "registry:example", + title: "Click Spark Demo", + description: "Example showing a click spark component.", + registryDependencies: ["@magicui/click-spark"], + files: [ + { + path: "example/click-spark-demo.tsx", + type: "registry:example", + }, + ], + }, { name: "magic-card-demo", type: "registry:example", diff --git a/apps/www/registry/registry-ui.ts b/apps/www/registry/registry-ui.ts index 0969e85f4..a66a26c1c 100644 --- a/apps/www/registry/registry-ui.ts +++ b/apps/www/registry/registry-ui.ts @@ -1,6 +1,18 @@ import { type Registry } from "shadcn/schema" export const ui: Registry["items"] = [ + { + name: "click-spark", + type: "registry:ui", + title: "Click Spark", + description: "A component that renders sparks when clicked.", + files: [ + { + path: "magicui/click-spark.tsx", + type: "registry:ui", + }, + ], + }, { name: "magic-card", type: "registry:ui",