From 810dfce6d60d444b264070d449c8c7896e4181f9 Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Wed, 1 Jul 2026 16:22:14 +0530 Subject: [PATCH 1/2] Add MouseLockButton that disables pointer input for 5 seconds Introduces a warning button that blocks all mouse/pointer events across the page for five seconds after being clicked, with a full-screen countdown overlay. Includes docs, registry entry, and landing page showcase. --- components/evil-buttons/mouse-lock-button.tsx | 261 ++++++++++++++++++ components/landing/landing-page.tsx | 7 + components/mdx-custom-components.tsx | 2 + content/docs/mouse-lock-button.mdx | 59 ++++ public/r/index.json | 9 + public/r/mouse-lock-button.json | 20 ++ registry.components.json | 9 + 7 files changed, 367 insertions(+) create mode 100644 components/evil-buttons/mouse-lock-button.tsx create mode 100644 content/docs/mouse-lock-button.mdx create mode 100644 public/r/mouse-lock-button.json diff --git a/components/evil-buttons/mouse-lock-button.tsx b/components/evil-buttons/mouse-lock-button.tsx new file mode 100644 index 0000000..a459233 --- /dev/null +++ b/components/evil-buttons/mouse-lock-button.tsx @@ -0,0 +1,261 @@ +"use client"; + +import * as React from "react"; +import { createPortal } from "react-dom"; +import { + AnimatePresence, + motion, + useReducedMotion, + type Variants, +} from "motion/react"; +import { cn } from "@/lib/utils"; + +type MouseLockState = "idle" | "locked"; + +export interface MouseLockButtonProps + extends Omit, "onClick"> { + /** Trigger label. Falls back to `label` when no children are provided. */ + children?: React.ReactNode; + /** Idle warning label used when no children are provided. */ + label?: React.ReactNode; + /** Milliseconds to disable pointer input across the page after a click. */ + lockDuration?: number; + /** Message shown on the full-screen lock overlay. */ + lockedMessage?: React.ReactNode; + /** Fired when the lock begins. */ + onLock?: () => void; + /** Fired when the lock ends. */ + onUnlock?: () => void; +} + +const POINTER_BLOCK_EVENTS = [ + "click", + "mousedown", + "mouseup", + "mousemove", + "pointerdown", + "pointerup", + "pointermove", + "contextmenu", + "dblclick", + "wheel", +] as const; + +const WarningIcon = () => ( + + + + + +); + +const labelVariants: Variants = { + enter: { y: 6, opacity: 0 }, + center: { y: 0, opacity: 1 }, + exit: { y: -6, opacity: 0 }, +}; + +function blockPointerEvent(event: Event) { + event.preventDefault(); + event.stopPropagation(); +} + +export const MouseLockButton = React.forwardRef< + HTMLButtonElement, + MouseLockButtonProps +>( + ( + { + children, + label = "Do NOT click this", + lockDuration = 5000, + lockedMessage = "You were warned.", + onLock, + onUnlock, + className, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const reduceMotion = useReducedMotion(); + const [state, setState] = React.useState("idle"); + const [remainingMs, setRemainingMs] = React.useState(0); + const mounted = React.useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); + + const lockTimeoutRef = React.useRef(null); + const countdownIntervalRef = React.useRef(null); + const lockEndsAtRef = React.useRef(0); + + const clearTimers = React.useCallback(() => { + if (lockTimeoutRef.current !== null) { + window.clearTimeout(lockTimeoutRef.current); + lockTimeoutRef.current = null; + } + if (countdownIntervalRef.current !== null) { + window.clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + }, []); + + const unlock = React.useCallback(() => { + clearTimers(); + setState("idle"); + setRemainingMs(0); + onUnlock?.(); + }, [clearTimers, onUnlock]); + + React.useEffect(() => () => clearTimers(), [clearTimers]); + + React.useEffect(() => { + if (state !== "locked") return; + + const previousBodyCursor = document.body.style.cursor; + const previousHtmlCursor = document.documentElement.style.cursor; + + document.body.style.cursor = "none"; + document.documentElement.style.cursor = "none"; + + const options: AddEventListenerOptions = { capture: true }; + for (const eventName of POINTER_BLOCK_EVENTS) { + document.addEventListener(eventName, blockPointerEvent, options); + } + + return () => { + document.body.style.cursor = previousBodyCursor; + document.documentElement.style.cursor = previousHtmlCursor; + + for (const eventName of POINTER_BLOCK_EVENTS) { + document.removeEventListener(eventName, blockPointerEvent, options); + } + }; + }, [state]); + + const handleClick = () => { + if (disabled || state === "locked") return; + + clearTimers(); + lockEndsAtRef.current = Date.now() + lockDuration; + setRemainingMs(lockDuration); + setState("locked"); + onLock?.(); + + countdownIntervalRef.current = window.setInterval(() => { + const nextRemaining = Math.max(0, lockEndsAtRef.current - Date.now()); + setRemainingMs(nextRemaining); + if (nextRemaining <= 0 && countdownIntervalRef.current !== null) { + window.clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + }, 50); + + lockTimeoutRef.current = window.setTimeout(unlock, lockDuration); + }; + + const isLocked = state === "locked"; + const remainingSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); + const displayLabel = children ?? label; + + const overlay = + mounted && isLocked ? ( +
+ +

{lockedMessage}

+

+ Mouse disabled for{" "} + {remainingSeconds} + s +

+
+
+ ) : null; + + return ( + <> + + + {mounted && createPortal( + {overlay}, + document.body, + )} + + ); + }, +); + +MouseLockButton.displayName = "MouseLockButton"; + +export default MouseLockButton; \ No newline at end of file diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index 539194d..8a83287 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -36,6 +36,7 @@ import TrollButton from "@/components/evil-buttons/troll-button"; import { DemonicButton } from "../evil-buttons/demonic-button"; import { PillButton } from "@/components/evil-buttons/pill-button"; import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; +import { MouseLockButton } from "@/components/evil-buttons/mouse-lock-button"; type ButtonShowcase = { name: string; @@ -245,6 +246,12 @@ const showcase: ButtonShowcase[] = [ registryName: "hold-confirm-button", render: () => , }, + { + name: "MouseLockButton", + href: "/docs/mouse-lock-button", + registryName: "mouse-lock-button", + render: () => , + }, ]; function ButtonCell({ item }: { item: ButtonShowcase }) { diff --git a/components/mdx-custom-components.tsx b/components/mdx-custom-components.tsx index 7e106fe..119da24 100644 --- a/components/mdx-custom-components.tsx +++ b/components/mdx-custom-components.tsx @@ -30,6 +30,7 @@ import { MorphStatusButton } from "./evil-buttons/morph-status-button"; import { CooldownButton } from "./evil-buttons/cooldown-button"; import { PillButton } from "./evil-buttons/pill-button"; import { HoldConfirmButton } from "./evil-buttons/hold-confirm-button"; +import { MouseLockButton } from "./evil-buttons/mouse-lock-button"; import { MorphStatusButtonDemo, MorphStatusButtonFailDemo, @@ -129,5 +130,6 @@ export function getCustomMDXComponents(): MDXComponents { CooldownButton, PillButton, HoldConfirmButton, + MouseLockButton, }; } diff --git a/content/docs/mouse-lock-button.mdx b/content/docs/mouse-lock-button.mdx new file mode 100644 index 0000000..6563577 --- /dev/null +++ b/content/docs/mouse-lock-button.mdx @@ -0,0 +1,59 @@ +--- +title: MouseLockButton +description: A warning button that disables all pointer input across the page for five seconds after you click it anyway. +--- + +MouseLockButton wears its warning on its sleeve: a pulsing red label that says not to click. Click it anyway and every pointer event on the page is swallowed for five seconds while a full-screen overlay counts down and taunts you. + +## Preview + + + + + +## Install + +Add the item with the shadcn CLI. + +@evilbuttons/mouse-lock-button + +## Usage + +```tsx +import { MouseLockButton } from "@/components/evil-buttons/mouse-lock-button"; + +export function ButtonDemo() { + return ( + console.log("Pointer jammed.")} + onUnlock={() => console.log("Mouse restored.")} + /> + ); +} +``` + +## Props + +The component spreads any `\n\n {mounted && createPortal(\n {overlay},\n document.body,\n )}\n \n );\n },\n);\n\nMouseLockButton.displayName = \"MouseLockButton\";\n\nexport default MouseLockButton;" + } + ], + "dependencies": [ + "clsx", + "tailwind-merge", + "motion" + ] +} diff --git a/registry.components.json b/registry.components.json index 8c35263..6e578ee 100644 --- a/registry.components.json +++ b/registry.components.json @@ -260,5 +260,14 @@ "description": "A hold-to-confirm pill button that shrinks while pressed and draws a circular progress ring around it using Motion styleEffect and svgEffect.", "file": "components/evil-buttons/hold-confirm-button.tsx", "dependencies": ["clsx", "tailwind-merge", "motion"] + }, + { + "name": "mouse-lock-button", + "exportName": "MouseLockButton", + "docSlug": "mouse-lock-button", + "title": "MouseLockButton", + "description": "A warning button that disables all pointer input across the page for five seconds after you click it anyway.", + "file": "components/evil-buttons/mouse-lock-button.tsx", + "dependencies": ["clsx", "tailwind-merge", "motion"] } ] From e0d08fbe46a1142cd5f84ef0f79fb80f37ba838d Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Wed, 1 Jul 2026 16:29:21 +0530 Subject: [PATCH 2/2] Replace MouseLockButton with ConfettiButton celebration pop Adds a normal shadcn button that pops on click and fires a canvas-confetti burst. Fixes the Motion spring runtime error by using a two-step spring animation instead of multi-keyframe scale values. --- components/evil-buttons/confetti-button.tsx | 141 ++++++++++ components/evil-buttons/mouse-lock-button.tsx | 261 ------------------ components/landing/landing-page.tsx | 10 +- components/mdx-custom-components.tsx | 4 +- content/docs/confetti-button.mdx | 62 +++++ content/docs/mouse-lock-button.mdx | 59 ---- package.json | 2 + pnpm-lock.yaml | 16 ++ public/r/confetti-button.json | 24 ++ public/r/index.json | 8 +- public/r/mouse-lock-button.json | 20 -- registry.components.json | 15 +- 12 files changed, 264 insertions(+), 358 deletions(-) create mode 100644 components/evil-buttons/confetti-button.tsx delete mode 100644 components/evil-buttons/mouse-lock-button.tsx create mode 100644 content/docs/confetti-button.mdx delete mode 100644 content/docs/mouse-lock-button.mdx create mode 100644 public/r/confetti-button.json delete mode 100644 public/r/mouse-lock-button.json diff --git a/components/evil-buttons/confetti-button.tsx b/components/evil-buttons/confetti-button.tsx new file mode 100644 index 0000000..61699dd --- /dev/null +++ b/components/evil-buttons/confetti-button.tsx @@ -0,0 +1,141 @@ +"use client"; + +import * as React from "react"; +import confetti from "canvas-confetti"; +import { motion, useAnimationControls } from "motion/react"; +import { Button } from "@/components/ui/button"; + +export interface ConfettiButtonProps + extends Omit, "onClick"> { + /** Button label. Falls back to `label` when no children are provided. */ + children?: React.ReactNode; + /** Label used when no children are provided. */ + label?: React.ReactNode; + /** Confetti particles per burst. */ + particleCount?: number; + /** Confetti spread in degrees. */ + spread?: number; + /** Extra vertical launch velocity. */ + startVelocity?: number; + /** Custom confetti colors. */ + colors?: string[]; + /** Fired after each confetti burst. */ + onCelebrate?: () => void; +} + +function burstFromElement( + element: HTMLElement, + options: { + particleCount: number; + spread: number; + startVelocity: number; + colors?: string[]; + }, +) { + const rect = element.getBoundingClientRect(); + const x = (rect.left + rect.width / 2) / window.innerWidth; + const y = (rect.top + rect.height / 2) / window.innerHeight; + + void confetti({ + particleCount: options.particleCount, + spread: options.spread, + startVelocity: options.startVelocity, + origin: { x, y }, + colors: options.colors, + disableForReducedMotion: true, + }); +} + +export const ConfettiButton = React.forwardRef< + HTMLButtonElement, + ConfettiButtonProps +>( + ( + { + children, + label = "Continue", + particleCount = 120, + spread = 72, + startVelocity = 38, + colors, + onCelebrate, + className, + disabled, + variant, + size, + type = "button", + ...props + }, + ref, + ) => { + const buttonRef = React.useRef(null); + const popControls = useAnimationControls(); + + const setButtonRef = (node: HTMLButtonElement | null) => { + buttonRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }; + + const handleClick = () => { + if (disabled || !buttonRef.current) return; + + burstFromElement(buttonRef.current, { + particleCount, + spread, + startVelocity, + colors, + }); + onCelebrate?.(); + + void popControls + .start({ + scale: 1.12, + transition: { + type: "spring", + stiffness: 520, + damping: 14, + mass: 0.55, + }, + }) + .then(() => + popControls.start({ + scale: 1, + transition: { + type: "spring", + stiffness: 420, + damping: 20, + mass: 0.6, + }, + }), + ); + }; + + const displayLabel = children ?? label; + + return ( + + + + ); + }, +); + +ConfettiButton.displayName = "ConfettiButton"; + +export default ConfettiButton; \ No newline at end of file diff --git a/components/evil-buttons/mouse-lock-button.tsx b/components/evil-buttons/mouse-lock-button.tsx deleted file mode 100644 index a459233..0000000 --- a/components/evil-buttons/mouse-lock-button.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"use client"; - -import * as React from "react"; -import { createPortal } from "react-dom"; -import { - AnimatePresence, - motion, - useReducedMotion, - type Variants, -} from "motion/react"; -import { cn } from "@/lib/utils"; - -type MouseLockState = "idle" | "locked"; - -export interface MouseLockButtonProps - extends Omit, "onClick"> { - /** Trigger label. Falls back to `label` when no children are provided. */ - children?: React.ReactNode; - /** Idle warning label used when no children are provided. */ - label?: React.ReactNode; - /** Milliseconds to disable pointer input across the page after a click. */ - lockDuration?: number; - /** Message shown on the full-screen lock overlay. */ - lockedMessage?: React.ReactNode; - /** Fired when the lock begins. */ - onLock?: () => void; - /** Fired when the lock ends. */ - onUnlock?: () => void; -} - -const POINTER_BLOCK_EVENTS = [ - "click", - "mousedown", - "mouseup", - "mousemove", - "pointerdown", - "pointerup", - "pointermove", - "contextmenu", - "dblclick", - "wheel", -] as const; - -const WarningIcon = () => ( - - - - - -); - -const labelVariants: Variants = { - enter: { y: 6, opacity: 0 }, - center: { y: 0, opacity: 1 }, - exit: { y: -6, opacity: 0 }, -}; - -function blockPointerEvent(event: Event) { - event.preventDefault(); - event.stopPropagation(); -} - -export const MouseLockButton = React.forwardRef< - HTMLButtonElement, - MouseLockButtonProps ->( - ( - { - children, - label = "Do NOT click this", - lockDuration = 5000, - lockedMessage = "You were warned.", - onLock, - onUnlock, - className, - disabled, - type = "button", - ...props - }, - ref, - ) => { - const reduceMotion = useReducedMotion(); - const [state, setState] = React.useState("idle"); - const [remainingMs, setRemainingMs] = React.useState(0); - const mounted = React.useSyncExternalStore( - () => () => {}, - () => true, - () => false, - ); - - const lockTimeoutRef = React.useRef(null); - const countdownIntervalRef = React.useRef(null); - const lockEndsAtRef = React.useRef(0); - - const clearTimers = React.useCallback(() => { - if (lockTimeoutRef.current !== null) { - window.clearTimeout(lockTimeoutRef.current); - lockTimeoutRef.current = null; - } - if (countdownIntervalRef.current !== null) { - window.clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - }, []); - - const unlock = React.useCallback(() => { - clearTimers(); - setState("idle"); - setRemainingMs(0); - onUnlock?.(); - }, [clearTimers, onUnlock]); - - React.useEffect(() => () => clearTimers(), [clearTimers]); - - React.useEffect(() => { - if (state !== "locked") return; - - const previousBodyCursor = document.body.style.cursor; - const previousHtmlCursor = document.documentElement.style.cursor; - - document.body.style.cursor = "none"; - document.documentElement.style.cursor = "none"; - - const options: AddEventListenerOptions = { capture: true }; - for (const eventName of POINTER_BLOCK_EVENTS) { - document.addEventListener(eventName, blockPointerEvent, options); - } - - return () => { - document.body.style.cursor = previousBodyCursor; - document.documentElement.style.cursor = previousHtmlCursor; - - for (const eventName of POINTER_BLOCK_EVENTS) { - document.removeEventListener(eventName, blockPointerEvent, options); - } - }; - }, [state]); - - const handleClick = () => { - if (disabled || state === "locked") return; - - clearTimers(); - lockEndsAtRef.current = Date.now() + lockDuration; - setRemainingMs(lockDuration); - setState("locked"); - onLock?.(); - - countdownIntervalRef.current = window.setInterval(() => { - const nextRemaining = Math.max(0, lockEndsAtRef.current - Date.now()); - setRemainingMs(nextRemaining); - if (nextRemaining <= 0 && countdownIntervalRef.current !== null) { - window.clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - }, 50); - - lockTimeoutRef.current = window.setTimeout(unlock, lockDuration); - }; - - const isLocked = state === "locked"; - const remainingSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); - const displayLabel = children ?? label; - - const overlay = - mounted && isLocked ? ( -
- -

{lockedMessage}

-

- Mouse disabled for{" "} - {remainingSeconds} - s -

-
-
- ) : null; - - return ( - <> - - - {mounted && createPortal( - {overlay}, - document.body, - )} - - ); - }, -); - -MouseLockButton.displayName = "MouseLockButton"; - -export default MouseLockButton; \ No newline at end of file diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index 8a83287..156a96f 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -36,7 +36,7 @@ import TrollButton from "@/components/evil-buttons/troll-button"; import { DemonicButton } from "../evil-buttons/demonic-button"; import { PillButton } from "@/components/evil-buttons/pill-button"; import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; -import { MouseLockButton } from "@/components/evil-buttons/mouse-lock-button"; +import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; type ButtonShowcase = { name: string; @@ -247,10 +247,10 @@ const showcase: ButtonShowcase[] = [ render: () => , }, { - name: "MouseLockButton", - href: "/docs/mouse-lock-button", - registryName: "mouse-lock-button", - render: () => , + name: "ConfettiButton", + href: "/docs/confetti-button", + registryName: "confetti-button", + render: () => Celebrate, }, ]; diff --git a/components/mdx-custom-components.tsx b/components/mdx-custom-components.tsx index 119da24..7b0e471 100644 --- a/components/mdx-custom-components.tsx +++ b/components/mdx-custom-components.tsx @@ -30,7 +30,7 @@ import { MorphStatusButton } from "./evil-buttons/morph-status-button"; import { CooldownButton } from "./evil-buttons/cooldown-button"; import { PillButton } from "./evil-buttons/pill-button"; import { HoldConfirmButton } from "./evil-buttons/hold-confirm-button"; -import { MouseLockButton } from "./evil-buttons/mouse-lock-button"; +import { ConfettiButton } from "./evil-buttons/confetti-button"; import { MorphStatusButtonDemo, MorphStatusButtonFailDemo, @@ -130,6 +130,6 @@ export function getCustomMDXComponents(): MDXComponents { CooldownButton, PillButton, HoldConfirmButton, - MouseLockButton, + ConfettiButton, }; } diff --git a/content/docs/confetti-button.mdx b/content/docs/confetti-button.mdx new file mode 100644 index 0000000..ea25370 --- /dev/null +++ b/content/docs/confetti-button.mdx @@ -0,0 +1,62 @@ +--- +title: ConfettiButton +description: A plain shadcn button that pops on click and fires a confetti celebration burst. +--- + +ConfettiButton looks like any other default button. Click it and it pops with a spring bounce while confetti erupts from the button — a tiny celebration for doing almost nothing. + +## Preview + + + + + +## Install + +Add the item with the shadcn CLI. + +@evilbuttons/confetti-button + +## Usage + +```tsx +import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; + +export function ButtonDemo() { + return ( + console.log("Celebrated!")} + > + Celebrate + + ); +} +``` + +## Props + +The component spreads shadcn `Button` props except `onClick`. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `children` | `React.ReactNode` | - | Button label. Falls back to `label`. | +| `label` | `React.ReactNode` | `"Continue"` | Label used when no children are provided. | +| `particleCount` | `number` | `120` | Confetti particles per burst. | +| `spread` | `number` | `72` | Confetti spread in degrees. | +| `startVelocity` | `number` | `38` | Extra vertical launch velocity. | +| `colors` | `string[]` | - | Custom confetti colors. | +| `onCelebrate` | `() => void` | - | Fired after each confetti burst. | +| `variant` | `Button` variant | `"default"` | Passed through to the shadcn button. | +| `size` | `Button` size | `"default"` | Passed through to the shadcn button. | +| `className` | `string` | - | Extra classes passed to the button. | + +## Notes + +- Built on the shadcn `Button` with default styling so it blends into any UI. +- Each click runs a spring pop animation and launches confetti from the button center via `canvas-confetti`. +- Confetti is skipped automatically when the user prefers reduced motion. + +## Registry + +The registry item includes `components/evil-buttons/confetti-button.tsx`, installs the shadcn `button` registry item, and adds `canvas-confetti`, `clsx`, `tailwind-merge`, and `motion` as dependencies. \ No newline at end of file diff --git a/content/docs/mouse-lock-button.mdx b/content/docs/mouse-lock-button.mdx deleted file mode 100644 index 6563577..0000000 --- a/content/docs/mouse-lock-button.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: MouseLockButton -description: A warning button that disables all pointer input across the page for five seconds after you click it anyway. ---- - -MouseLockButton wears its warning on its sleeve: a pulsing red label that says not to click. Click it anyway and every pointer event on the page is swallowed for five seconds while a full-screen overlay counts down and taunts you. - -## Preview - - - - - -## Install - -Add the item with the shadcn CLI. - -@evilbuttons/mouse-lock-button - -## Usage - -```tsx -import { MouseLockButton } from "@/components/evil-buttons/mouse-lock-button"; - -export function ButtonDemo() { - return ( - console.log("Pointer jammed.")} - onUnlock={() => console.log("Mouse restored.")} - /> - ); -} -``` - -## Props - -The component spreads any `\n \n );\n },\n);\n\nConfettiButton.displayName = \"ConfettiButton\";\n\nexport default ConfettiButton;" + } + ], + "registryDependencies": [ + "button" + ], + "dependencies": [ + "canvas-confetti", + "clsx", + "tailwind-merge", + "motion" + ] +} diff --git a/public/r/index.json b/public/r/index.json index a7b035a..a7847ae 100644 --- a/public/r/index.json +++ b/public/r/index.json @@ -256,12 +256,12 @@ ] }, { - "name": "mouse-lock-button", + "name": "confetti-button", "type": "registry:ui", - "title": "MouseLockButton", - "description": "A warning button that disables all pointer input across the page for five seconds after you click it anyway.", + "title": "ConfettiButton", + "description": "A plain shadcn button that pops on click and fires a confetti celebration burst.", "files": [ - "components/evil-buttons/mouse-lock-button.tsx" + "components/evil-buttons/confetti-button.tsx" ] } ] diff --git a/public/r/mouse-lock-button.json b/public/r/mouse-lock-button.json deleted file mode 100644 index 2b6b3ab..0000000 --- a/public/r/mouse-lock-button.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "mouse-lock-button", - "type": "registry:ui", - "title": "MouseLockButton", - "description": "A warning button that disables all pointer input across the page for five seconds after you click it anyway.", - "files": [ - { - "path": "components/evil-buttons/mouse-lock-button.tsx", - "type": "registry:ui", - "target": "components/evil-buttons/mouse-lock-button.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n AnimatePresence,\n motion,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\ntype MouseLockState = \"idle\" | \"locked\";\n\nexport interface MouseLockButtonProps\n extends Omit, \"onClick\"> {\n /** Trigger label. Falls back to `label` when no children are provided. */\n children?: React.ReactNode;\n /** Idle warning label used when no children are provided. */\n label?: React.ReactNode;\n /** Milliseconds to disable pointer input across the page after a click. */\n lockDuration?: number;\n /** Message shown on the full-screen lock overlay. */\n lockedMessage?: React.ReactNode;\n /** Fired when the lock begins. */\n onLock?: () => void;\n /** Fired when the lock ends. */\n onUnlock?: () => void;\n}\n\nconst POINTER_BLOCK_EVENTS = [\n \"click\",\n \"mousedown\",\n \"mouseup\",\n \"mousemove\",\n \"pointerdown\",\n \"pointerup\",\n \"pointermove\",\n \"contextmenu\",\n \"dblclick\",\n \"wheel\",\n] as const;\n\nconst WarningIcon = () => (\n \n \n \n \n \n);\n\nconst labelVariants: Variants = {\n enter: { y: 6, opacity: 0 },\n center: { y: 0, opacity: 1 },\n exit: { y: -6, opacity: 0 },\n};\n\nfunction blockPointerEvent(event: Event) {\n event.preventDefault();\n event.stopPropagation();\n}\n\nexport const MouseLockButton = React.forwardRef<\n HTMLButtonElement,\n MouseLockButtonProps\n>(\n (\n {\n children,\n label = \"Do NOT click this\",\n lockDuration = 5000,\n lockedMessage = \"You were warned.\",\n onLock,\n onUnlock,\n className,\n disabled,\n type = \"button\",\n ...props\n },\n ref,\n ) => {\n const reduceMotion = useReducedMotion();\n const [state, setState] = React.useState(\"idle\");\n const [remainingMs, setRemainingMs] = React.useState(0);\n const mounted = React.useSyncExternalStore(\n () => () => {},\n () => true,\n () => false,\n );\n\n const lockTimeoutRef = React.useRef(null);\n const countdownIntervalRef = React.useRef(null);\n const lockEndsAtRef = React.useRef(0);\n\n const clearTimers = React.useCallback(() => {\n if (lockTimeoutRef.current !== null) {\n window.clearTimeout(lockTimeoutRef.current);\n lockTimeoutRef.current = null;\n }\n if (countdownIntervalRef.current !== null) {\n window.clearInterval(countdownIntervalRef.current);\n countdownIntervalRef.current = null;\n }\n }, []);\n\n const unlock = React.useCallback(() => {\n clearTimers();\n setState(\"idle\");\n setRemainingMs(0);\n onUnlock?.();\n }, [clearTimers, onUnlock]);\n\n React.useEffect(() => () => clearTimers(), [clearTimers]);\n\n React.useEffect(() => {\n if (state !== \"locked\") return;\n\n const previousBodyCursor = document.body.style.cursor;\n const previousHtmlCursor = document.documentElement.style.cursor;\n\n document.body.style.cursor = \"none\";\n document.documentElement.style.cursor = \"none\";\n\n const options: AddEventListenerOptions = { capture: true };\n for (const eventName of POINTER_BLOCK_EVENTS) {\n document.addEventListener(eventName, blockPointerEvent, options);\n }\n\n return () => {\n document.body.style.cursor = previousBodyCursor;\n document.documentElement.style.cursor = previousHtmlCursor;\n\n for (const eventName of POINTER_BLOCK_EVENTS) {\n document.removeEventListener(eventName, blockPointerEvent, options);\n }\n };\n }, [state]);\n\n const handleClick = () => {\n if (disabled || state === \"locked\") return;\n\n clearTimers();\n lockEndsAtRef.current = Date.now() + lockDuration;\n setRemainingMs(lockDuration);\n setState(\"locked\");\n onLock?.();\n\n countdownIntervalRef.current = window.setInterval(() => {\n const nextRemaining = Math.max(0, lockEndsAtRef.current - Date.now());\n setRemainingMs(nextRemaining);\n if (nextRemaining <= 0 && countdownIntervalRef.current !== null) {\n window.clearInterval(countdownIntervalRef.current);\n countdownIntervalRef.current = null;\n }\n }, 50);\n\n lockTimeoutRef.current = window.setTimeout(unlock, lockDuration);\n };\n\n const isLocked = state === \"locked\";\n const remainingSeconds = Math.max(1, Math.ceil(remainingMs / 1000));\n const displayLabel = children ?? label;\n\n const overlay =\n mounted && isLocked ? (\n \n \n

