diff --git a/components/evil-buttons/hold-confirm-button.tsx b/components/evil-buttons/hold-confirm-button.tsx new file mode 100644 index 0000000..eb6390d --- /dev/null +++ b/components/evil-buttons/hold-confirm-button.tsx @@ -0,0 +1,238 @@ +"use client"; + +import * as React from "react"; +import { + animate, + mapValue, + motionValue, + press, + springValue, + 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 = 280, + ringStrokeWidth = 12, + ringColor = "#5eead4", + onConfirm, + onAbort, + resetAfter = 1600, + className, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const [state, setState] = React.useState("idle"); + + 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"); + 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 ring = ringRef.current; + const circle = circleRef.current; + if (!button || !ring || !circle || disabled) return; + + const progress = motionValue(0); + const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), { + stiffness: 420, + damping: 28, + 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 = () => { + animationRef.current?.stop(); + animationRef.current = null; + }; + + const rewind = () => { + stopAnimation(); + animate(progress, 0, { type: "spring", stiffness: 520, damping: 32 }); + }; + + 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(); + cancelRingOpacity(); + cancelSvg(); + stopAnimation(); + if (resetTimeoutRef.current !== null) { + window.clearTimeout(resetTimeoutRef.current); + } + }; + }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]); + + const radius = ringSize / 2 - ringStrokeWidth / 2 - 6; + 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 67bad14..539194d 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,12 @@ 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 eaef922..7e106fe 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 0000000..347c354 --- /dev/null +++ b/content/docs/hold-confirm-button.mdx @@ -0,0 +1,67 @@ +--- +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 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 + +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` | `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%. | +| `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 0000000..ff523cb --- /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\";\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": [ + "clsx", + "tailwind-merge", + "motion" + ] +} diff --git a/public/r/index.json b/public/r/index.json index 0b3c4dd..63c5786 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 a389831..8c35263 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"] } ]