From ff6cdff499bddd7022f75c54e5e5eb9e99e16d92 Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 30 Jun 2026 22:25:08 +0530 Subject: [PATCH 1/7] Add HoldConfirmButton with circular progress ring Introduces a hold-to-confirm pill button that shrinks while pressed and draws a progress ring using Motion styleEffect and svgEffect. Includes docs, registry wiring, and homepage showcase. --- .../evil-buttons/hold-confirm-button.tsx | 222 ++++++++++++++++++ components/landing/landing-page.tsx | 11 + components/mdx-custom-components.tsx | 2 + content/docs/hold-confirm-button.mdx | 69 ++++++ public/r/hold-confirm-button.json | 20 ++ public/r/index.json | 9 + registry.components.json | 9 + 7 files changed, 342 insertions(+) create mode 100644 components/evil-buttons/hold-confirm-button.tsx create mode 100644 content/docs/hold-confirm-button.mdx create mode 100644 public/r/hold-confirm-button.json diff --git a/components/evil-buttons/hold-confirm-button.tsx b/components/evil-buttons/hold-confirm-button.tsx new file mode 100644 index 00000000..a06840b4 --- /dev/null +++ b/components/evil-buttons/hold-confirm-button.tsx @@ -0,0 +1,222 @@ +"use client"; + +import * as React from "react"; +import { + animate, + mapValue, + motionValue, + press, + styleEffect, + svgEffect, +} from "motion"; +import { cn } from "@/lib/utils"; + +type HoldConfirmState = "idle" | "holding" | "success"; + +export interface HoldConfirmButtonProps + extends Omit< + React.ButtonHTMLAttributes, + "onClick" | "onAbort" + > { + /** Milliseconds the user must hold before `onConfirm` fires. */ + duration?: number; + /** Label shown while idle. */ + label?: React.ReactNode; + /** Optional label shown while holding. Falls back to `label`. */ + holdingLabel?: React.ReactNode; + /** Label shown after a successful hold. */ + successLabel?: React.ReactNode; + /** Smallest scale reached at 100% progress. */ + minScale?: number; + /** Diameter of the progress ring in pixels. */ + ringSize?: number; + /** Stroke width of the progress ring. */ + ringStrokeWidth?: number; + /** Color of the progress ring. */ + ringColor?: string; + /** Fired when the hold reaches 100%. */ + onConfirm?: () => void; + /** Fired when the hold is released early. Receives progress reached (0-1). */ + onAbort?: (progress: number) => void; + /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */ + resetAfter?: number; +} + +export const HoldConfirmButton = React.forwardRef< + HTMLButtonElement, + HoldConfirmButtonProps +>( + ( + { + duration = 2000, + label = "Hold to confirm", + holdingLabel, + successLabel = "Confirmed", + minScale = 0.9, + ringSize = 200, + ringStrokeWidth = 3, + ringColor = "#5eead4", + onConfirm, + onAbort, + resetAfter = 1600, + className, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const [state, setState] = React.useState("idle"); + + const buttonRef = React.useRef(null); + const circleRef = React.useRef(null); + const animationRef = React.useRef | null>(null); + const stateRef = React.useRef("idle"); + const resetTimeoutRef = React.useRef(null); + + const setButtonRef = (node: HTMLButtonElement | null) => { + buttonRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }; + + React.useEffect(() => { + stateRef.current = state; + }, [state]); + + React.useEffect(() => { + const button = buttonRef.current; + const circle = circleRef.current; + if (!button || !circle || disabled) return; + + const progress = motionValue(0); + const scale = mapValue(progress, [0, 1], [1, minScale]); + + const cancelStyle = styleEffect(button, { scale }); + const cancelSvg = svgEffect(circle, { pathLength: progress }); + + const stopAnimation = () => { + animationRef.current?.stop(); + animationRef.current = null; + }; + + const rewind = () => { + stopAnimation(); + animate(progress, 0, { duration: 0.28, ease: "easeOut" }); + }; + + const complete = () => { + stopAnimation(); + progress.set(1); + setState("success"); + onConfirm?.(); + + if (resetAfter > 0) { + if (resetTimeoutRef.current !== null) { + window.clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = window.setTimeout(() => { + rewind(); + setState("idle"); + }, resetAfter); + } + }; + + const cancelPress = press(button, () => { + if (stateRef.current !== "idle") return; + + if (resetTimeoutRef.current !== null) { + window.clearTimeout(resetTimeoutRef.current); + } + + setState("holding"); + stopAnimation(); + progress.set(0); + + animationRef.current = animate(progress, 1, { + duration: duration / 1000, + ease: "linear", + onComplete: complete, + }); + + return (_endEvent, { success }) => { + if (stateRef.current === "success") return; + + if (!success || progress.get() < 1) { + const reached = progress.get(); + setState("idle"); + rewind(); + onAbort?.(reached); + } + }; + }); + + return () => { + cancelPress(); + cancelStyle(); + cancelSvg(); + stopAnimation(); + if (resetTimeoutRef.current !== null) { + window.clearTimeout(resetTimeoutRef.current); + } + }; + }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]); + + const radius = ringSize / 2 - ringStrokeWidth * 2 - 10; + const center = ringSize / 2; + const isHolding = state === "holding"; + const isSuccess = state === "success"; + const displayLabel = isSuccess + ? successLabel + : isHolding && holdingLabel !== undefined + ? holdingLabel + : label; + + return ( +
+ + + + + +
+ ); + }, +); + +HoldConfirmButton.displayName = "HoldConfirmButton"; + +export default HoldConfirmButton; \ No newline at end of file diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index 67bad146..34300cc8 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -35,6 +35,7 @@ import { ThreeDButton } from "@/components/evil-buttons/3d-button"; 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"; type ButtonShowcase = { name: string; @@ -238,6 +239,16 @@ const showcase: ButtonShowcase[] = [ /> ), }, + { + name: "HoldConfirmButton", + href: "/docs/hold-confirm-button", + registryName: "hold-confirm-button", + render: () => ( +
+ +
+ ), + }, ]; function ButtonCell({ item }: { item: ButtonShowcase }) { diff --git a/components/mdx-custom-components.tsx b/components/mdx-custom-components.tsx index eaef922f..7e106fed 100644 --- a/components/mdx-custom-components.tsx +++ b/components/mdx-custom-components.tsx @@ -29,6 +29,7 @@ import { SlideToDetonate } from "./evil-buttons/slide-to-detonate"; 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 { MorphStatusButtonDemo, MorphStatusButtonFailDemo, @@ -127,5 +128,6 @@ export function getCustomMDXComponents(): MDXComponents { MorphStatusButtonFailDemo, CooldownButton, PillButton, + HoldConfirmButton, }; } diff --git a/content/docs/hold-confirm-button.mdx b/content/docs/hold-confirm-button.mdx new file mode 100644 index 00000000..beca7683 --- /dev/null +++ b/content/docs/hold-confirm-button.mdx @@ -0,0 +1,69 @@ +--- +title: HoldConfirmButton +description: A hold-to-confirm pill button that shrinks while pressed and draws a circular progress ring around it using Motion styleEffect and svgEffect. +--- + +HoldConfirmButton guards actions behind a deliberate press-and-hold. While held, a mint progress ring draws clockwise around the pill and the button scales down to a configurable minimum. Release early and progress rewinds; hold to completion and `onConfirm` fires. + +## Preview + +
+ console.log("confirmed")} /> +
+ +### Fast hold + +
+ +
+ +## Install + +Add the item with the shadcn CLI. + +@evilbuttons/hold-confirm-button + +## Usage + +```tsx +import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; + +export function ButtonDemo() { + return ( +
+ console.log("confirmed")} + onAbort={(progress) => console.log("aborted at", progress)} + /> +
+ ); +} +``` + +## Props + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `duration` | `number` | `2000` | Milliseconds the user must hold before `onConfirm` fires. | +| `label` | `React.ReactNode` | `"Hold to confirm"` | Label shown while idle. | +| `holdingLabel` | `React.ReactNode` | `label` | Label shown while holding. | +| `successLabel` | `React.ReactNode` | `"Confirmed"` | Label shown after a successful hold. | +| `minScale` | `number` | `0.9` | Smallest scale reached at 100% progress. | +| `ringSize` | `number` | `200` | Diameter of the progress ring in pixels. | +| `ringStrokeWidth` | `number` | `3` | Stroke width of the progress ring. | +| `ringColor` | `string` | `"#5eead4"` | Color of the progress ring. | +| `onConfirm` | `() => void` | - | Fired when the hold reaches 100%. | +| `onAbort` | `(progress: number) => void` | - | Fired when released early with progress reached (0-1). | +| `resetAfter` | `number` | `1600` | Ms to stay in success before resetting. `0` stays. | + +## Notes + +- Uses Motion [`press`](https://motion.dev/docs/press), [`motionValue`](https://motion.dev/docs/motion-value), [`mapValue`](https://motion.dev/docs/transform), [`styleEffect`](https://motion.dev/docs/motion-value#styleeffect), and [`svgEffect`](https://motion.dev/docs/motion-value#svgeffect) so one progress value drives both the scale and ring. +- The ring SVG is rotated `-90deg` so progress starts at the top. +- `data-state` is `idle` | `holding` | `success` for styling hooks. + +## Registry + +The registry item includes `components/evil-buttons/hold-confirm-button.tsx` and installs `motion`, `clsx`, and `tailwind-merge` as dependencies. \ No newline at end of file diff --git a/public/r/hold-confirm-button.json b/public/r/hold-confirm-button.json new file mode 100644 index 00000000..62e92fc6 --- /dev/null +++ b/public/r/hold-confirm-button.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "hold-confirm-button", + "type": "registry:ui", + "title": "HoldConfirmButton", + "description": "A hold-to-confirm pill button that shrinks while pressed and draws a circular progress ring around it using Motion styleEffect and svgEffect.", + "files": [ + { + "path": "components/evil-buttons/hold-confirm-button.tsx", + "type": "registry:ui", + "target": "components/evil-buttons/hold-confirm-button.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\n\nexport interface HoldConfirmButtonProps\n extends Omit<\n React.ButtonHTMLAttributes,\n \"onClick\" | \"onAbort\"\n > {\n /** Milliseconds the user must hold before `onConfirm` fires. */\n duration?: number;\n /** Label shown while idle. */\n label?: React.ReactNode;\n /** Optional label shown while holding. Falls back to `label`. */\n holdingLabel?: React.ReactNode;\n /** Label shown after a successful hold. */\n successLabel?: React.ReactNode;\n /** Smallest scale reached at 100% progress. */\n minScale?: number;\n /** Diameter of the progress ring in pixels. */\n ringSize?: number;\n /** Stroke width of the progress ring. */\n ringStrokeWidth?: number;\n /** Color of the progress ring. */\n ringColor?: string;\n /** Fired when the hold reaches 100%. */\n onConfirm?: () => void;\n /** Fired when the hold is released early. Receives progress reached (0-1). */\n onAbort?: (progress: number) => void;\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\n resetAfter?: number;\n}\n\nexport const HoldConfirmButton = React.forwardRef<\n HTMLButtonElement,\n HoldConfirmButtonProps\n>(\n (\n {\n duration = 2000,\n label = \"Hold to confirm\",\n holdingLabel,\n successLabel = \"Confirmed\",\n minScale = 0.9,\n ringSize = 200,\n ringStrokeWidth = 3,\n ringColor = \"#5eead4\",\n onConfirm,\n onAbort,\n resetAfter = 1600,\n className,\n disabled,\n type = \"button\",\n ...props\n },\n ref,\n ) => {\n const [state, setState] = React.useState(\"idle\");\n\n const buttonRef = React.useRef(null);\n const circleRef = React.useRef(null);\n const animationRef = React.useRef | null>(null);\n const stateRef = React.useRef(\"idle\");\n const resetTimeoutRef = React.useRef(null);\n\n const setButtonRef = (node: HTMLButtonElement | null) => {\n buttonRef.current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) ref.current = node;\n };\n\n React.useEffect(() => {\n stateRef.current = state;\n }, [state]);\n\n React.useEffect(() => {\n const button = buttonRef.current;\n const circle = circleRef.current;\n if (!button || !circle || disabled) return;\n\n const progress = motionValue(0);\n const scale = mapValue(progress, [0, 1], [1, minScale]);\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelSvg = svgEffect(circle, { pathLength: progress });\n\n const stopAnimation = () => {\n animationRef.current?.stop();\n animationRef.current = null;\n };\n\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { duration: 0.28, ease: \"easeOut\" });\n };\n\n const complete = () => {\n stopAnimation();\n progress.set(1);\n setState(\"success\");\n onConfirm?.();\n\n if (resetAfter > 0) {\n if (resetTimeoutRef.current !== null) {\n window.clearTimeout(resetTimeoutRef.current);\n }\n resetTimeoutRef.current = window.setTimeout(() => {\n rewind();\n setState(\"idle\");\n }, resetAfter);\n }\n };\n\n const cancelPress = press(button, () => {\n if (stateRef.current !== \"idle\") return;\n\n if (resetTimeoutRef.current !== null) {\n window.clearTimeout(resetTimeoutRef.current);\n }\n\n setState(\"holding\");\n stopAnimation();\n progress.set(0);\n\n animationRef.current = animate(progress, 1, {\n duration: duration / 1000,\n ease: \"linear\",\n onComplete: complete,\n });\n\n return (_endEvent, { success }) => {\n if (stateRef.current === \"success\") return;\n\n if (!success || progress.get() < 1) {\n const reached = progress.get();\n setState(\"idle\");\n rewind();\n onAbort?.(reached);\n }\n };\n });\n\n return () => {\n cancelPress();\n cancelStyle();\n cancelSvg();\n stopAnimation();\n if (resetTimeoutRef.current !== null) {\n window.clearTimeout(resetTimeoutRef.current);\n }\n };\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\n\n const radius = ringSize / 2 - ringStrokeWidth * 2 - 10;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\n const isSuccess = state === \"success\";\n const displayLabel = isSuccess\n ? successLabel\n : isHolding && holdingLabel !== undefined\n ? holdingLabel\n : label;\n\n return (\n \n \n \n \n\n \n {displayLabel}\n \n \n );\n },\n);\n\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\n\nexport default HoldConfirmButton;" + } + ], + "dependencies": [ + "clsx", + "tailwind-merge", + "motion" + ] +} diff --git a/public/r/index.json b/public/r/index.json index 0b3c4dd7..63c57861 100644 --- a/public/r/index.json +++ b/public/r/index.json @@ -245,6 +245,15 @@ "files": [ "components/evil-buttons/pill-button.tsx" ] + }, + { + "name": "hold-confirm-button", + "type": "registry:ui", + "title": "HoldConfirmButton", + "description": "A hold-to-confirm pill button that shrinks while pressed and draws a circular progress ring around it using Motion styleEffect and svgEffect.", + "files": [ + "components/evil-buttons/hold-confirm-button.tsx" + ] } ] } diff --git a/registry.components.json b/registry.components.json index a389831a..8c352638 100644 --- a/registry.components.json +++ b/registry.components.json @@ -251,5 +251,14 @@ "description": "A compact pill toggle that slides between two labeled faces with a rolling hover label animation.", "file": "components/evil-buttons/pill-button.tsx", "dependencies": ["clsx", "tailwind-merge", "motion"] + }, + { + "name": "hold-confirm-button", + "exportName": "HoldConfirmButton", + "docSlug": "hold-confirm-button", + "title": "HoldConfirmButton", + "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"] } ] From ebbe5766cc6ab460e9bab5d469d44c5d6afd7d25 Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 30 Jun 2026 22:29:02 +0530 Subject: [PATCH 2/7] Fix HoldConfirmButton docs preview server component error Remove onConfirm handler from MDX preview. Server-rendered docs cannot pass event handlers to client components. --- content/docs/hold-confirm-button.mdx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/content/docs/hold-confirm-button.mdx b/content/docs/hold-confirm-button.mdx index beca7683..12d2bd64 100644 --- a/content/docs/hold-confirm-button.mdx +++ b/content/docs/hold-confirm-button.mdx @@ -7,15 +7,19 @@ HoldConfirmButton guards actions behind a deliberate press-and-hold. While held, ## Preview -
- console.log("confirmed")} /> -
+ +
+ +
+
### Fast hold -
- -
+ +
+ +
+
## Install From fe04f206b138b4e8203f81d2e13d9690662015e4 Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 30 Jun 2026 22:30:38 +0530 Subject: [PATCH 3/7] Thicken gooey ring and remove black preview backgrounds Default stroke is 12px with an SVG goo filter, spring-driven scale squash, and spring rewind. Docs and landing previews no longer wrap the button in a black box. --- .../evil-buttons/hold-confirm-button.tsx | 40 ++++++++++++++++--- components/landing/landing-page.tsx | 6 +-- content/docs/hold-confirm-button.mdx | 26 +++++------- public/r/hold-confirm-button.json | 2 +- 4 files changed, 47 insertions(+), 27 deletions(-) diff --git a/components/evil-buttons/hold-confirm-button.tsx b/components/evil-buttons/hold-confirm-button.tsx index a06840b4..b23ae9ce 100644 --- a/components/evil-buttons/hold-confirm-button.tsx +++ b/components/evil-buttons/hold-confirm-button.tsx @@ -6,6 +6,7 @@ import { mapValue, motionValue, press, + springValue, styleEffect, svgEffect, } from "motion"; @@ -54,7 +55,7 @@ export const HoldConfirmButton = React.forwardRef< successLabel = "Confirmed", minScale = 0.9, ringSize = 200, - ringStrokeWidth = 3, + ringStrokeWidth = 12, ringColor = "#5eead4", onConfirm, onAbort, @@ -67,6 +68,7 @@ export const HoldConfirmButton = React.forwardRef< ref, ) => { const [state, setState] = React.useState("idle"); + const filterId = React.useId().replace(/:/g, ""); const buttonRef = React.useRef(null); const circleRef = React.useRef(null); @@ -90,7 +92,11 @@ export const HoldConfirmButton = React.forwardRef< if (!button || !circle || disabled) return; const progress = motionValue(0); - const scale = mapValue(progress, [0, 1], [1, minScale]); + const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), { + stiffness: 420, + damping: 28, + mass: 0.6, + }); const cancelStyle = styleEffect(button, { scale }); const cancelSvg = svgEffect(circle, { pathLength: progress }); @@ -102,7 +108,7 @@ export const HoldConfirmButton = React.forwardRef< const rewind = () => { stopAnimation(); - animate(progress, 0, { duration: 0.28, ease: "easeOut" }); + animate(progress, 0, { type: "spring", stiffness: 520, damping: 32 }); }; const complete = () => { @@ -162,7 +168,7 @@ export const HoldConfirmButton = React.forwardRef< }; }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]); - const radius = ringSize / 2 - ringStrokeWidth * 2 - 10; + const radius = ringSize / 2 - ringStrokeWidth - 14; const center = ringSize / 2; const isHolding = state === "holding"; const isSuccess = state === "success"; @@ -184,6 +190,29 @@ export const HoldConfirmButton = React.forwardRef< viewBox={`0 0 ${ringSize} ${ringSize}`} aria-hidden > + + + + + + + @@ -203,7 +233,7 @@ export const HoldConfirmButton = React.forwardRef< aria-live="polite" data-state={state} className={cn( - "relative z-10 inline-flex select-none items-center justify-center rounded-full bg-neutral-100 px-6 py-2.5 text-sm font-medium text-neutral-900 shadow-sm outline-none", + "relative z-10 inline-flex select-none items-center justify-center rounded-full bg-neutral-100 px-6 py-2.5 text-sm font-medium text-neutral-900 shadow-[0_4px_18px_rgba(94,234,212,0.22)] outline-none", "touch-none focus-visible:ring-2 focus-visible:ring-emerald-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", isSuccess && "bg-emerald-50 text-emerald-700", className, diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index 34300cc8..539194dd 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -243,11 +243,7 @@ const showcase: ButtonShowcase[] = [ name: "HoldConfirmButton", href: "/docs/hold-confirm-button", registryName: "hold-confirm-button", - render: () => ( -
- -
- ), + render: () => , }, ]; diff --git a/content/docs/hold-confirm-button.mdx b/content/docs/hold-confirm-button.mdx index 12d2bd64..1787ca72 100644 --- a/content/docs/hold-confirm-button.mdx +++ b/content/docs/hold-confirm-button.mdx @@ -3,22 +3,18 @@ title: HoldConfirmButton description: A hold-to-confirm pill button that shrinks while pressed and draws a circular progress ring around it using Motion styleEffect and svgEffect. --- -HoldConfirmButton guards actions behind a deliberate press-and-hold. While held, a mint progress ring draws clockwise around the pill and the button scales down to a configurable minimum. Release early and progress rewinds; hold to completion and `onConfirm` fires. +HoldConfirmButton guards actions behind a deliberate press-and-hold. While held, a thick gooey mint ring draws clockwise around the pill and the button squashes down with a spring. Release early and progress rewinds; hold to completion and `onConfirm` fires. ## Preview -
- -
+
### Fast hold -
- -
+
## Install @@ -34,14 +30,12 @@ import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button export function ButtonDemo() { return ( -
- console.log("confirmed")} - onAbort={(progress) => console.log("aborted at", progress)} - /> -
+ console.log("confirmed")} + onAbort={(progress) => console.log("aborted at", progress)} + /> ); } ``` @@ -56,7 +50,7 @@ export function ButtonDemo() { | `successLabel` | `React.ReactNode` | `"Confirmed"` | Label shown after a successful hold. | | `minScale` | `number` | `0.9` | Smallest scale reached at 100% progress. | | `ringSize` | `number` | `200` | Diameter of the progress ring in pixels. | -| `ringStrokeWidth` | `number` | `3` | Stroke width of the progress ring. | +| `ringStrokeWidth` | `number` | `12` | Stroke width of the progress ring. | | `ringColor` | `string` | `"#5eead4"` | Color of the progress ring. | | `onConfirm` | `() => void` | - | Fired when the hold reaches 100%. | | `onAbort` | `(progress: number) => void` | - | Fired when released early with progress reached (0-1). | diff --git a/public/r/hold-confirm-button.json b/public/r/hold-confirm-button.json index 62e92fc6..50993adb 100644 --- a/public/r/hold-confirm-button.json +++ b/public/r/hold-confirm-button.json @@ -9,7 +9,7 @@ "path": "components/evil-buttons/hold-confirm-button.tsx", "type": "registry:ui", "target": "components/evil-buttons/hold-confirm-button.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\n\nexport interface HoldConfirmButtonProps\n extends Omit<\n React.ButtonHTMLAttributes,\n \"onClick\" | \"onAbort\"\n > {\n /** Milliseconds the user must hold before `onConfirm` fires. */\n duration?: number;\n /** Label shown while idle. */\n label?: React.ReactNode;\n /** Optional label shown while holding. Falls back to `label`. */\n holdingLabel?: React.ReactNode;\n /** Label shown after a successful hold. */\n successLabel?: React.ReactNode;\n /** Smallest scale reached at 100% progress. */\n minScale?: number;\n /** Diameter of the progress ring in pixels. */\n ringSize?: number;\n /** Stroke width of the progress ring. */\n ringStrokeWidth?: number;\n /** Color of the progress ring. */\n ringColor?: string;\n /** Fired when the hold reaches 100%. */\n onConfirm?: () => void;\n /** Fired when the hold is released early. Receives progress reached (0-1). */\n onAbort?: (progress: number) => void;\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\n resetAfter?: number;\n}\n\nexport const HoldConfirmButton = React.forwardRef<\n HTMLButtonElement,\n HoldConfirmButtonProps\n>(\n (\n {\n duration = 2000,\n label = \"Hold to confirm\",\n holdingLabel,\n successLabel = \"Confirmed\",\n minScale = 0.9,\n ringSize = 200,\n ringStrokeWidth = 3,\n ringColor = \"#5eead4\",\n onConfirm,\n onAbort,\n resetAfter = 1600,\n className,\n disabled,\n type = \"button\",\n ...props\n },\n ref,\n ) => {\n const [state, setState] = React.useState(\"idle\");\n\n const buttonRef = React.useRef(null);\n const circleRef = React.useRef(null);\n const animationRef = React.useRef | null>(null);\n const stateRef = React.useRef(\"idle\");\n const resetTimeoutRef = React.useRef(null);\n\n const setButtonRef = (node: HTMLButtonElement | null) => {\n buttonRef.current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) ref.current = node;\n };\n\n React.useEffect(() => {\n stateRef.current = state;\n }, [state]);\n\n React.useEffect(() => {\n const button = buttonRef.current;\n const circle = circleRef.current;\n if (!button || !circle || disabled) return;\n\n const progress = motionValue(0);\n const scale = mapValue(progress, [0, 1], [1, minScale]);\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelSvg = svgEffect(circle, { pathLength: progress });\n\n const stopAnimation = () => {\n animationRef.current?.stop();\n animationRef.current = null;\n };\n\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { duration: 0.28, ease: \"easeOut\" });\n };\n\n const complete = () => {\n stopAnimation();\n progress.set(1);\n setState(\"success\");\n onConfirm?.();\n\n if (resetAfter > 0) {\n if (resetTimeoutRef.current !== null) {\n window.clearTimeout(resetTimeoutRef.current);\n }\n resetTimeoutRef.current = window.setTimeout(() => {\n rewind();\n setState(\"idle\");\n }, resetAfter);\n }\n };\n\n const cancelPress = press(button, () => {\n if (stateRef.current !== \"idle\") return;\n\n if (resetTimeoutRef.current !== null) {\n window.clearTimeout(resetTimeoutRef.current);\n }\n\n setState(\"holding\");\n stopAnimation();\n progress.set(0);\n\n animationRef.current = animate(progress, 1, {\n duration: duration / 1000,\n ease: \"linear\",\n onComplete: complete,\n });\n\n return (_endEvent, { success }) => {\n if (stateRef.current === \"success\") return;\n\n if (!success || progress.get() < 1) {\n const reached = progress.get();\n setState(\"idle\");\n rewind();\n onAbort?.(reached);\n }\n };\n });\n\n return () => {\n cancelPress();\n cancelStyle();\n cancelSvg();\n stopAnimation();\n if (resetTimeoutRef.current !== null) {\n window.clearTimeout(resetTimeoutRef.current);\n }\n };\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\n\n const radius = ringSize / 2 - ringStrokeWidth * 2 - 10;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\n const isSuccess = state === \"success\";\n const displayLabel = isSuccess\n ? successLabel\n : isHolding && holdingLabel !== undefined\n ? holdingLabel\n : label;\n\n return (\n \n \n \n \n\n \n {displayLabel}\n \n \n );\n },\n);\n\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\n\nexport default HoldConfirmButton;" + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 200,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n const filterId = React.useId().replace(/:/g, \"\");\n\n const buttonRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\r\n const circle = circleRef.current;\r\n if (!button || !circle || disabled) return;\r\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelSvg = svgEffect(circle, { pathLength: progress });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\r\n cancelPress();\r\n cancelStyle();\r\n cancelSvg();\r\n stopAnimation();\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth - 14;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n \n \n \n \n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" } ], "dependencies": [ From d573f0f477b03c1fcc339f1f539fd1d930e4e473 Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 30 Jun 2026 22:31:37 +0530 Subject: [PATCH 4/7] Hide progress ring until hold starts Fix idle full-circle artifact by initializing stroke-dasharray to zero, gating ring opacity on progress, hiding the SVG while idle, and removing the goo filter SourceGraphic blend. --- .../evil-buttons/hold-confirm-button.tsx | 24 ++++++++++++++----- public/r/hold-confirm-button.json | 2 +- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/components/evil-buttons/hold-confirm-button.tsx b/components/evil-buttons/hold-confirm-button.tsx index b23ae9ce..f2b5bdeb 100644 --- a/components/evil-buttons/hold-confirm-button.tsx +++ b/components/evil-buttons/hold-confirm-button.tsx @@ -98,8 +98,13 @@ export const HoldConfirmButton = React.forwardRef< mass: 0.6, }); + const ringOpacity = mapValue(progress, [0, 0.015, 1], [0, 1, 1]); + const cancelStyle = styleEffect(button, { scale }); - const cancelSvg = svgEffect(circle, { pathLength: progress }); + const cancelSvg = svgEffect(circle, { + pathLength: progress, + opacity: ringOpacity, + }); const stopAnimation = () => { animationRef.current?.stop(); @@ -184,7 +189,10 @@ export const HoldConfirmButton = React.forwardRef< style={{ width: ringSize, height: ringSize }} > - + @@ -233,7 +245,7 @@ export const HoldConfirmButton = React.forwardRef< aria-live="polite" data-state={state} className={cn( - "relative z-10 inline-flex select-none items-center justify-center rounded-full bg-neutral-100 px-6 py-2.5 text-sm font-medium text-neutral-900 shadow-[0_4px_18px_rgba(94,234,212,0.22)] outline-none", + "relative z-10 inline-flex select-none items-center justify-center rounded-full bg-neutral-100 px-6 py-2.5 text-sm font-medium text-neutral-900 shadow-sm outline-none", "touch-none focus-visible:ring-2 focus-visible:ring-emerald-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50", isSuccess && "bg-emerald-50 text-emerald-700", className, diff --git a/public/r/hold-confirm-button.json b/public/r/hold-confirm-button.json index 50993adb..5cefafab 100644 --- a/public/r/hold-confirm-button.json +++ b/public/r/hold-confirm-button.json @@ -9,7 +9,7 @@ "path": "components/evil-buttons/hold-confirm-button.tsx", "type": "registry:ui", "target": "components/evil-buttons/hold-confirm-button.tsx", - "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 200,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n const filterId = React.useId().replace(/:/g, \"\");\n\n const buttonRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\r\n const circle = circleRef.current;\r\n if (!button || !circle || disabled) return;\r\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelSvg = svgEffect(circle, { pathLength: progress });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\r\n cancelPress();\r\n cancelStyle();\r\n cancelSvg();\r\n stopAnimation();\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth - 14;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n \n \n \n \n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 200,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n const filterId = React.useId().replace(/:/g, \"\");\n\n const buttonRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\r\n const circle = circleRef.current;\r\n if (!button || !circle || disabled) return;\r\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const ringOpacity = mapValue(progress, [0, 0.015, 1], [0, 1, 1]);\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelSvg = svgEffect(circle, {\n pathLength: progress,\n opacity: ringOpacity,\n });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\r\n cancelPress();\r\n cancelStyle();\r\n cancelSvg();\r\n stopAnimation();\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth - 14;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n \n \n \n \n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" } ], "dependencies": [ From cbeabe154967aa94187074749e97f23c7fa0496a Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 30 Jun 2026 22:32:08 +0530 Subject: [PATCH 5/7] Fix ring not appearing on press Stop React from overriding Motion svgEffect by removing controlled opacity and stroke-dash attributes on the circle. Ring visibility is gated on the SVG wrapper state only. --- components/evil-buttons/hold-confirm-button.tsx | 17 +++-------------- public/r/hold-confirm-button.json | 2 +- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/components/evil-buttons/hold-confirm-button.tsx b/components/evil-buttons/hold-confirm-button.tsx index f2b5bdeb..3b9b69ba 100644 --- a/components/evil-buttons/hold-confirm-button.tsx +++ b/components/evil-buttons/hold-confirm-button.tsx @@ -98,13 +98,8 @@ export const HoldConfirmButton = React.forwardRef< mass: 0.6, }); - const ringOpacity = mapValue(progress, [0, 0.015, 1], [0, 1, 1]); - const cancelStyle = styleEffect(button, { scale }); - const cancelSvg = svgEffect(circle, { - pathLength: progress, - opacity: ringOpacity, - }); + const cancelSvg = svgEffect(circle, { pathLength: progress }); const stopAnimation = () => { animationRef.current?.stop(); @@ -190,8 +185,8 @@ export const HoldConfirmButton = React.forwardRef< > - diff --git a/public/r/hold-confirm-button.json b/public/r/hold-confirm-button.json index 5cefafab..80be8404 100644 --- a/public/r/hold-confirm-button.json +++ b/public/r/hold-confirm-button.json @@ -9,7 +9,7 @@ "path": "components/evil-buttons/hold-confirm-button.tsx", "type": "registry:ui", "target": "components/evil-buttons/hold-confirm-button.tsx", - "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 200,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n const filterId = React.useId().replace(/:/g, \"\");\n\n const buttonRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\r\n const circle = circleRef.current;\r\n if (!button || !circle || disabled) return;\r\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const ringOpacity = mapValue(progress, [0, 0.015, 1], [0, 1, 1]);\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelSvg = svgEffect(circle, {\n pathLength: progress,\n opacity: ringOpacity,\n });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\r\n cancelPress();\r\n cancelStyle();\r\n cancelSvg();\r\n stopAnimation();\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth - 14;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n \n \n \n \n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 200,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n const filterId = React.useId().replace(/:/g, \"\");\n\n const buttonRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\r\n const circle = circleRef.current;\r\n if (!button || !circle || disabled) return;\r\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelSvg = svgEffect(circle, { pathLength: progress });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\r\n cancelPress();\r\n cancelStyle();\r\n cancelSvg();\r\n stopAnimation();\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth - 14;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n \n \n \n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" } ], "dependencies": [ From 52f852c58e25d6acf115f7a2f461433e8a6430bd Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 30 Jun 2026 22:33:27 +0530 Subject: [PATCH 6/7] Draw progress ring gradually instead of full circle bloom Remove the SVG goo filter that blurred the thick stroke into a full ring on press. Ring opacity and pathLength now both track hold progress for a gradual arc. --- .../evil-buttons/hold-confirm-button.tsx | 41 ++++++------------- public/r/hold-confirm-button.json | 2 +- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/components/evil-buttons/hold-confirm-button.tsx b/components/evil-buttons/hold-confirm-button.tsx index 3b9b69ba..a57d890f 100644 --- a/components/evil-buttons/hold-confirm-button.tsx +++ b/components/evil-buttons/hold-confirm-button.tsx @@ -68,9 +68,9 @@ export const HoldConfirmButton = React.forwardRef< ref, ) => { const [state, setState] = React.useState("idle"); - const filterId = React.useId().replace(/:/g, ""); const buttonRef = React.useRef(null); + const ringRef = React.useRef(null); const circleRef = React.useRef(null); const animationRef = React.useRef | null>(null); const stateRef = React.useRef("idle"); @@ -88,8 +88,9 @@ export const HoldConfirmButton = React.forwardRef< React.useEffect(() => { const button = buttonRef.current; + const ring = ringRef.current; const circle = circleRef.current; - if (!button || !circle || disabled) return; + if (!button || !ring || !circle || disabled) return; const progress = motionValue(0); const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), { @@ -98,7 +99,10 @@ export const HoldConfirmButton = React.forwardRef< mass: 0.6, }); + const ringOpacity = mapValue(progress, [0, 0.01, 1], [0, 1, 1]); + const cancelStyle = styleEffect(button, { scale }); + const cancelRingOpacity = styleEffect(ring, { opacity: ringOpacity }); const cancelSvg = svgEffect(circle, { pathLength: progress }); const stopAnimation = () => { @@ -160,6 +164,7 @@ export const HoldConfirmButton = React.forwardRef< return () => { cancelPress(); cancelStyle(); + cancelRingOpacity(); cancelSvg(); stopAnimation(); if (resetTimeoutRef.current !== null) { @@ -184,36 +189,13 @@ export const HoldConfirmButton = React.forwardRef< style={{ width: ringSize, height: ringSize }} > - - - - - - diff --git a/public/r/hold-confirm-button.json b/public/r/hold-confirm-button.json index 80be8404..d39fe2ed 100644 --- a/public/r/hold-confirm-button.json +++ b/public/r/hold-confirm-button.json @@ -9,7 +9,7 @@ "path": "components/evil-buttons/hold-confirm-button.tsx", "type": "registry:ui", "target": "components/evil-buttons/hold-confirm-button.tsx", - "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 200,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n const filterId = React.useId().replace(/:/g, \"\");\n\n const buttonRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\r\n const circle = circleRef.current;\r\n if (!button || !circle || disabled) return;\r\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelSvg = svgEffect(circle, { pathLength: progress });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\r\n cancelPress();\r\n cancelStyle();\r\n cancelSvg();\r\n stopAnimation();\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth - 14;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n \n \n \n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 200,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n\n const buttonRef = React.useRef(null);\n const ringRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\n const ring = ringRef.current;\n const circle = circleRef.current;\n if (!button || !ring || !circle || disabled) return;\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const ringOpacity = mapValue(progress, [0, 0.01, 1], [0, 1, 1]);\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelRingOpacity = styleEffect(ring, { opacity: ringOpacity });\n const cancelSvg = svgEffect(circle, {\n pathLength: progress,\n pathOffset: 0,\n });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\n cancelPress();\n cancelStyle();\n cancelRingOpacity();\n cancelSvg();\n stopAnimation();\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth - 14;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" } ], "dependencies": [ From 933bbd9e0d228d006f8fee7a11438bf8aeed0ad8 Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 30 Jun 2026 22:33:52 +0530 Subject: [PATCH 7/7] Increase hold-confirm progress ring radius Default ringSize is 280px with a tighter radius formula so the arc sits farther from the pill. --- components/evil-buttons/hold-confirm-button.tsx | 4 ++-- content/docs/hold-confirm-button.mdx | 2 +- public/r/hold-confirm-button.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/evil-buttons/hold-confirm-button.tsx b/components/evil-buttons/hold-confirm-button.tsx index a57d890f..eb6390dc 100644 --- a/components/evil-buttons/hold-confirm-button.tsx +++ b/components/evil-buttons/hold-confirm-button.tsx @@ -54,7 +54,7 @@ export const HoldConfirmButton = React.forwardRef< holdingLabel, successLabel = "Confirmed", minScale = 0.9, - ringSize = 200, + ringSize = 280, ringStrokeWidth = 12, ringColor = "#5eead4", onConfirm, @@ -173,7 +173,7 @@ export const HoldConfirmButton = React.forwardRef< }; }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]); - const radius = ringSize / 2 - ringStrokeWidth - 14; + const radius = ringSize / 2 - ringStrokeWidth / 2 - 6; const center = ringSize / 2; const isHolding = state === "holding"; const isSuccess = state === "success"; diff --git a/content/docs/hold-confirm-button.mdx b/content/docs/hold-confirm-button.mdx index 1787ca72..347c354f 100644 --- a/content/docs/hold-confirm-button.mdx +++ b/content/docs/hold-confirm-button.mdx @@ -49,7 +49,7 @@ export function ButtonDemo() { | `holdingLabel` | `React.ReactNode` | `label` | Label shown while holding. | | `successLabel` | `React.ReactNode` | `"Confirmed"` | Label shown after a successful hold. | | `minScale` | `number` | `0.9` | Smallest scale reached at 100% progress. | -| `ringSize` | `number` | `200` | Diameter of the progress ring in pixels. | +| `ringSize` | `number` | `280` | Diameter of the progress ring in pixels. | | `ringStrokeWidth` | `number` | `12` | Stroke width of the progress ring. | | `ringColor` | `string` | `"#5eead4"` | Color of the progress ring. | | `onConfirm` | `() => void` | - | Fired when the hold reaches 100%. | diff --git a/public/r/hold-confirm-button.json b/public/r/hold-confirm-button.json index d39fe2ed..ff523cbc 100644 --- a/public/r/hold-confirm-button.json +++ b/public/r/hold-confirm-button.json @@ -9,7 +9,7 @@ "path": "components/evil-buttons/hold-confirm-button.tsx", "type": "registry:ui", "target": "components/evil-buttons/hold-confirm-button.tsx", - "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 200,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n\n const buttonRef = React.useRef(null);\n const ringRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\n const ring = ringRef.current;\n const circle = circleRef.current;\n if (!button || !ring || !circle || disabled) return;\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const ringOpacity = mapValue(progress, [0, 0.01, 1], [0, 1, 1]);\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelRingOpacity = styleEffect(ring, { opacity: ringOpacity });\n const cancelSvg = svgEffect(circle, {\n pathLength: progress,\n pathOffset: 0,\n });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\n cancelPress();\n cancelStyle();\n cancelRingOpacity();\n cancelSvg();\n stopAnimation();\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth - 14;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 280,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n\n const buttonRef = React.useRef(null);\n const ringRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\n const ring = ringRef.current;\n const circle = circleRef.current;\n if (!button || !ring || !circle || disabled) return;\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const ringOpacity = mapValue(progress, [0, 0.01, 1], [0, 1, 1]);\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelRingOpacity = styleEffect(ring, { opacity: ringOpacity });\n const cancelSvg = svgEffect(circle, { pathLength: progress });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\n cancelPress();\n cancelStyle();\n cancelRingOpacity();\n cancelSvg();\n stopAnimation();\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth / 2 - 6;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" } ], "dependencies": [