From 63272cc73b6b031c1a67985d54e4a3fe0343630c Mon Sep 17 00:00:00 2001 From: Wadoud Date: Thu, 23 Jul 2026 19:35:32 +0100 Subject: [PATCH] fix(confetti): prevent worker reset on re-render and fix ConfettiButton ref forwarding - Preserve canvas worker context across parent component re-renders by storing globalOptions and options in refs. - Wrap ConfettiButton in forwardRef and extend ComponentPropsWithoutRef. - Restructure confetti.mdx documentation tables to accurately reflect manualstart, ConfettiRef, and options. --- apps/www/content/docs/components/confetti.mdx | 82 ++++++---- apps/www/public/llms-full.txt | 143 +++++++++--------- apps/www/public/r/confetti.json | 2 +- apps/www/registry/magicui/confetti.tsx | 143 +++++++++--------- 4 files changed, 198 insertions(+), 172 deletions(-) diff --git a/apps/www/content/docs/components/confetti.mdx b/apps/www/content/docs/components/confetti.mdx index d46277d6f..281032e90 100644 --- a/apps/www/content/docs/components/confetti.mdx +++ b/apps/www/content/docs/components/confetti.mdx @@ -79,44 +79,68 @@ npm install canvas-confetti ### Usage ```tsx showLineNumbers -import { Confetti } from "@/components/ui/confetti" -``` - -```tsx showLineNumbers - +import { useRef } from "react" + +import { Confetti, type ConfettiRef } from "@/components/ui/confetti" + +export function ConfettiDemo() { + const confettiRef = useRef(null) + + return ( +
+ + +
+ ) +} ``` ## Props ### Confetti -| Prop | Type | Default | Description | -| ------------------------- | --------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | -| `particleCount` | `Integer` | `50` | The number of confetti particles to launch | -| `angle` | `Number` | `90` | The angle in degrees at which to launch confetti | -| `spread` | `Number` | `45` | The spread in degrees of the confetti | -| `startVelocity` | `Number` | `45` | The initial velocity of the confetti | -| `decay` | `Number` | `0.9` | The rate at which confetti slows down | -| `gravity` | `Number` | `1` | The gravity applied to confetti particles | -| `drift` | `Number` | `0` | The horizontal drift applied to particles | -| `flat` | `Boolean` | `false` | Whether confetti particles are flat | -| `ticks` | `Number` | `200` | The number of frames confetti lasts | -| `origin` | `Object` | `{ x: 0.5, y: 0.5 }` | The origin point of the confetti | -| `colors` | `Array of Strings` | `['#26ccff', '#a25afd', '#ff5e7e', '#88ff5a', '#fcff42', '#ffa62d', '#ff36ff']` | Array of color strings in HEX format | -| `shapes` | `Array of Strings` | `['square', 'circle', 'star']` | Array of shapes for the confetti | -| `zIndex` | `Integer` | `100` | The z-index of the confetti | -| `disableForReducedMotion` | `Boolean` | `false` | Disables confetti for users who prefer no motion | -| `useWorker` | `Boolean` | `true` | Use Web Worker for better performance | -| `resize` | `Boolean` | `true` | Whether to resize the canvas | -| `canvas` | `HTMLCanvasElement or null` | `null` | Custom canvas element to draw confetti | -| `scalar` | `Number` | `1` | Scaling factor for confetti size | +| Prop | Type | Default | Description | +| --------------- | ------------------------ | ----------------------------------- | ---------------------------------------------------------------- | +| `ref` | `React.Ref` | `null` | Ref exposing imperative `fire(options?: ConfettiOptions)` method | +| `options` | `ConfettiOptions` | `{}` | Default options for automatic or imperative confetti triggers | +| `globalOptions` | `ConfettiGlobalOptions` | `{ resize: true, useWorker: true }` | Global options passed to `canvas-confetti.create()` | +| `manualstart` | `Boolean` | `false` | Prevents automatic confetti firing on component mount | +| `className` | `String` | `undefined` | Custom CSS classes applied to the portaled canvas element | + +### ConfettiRef + +| Method | Parameters | Return Type | Description | +| ------ | --------------------------- | ----------------------- | ------------------------------------------------------------- | +| `fire` | `options?: ConfettiOptions` | `Promise \| void` | Imperatively triggers a confetti burst on the canvas instance | + +### ConfettiOptions + +| Field | Type | Default | Description | +| --------------- | -------------------------- | ------------------------------ | ------------------------------------------------------- | +| `particleCount` | `Integer` | `50` | Number of confetti particles to launch | +| `angle` | `Number` | `90` | Launch angle in degrees | +| `spread` | `Number` | `45` | Spread angle in degrees | +| `startVelocity` | `Number` | `45` | Initial particle velocity | +| `decay` | `Number` | `0.9` | Velocity decay rate | +| `gravity` | `Number` | `1` | Gravity applied to particles | +| `drift` | `Number` | `0` | Horizontal drift factor | +| `flat` | `Boolean` | `false` | Renders flat 2D shapes | +| `ticks` | `Number` | `200` | Number of animation frames confetti lasts | +| `origin` | `{ x: number, y: number }` | `{ x: 0.5, y: 0.5 }` | Normalized viewport origin coordinates (`0.0` to `1.0`) | +| `colors` | `Array of Strings` | `HEX array` | Custom HEX color palette | +| `shapes` | `Array of Strings` | `['square', 'circle', 'star']` | Particle shapes or custom text shapes | +| `zIndex` | `Integer` | `9999` | Z-index of the confetti burst | +| `scalar` | `Number` | `1` | Scaling factor for particle dimensions | ### ConfettiButton -| Prop | Type | Default | Description | -| ---------- | ----------------- | ------- | ------------------------------------ | -| `options` | `Object` | `{}` | Options for the confetti | -| `children` | `React.ReactNode` | `null` | Children to render inside the button | +| Prop | Type | Default | Description | +| ---------- | ----------------------------------------- | ------- | ------------------------------------------------- | +| `options` | `ConfettiOptions & ConfettiGlobalOptions` | `{}` | Options for the confetti launched on button click | +| `children` | `React.ReactNode` | `null` | Button label and child elements | +| `ref` | `React.Ref` | `null` | Forwarded DOM reference to the underlying button | ## Credits diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index e8a1ce9b1..546b3d114 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -5160,8 +5160,8 @@ import confetti from "canvas-confetti" import { Button } from "@/components/ui/button" -type Api = { - fire: (options?: ConfettiOptions) => void +export type ConfettiRef = { + fire: (options?: ConfettiOptions) => Promise | void } type Props = React.ComponentPropsWithRef<"canvas"> & { @@ -5171,122 +5171,123 @@ type Props = React.ComponentPropsWithRef<"canvas"> & { children?: ReactNode } -export type ConfettiRef = Api | null +const ConfettiContext = createContext(null) -const ConfettiContext = createContext({} as Api) - -// Define component first const ConfettiComponent = forwardRef((props, ref) => { const { options, globalOptions = { resize: true, useWorker: true }, manualstart = false, children, + className, ...rest } = props + + const canvasNodeRef = useRef(null) const instanceRef = useRef(null) + const optionsRef = useRef(options) + const globalOptionsRef = useRef(globalOptions) - const canvasRef = useCallback( - (node: HTMLCanvasElement) => { - if (node !== null) { - if (instanceRef.current) return - instanceRef.current = confetti.create(node, { - ...globalOptions, - resize: true, - }) - } else { - if (instanceRef.current) { - instanceRef.current.reset() - instanceRef.current = null - } - } - }, - [globalOptions] - ) + useEffect(() => { + optionsRef.current = options + }, [options]) - const fire = useCallback( - async (opts = {}) => { - try { - await instanceRef.current?.({ ...options, ...opts }) - } catch (error) { - console.error("Confetti error:", error) - } - }, - [options] - ) + useEffect(() => { + globalOptionsRef.current = globalOptions + }, [globalOptions]) - const api = useMemo( - () => ({ - fire, - }), - [fire] - ) + useEffect(() => { + if (canvasNodeRef.current && !instanceRef.current) { + instanceRef.current = confetti.create(canvasNodeRef.current, { + resize: true, + useWorker: true, + ...globalOptionsRef.current, + }) + } + + return () => { + instanceRef.current?.reset() + instanceRef.current = null + } + }, []) + + const fire = useCallback(async (opts: ConfettiOptions = {}) => { + try { + await instanceRef.current?.({ + zIndex: 9999, + ...optionsRef.current, + ...opts, + }) + } catch (error) { + console.error("Confetti error:", error) + } + }, []) + + const api = useMemo(() => ({ fire }), [fire]) useImperativeHandle(ref, () => api, [api]) useEffect(() => { if (!manualstart) { - ;(async () => { - try { - await fire() - } catch (error) { - console.error("Confetti effect error:", error) - } - })() + void fire() } }, [manualstart, fire]) return ( - + {children} ) }) -// Set display name immediately ConfettiComponent.displayName = "Confetti" -// Export as Confetti export const Confetti = ConfettiComponent -interface ConfettiButtonProps extends React.ComponentProps<"button"> { +export interface ConfettiButtonProps extends React.ComponentPropsWithoutRef< + typeof Button +> { options?: ConfettiOptions & ConfettiGlobalOptions & { canvas?: HTMLCanvasElement } } -const ConfettiButtonComponent = ({ - options, - children, - ...props -}: ConfettiButtonProps) => { - const handleClick = async (event: React.MouseEvent) => { +export const ConfettiButton = forwardRef< + HTMLButtonElement, + ConfettiButtonProps +>(({ options, children, onClick, ...props }, ref) => { + const handleClick: ConfettiButtonProps["onClick"] = async (event) => { try { - const rect = event.currentTarget.getBoundingClientRect() - const x = rect.left + rect.width / 2 - const y = rect.top + rect.height / 2 - await confetti({ - ...options, - origin: { - x: x / window.innerWidth, - y: y / window.innerHeight, - }, - }) + onClick?.(event) + if (event?.defaultPrevented) return + + const target = event?.currentTarget + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect() + const origin = { + x: (rect.left + rect.width / 2) / window.innerWidth, + y: (rect.top + rect.height / 2) / window.innerHeight, + } + + await confetti({ + zIndex: 9999, + ...options, + origin, + }) + } } catch (error) { console.error("Confetti button error:", error) } } return ( - ) -} - -ConfettiButtonComponent.displayName = "ConfettiButton" +}) -export const ConfettiButton = ConfettiButtonComponent +ConfettiButton.displayName = "ConfettiButton" ===== EXAMPLE: confetti-demo ===== diff --git a/apps/www/public/r/confetti.json b/apps/www/public/r/confetti.json index b216a75b6..9fb31acf1 100644 --- a/apps/www/public/r/confetti.json +++ b/apps/www/public/r/confetti.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/magicui/confetti.tsx", - "content": "\"use client\"\n\nimport type { ReactNode } from \"react\"\nimport React, {\n createContext,\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n} from \"react\"\nimport type {\n GlobalOptions as ConfettiGlobalOptions,\n CreateTypes as ConfettiInstance,\n Options as ConfettiOptions,\n} from \"canvas-confetti\"\nimport confetti from \"canvas-confetti\"\n\nimport { Button } from \"@/components/ui/button\"\n\ntype Api = {\n fire: (options?: ConfettiOptions) => void\n}\n\ntype Props = React.ComponentPropsWithRef<\"canvas\"> & {\n options?: ConfettiOptions\n globalOptions?: ConfettiGlobalOptions\n manualstart?: boolean\n children?: ReactNode\n}\n\nexport type ConfettiRef = Api | null\n\nconst ConfettiContext = createContext({} as Api)\n\n// Define component first\nconst ConfettiComponent = forwardRef((props, ref) => {\n const {\n options,\n globalOptions = { resize: true, useWorker: true },\n manualstart = false,\n children,\n ...rest\n } = props\n const instanceRef = useRef(null)\n\n const canvasRef = useCallback(\n (node: HTMLCanvasElement) => {\n if (node !== null) {\n if (instanceRef.current) return\n instanceRef.current = confetti.create(node, {\n ...globalOptions,\n resize: true,\n })\n } else {\n if (instanceRef.current) {\n instanceRef.current.reset()\n instanceRef.current = null\n }\n }\n },\n [globalOptions]\n )\n\n const fire = useCallback(\n async (opts = {}) => {\n try {\n await instanceRef.current?.({ ...options, ...opts })\n } catch (error) {\n console.error(\"Confetti error:\", error)\n }\n },\n [options]\n )\n\n const api = useMemo(\n () => ({\n fire,\n }),\n [fire]\n )\n\n useImperativeHandle(ref, () => api, [api])\n\n useEffect(() => {\n if (!manualstart) {\n ;(async () => {\n try {\n await fire()\n } catch (error) {\n console.error(\"Confetti effect error:\", error)\n }\n })()\n }\n }, [manualstart, fire])\n\n return (\n \n \n {children}\n \n )\n})\n\n// Set display name immediately\nConfettiComponent.displayName = \"Confetti\"\n\n// Export as Confetti\nexport const Confetti = ConfettiComponent\n\ninterface ConfettiButtonProps extends React.ComponentProps<\"button\"> {\n options?: ConfettiOptions &\n ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }\n}\n\nconst ConfettiButtonComponent = ({\n options,\n children,\n ...props\n}: ConfettiButtonProps) => {\n const handleClick = async (event: React.MouseEvent) => {\n try {\n const rect = event.currentTarget.getBoundingClientRect()\n const x = rect.left + rect.width / 2\n const y = rect.top + rect.height / 2\n await confetti({\n ...options,\n origin: {\n x: x / window.innerWidth,\n y: y / window.innerHeight,\n },\n })\n } catch (error) {\n console.error(\"Confetti button error:\", error)\n }\n }\n\n return (\n \n )\n}\n\nConfettiButtonComponent.displayName = \"ConfettiButton\"\n\nexport const ConfettiButton = ConfettiButtonComponent\n", + "content": "\"use client\"\n\nimport type { ReactNode } from \"react\"\nimport React, {\n createContext,\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n} from \"react\"\nimport type {\n GlobalOptions as ConfettiGlobalOptions,\n CreateTypes as ConfettiInstance,\n Options as ConfettiOptions,\n} from \"canvas-confetti\"\nimport confetti from \"canvas-confetti\"\n\nimport { Button } from \"@/components/ui/button\"\n\nexport type ConfettiRef = {\n fire: (options?: ConfettiOptions) => Promise | void\n}\n\ntype Props = React.ComponentPropsWithRef<\"canvas\"> & {\n options?: ConfettiOptions\n globalOptions?: ConfettiGlobalOptions\n manualstart?: boolean\n children?: ReactNode\n}\n\nconst ConfettiContext = createContext(null)\n\nconst ConfettiComponent = forwardRef((props, ref) => {\n const {\n options,\n globalOptions = { resize: true, useWorker: true },\n manualstart = false,\n children,\n className,\n ...rest\n } = props\n\n const canvasNodeRef = useRef(null)\n const instanceRef = useRef(null)\n const optionsRef = useRef(options)\n const globalOptionsRef = useRef(globalOptions)\n\n useEffect(() => {\n optionsRef.current = options\n }, [options])\n\n useEffect(() => {\n globalOptionsRef.current = globalOptions\n }, [globalOptions])\n\n useEffect(() => {\n if (canvasNodeRef.current && !instanceRef.current) {\n instanceRef.current = confetti.create(canvasNodeRef.current, {\n resize: true,\n useWorker: true,\n ...globalOptionsRef.current,\n })\n }\n\n return () => {\n instanceRef.current?.reset()\n instanceRef.current = null\n }\n }, [])\n\n const fire = useCallback(async (opts: ConfettiOptions = {}) => {\n try {\n await instanceRef.current?.({\n zIndex: 9999,\n ...optionsRef.current,\n ...opts,\n })\n } catch (error) {\n console.error(\"Confetti error:\", error)\n }\n }, [])\n\n const api = useMemo(() => ({ fire }), [fire])\n\n useImperativeHandle(ref, () => api, [api])\n\n useEffect(() => {\n if (!manualstart) {\n void fire()\n }\n }, [manualstart, fire])\n\n return (\n \n \n {children}\n \n )\n})\n\nConfettiComponent.displayName = \"Confetti\"\n\nexport const Confetti = ConfettiComponent\n\nexport interface ConfettiButtonProps extends React.ComponentPropsWithoutRef<\n typeof Button\n> {\n options?: ConfettiOptions &\n ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }\n}\n\nexport const ConfettiButton = forwardRef<\n HTMLButtonElement,\n ConfettiButtonProps\n>(({ options, children, onClick, ...props }, ref) => {\n const handleClick: ConfettiButtonProps[\"onClick\"] = async (event) => {\n try {\n onClick?.(event)\n if (event?.defaultPrevented) return\n\n const target = event?.currentTarget\n if (target && \"getBoundingClientRect\" in target) {\n const rect = target.getBoundingClientRect()\n const origin = {\n x: (rect.left + rect.width / 2) / window.innerWidth,\n y: (rect.top + rect.height / 2) / window.innerHeight,\n }\n\n await confetti({\n zIndex: 9999,\n ...options,\n origin,\n })\n }\n } catch (error) {\n console.error(\"Confetti button error:\", error)\n }\n }\n\n return (\n \n )\n})\n\nConfettiButton.displayName = \"ConfettiButton\"\n", "type": "registry:ui" } ] diff --git a/apps/www/registry/magicui/confetti.tsx b/apps/www/registry/magicui/confetti.tsx index 53ef5d6b3..6b5bb99a7 100644 --- a/apps/www/registry/magicui/confetti.tsx +++ b/apps/www/registry/magicui/confetti.tsx @@ -19,8 +19,8 @@ import confetti from "canvas-confetti" import { Button } from "@/components/ui/button" -type Api = { - fire: (options?: ConfettiOptions) => void +export type ConfettiRef = { + fire: (options?: ConfettiOptions) => Promise | void } type Props = React.ComponentPropsWithRef<"canvas"> & { @@ -30,119 +30,120 @@ type Props = React.ComponentPropsWithRef<"canvas"> & { children?: ReactNode } -export type ConfettiRef = Api | null +const ConfettiContext = createContext(null) -const ConfettiContext = createContext({} as Api) - -// Define component first const ConfettiComponent = forwardRef((props, ref) => { const { options, globalOptions = { resize: true, useWorker: true }, manualstart = false, children, + className, ...rest } = props + + const canvasNodeRef = useRef(null) const instanceRef = useRef(null) + const optionsRef = useRef(options) + const globalOptionsRef = useRef(globalOptions) - const canvasRef = useCallback( - (node: HTMLCanvasElement) => { - if (node !== null) { - if (instanceRef.current) return - instanceRef.current = confetti.create(node, { - ...globalOptions, - resize: true, - }) - } else { - if (instanceRef.current) { - instanceRef.current.reset() - instanceRef.current = null - } - } - }, - [globalOptions] - ) + useEffect(() => { + optionsRef.current = options + }, [options]) - const fire = useCallback( - async (opts = {}) => { - try { - await instanceRef.current?.({ ...options, ...opts }) - } catch (error) { - console.error("Confetti error:", error) - } - }, - [options] - ) + useEffect(() => { + globalOptionsRef.current = globalOptions + }, [globalOptions]) - const api = useMemo( - () => ({ - fire, - }), - [fire] - ) + useEffect(() => { + if (canvasNodeRef.current && !instanceRef.current) { + instanceRef.current = confetti.create(canvasNodeRef.current, { + resize: true, + useWorker: true, + ...globalOptionsRef.current, + }) + } + + return () => { + instanceRef.current?.reset() + instanceRef.current = null + } + }, []) + + const fire = useCallback(async (opts: ConfettiOptions = {}) => { + try { + await instanceRef.current?.({ + zIndex: 9999, + ...optionsRef.current, + ...opts, + }) + } catch (error) { + console.error("Confetti error:", error) + } + }, []) + + const api = useMemo(() => ({ fire }), [fire]) useImperativeHandle(ref, () => api, [api]) useEffect(() => { if (!manualstart) { - ;(async () => { - try { - await fire() - } catch (error) { - console.error("Confetti effect error:", error) - } - })() + void fire() } }, [manualstart, fire]) return ( - + {children} ) }) -// Set display name immediately ConfettiComponent.displayName = "Confetti" -// Export as Confetti export const Confetti = ConfettiComponent -interface ConfettiButtonProps extends React.ComponentProps<"button"> { +export interface ConfettiButtonProps extends React.ComponentPropsWithoutRef< + typeof Button +> { options?: ConfettiOptions & ConfettiGlobalOptions & { canvas?: HTMLCanvasElement } } -const ConfettiButtonComponent = ({ - options, - children, - ...props -}: ConfettiButtonProps) => { - const handleClick = async (event: React.MouseEvent) => { +export const ConfettiButton = forwardRef< + HTMLButtonElement, + ConfettiButtonProps +>(({ options, children, onClick, ...props }, ref) => { + const handleClick: ConfettiButtonProps["onClick"] = async (event) => { try { - const rect = event.currentTarget.getBoundingClientRect() - const x = rect.left + rect.width / 2 - const y = rect.top + rect.height / 2 - await confetti({ - ...options, - origin: { - x: x / window.innerWidth, - y: y / window.innerHeight, - }, - }) + onClick?.(event) + if (event?.defaultPrevented) return + + const target = event?.currentTarget + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect() + const origin = { + x: (rect.left + rect.width / 2) / window.innerWidth, + y: (rect.top + rect.height / 2) / window.innerHeight, + } + + await confetti({ + zIndex: 9999, + ...options, + origin, + }) + } } catch (error) { console.error("Confetti button error:", error) } } return ( - ) -} - -ConfettiButtonComponent.displayName = "ConfettiButton" +}) -export const ConfettiButton = ConfettiButtonComponent +ConfettiButton.displayName = "ConfettiButton"