{lockedMessage}

\n

\n Mouse disabled for{\" \"}\n {remainingSeconds}\n s\n

\n \n \n ) : null;\n\n return (\n <>\n \n \n \n \n \n \n {isLocked ? \"Too late.\" : displayLabel}\n \n \n \n \n \n\n {mounted && createPortal(\n {overlay},\n document.body,\n )}\n \n );\n },\n);\n\nMouseLockButton.displayName = \"MouseLockButton\";\n\nexport default MouseLockButton;" - } - ], - "dependencies": [ - "clsx", - "tailwind-merge", - "motion" - ] -} diff --git a/registry.components.json b/registry.components.json index 6e578ee..21583b2 100644 --- a/registry.components.json +++ b/registry.components.json @@ -262,12 +262,13 @@ "dependencies": ["clsx", "tailwind-merge", "motion"] }, { - "name": "mouse-lock-button", - "exportName": "MouseLockButton", - "docSlug": "mouse-lock-button", - "title": "MouseLockButton", - "description": "A warning button that disables all pointer input across the page for five seconds after you click it anyway.", - "file": "components/evil-buttons/mouse-lock-button.tsx", - "dependencies": ["clsx", "tailwind-merge", "motion"] + "name": "confetti-button", + "exportName": "ConfettiButton", + "docSlug": "confetti-button", + "title": "ConfettiButton", + "description": "A plain shadcn button that pops on click and fires a confetti celebration burst.", + "file": "components/evil-buttons/confetti-button.tsx", + "registryDependencies": ["button"], + "dependencies": ["canvas-confetti", "clsx", "tailwind-merge", "motion"] } ]