Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions components/evil-buttons/hold-confirm-button.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>,
"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<HoldConfirmState>("idle");

const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const ringRef = React.useRef<SVGSVGElement | null>(null);
const circleRef = React.useRef<SVGCircleElement | null>(null);
const animationRef = React.useRef<ReturnType<typeof animate> | null>(null);
const stateRef = React.useRef<HoldConfirmState>("idle");
const resetTimeoutRef = React.useRef<number | null>(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 (
<div
className="relative inline-flex items-center justify-center"
style={{ width: ringSize, height: ringSize }}
>
<svg
ref={ringRef}
className="pointer-events-none absolute inset-0 -rotate-90"
width={ringSize}
height={ringSize}
viewBox={`0 0 ${ringSize} ${ringSize}`}
aria-hidden
>
<circle
ref={circleRef}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={ringColor}
strokeWidth={ringStrokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
style={{
filter: `drop-shadow(0 0 6px ${ringColor})`,
}}
/>
</svg>

<button
ref={setButtonRef}
type={type}
disabled={disabled}
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",
"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,
)}
{...props}
>
{displayLabel}
</button>
</div>
);
},
);

HoldConfirmButton.displayName = "HoldConfirmButton";

export default HoldConfirmButton;
7 changes: 7 additions & 0 deletions components/landing/landing-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -238,6 +239,12 @@ const showcase: ButtonShowcase[] = [
/>
),
},
{
name: "HoldConfirmButton",
href: "/docs/hold-confirm-button",
registryName: "hold-confirm-button",
render: () => <HoldConfirmButton />,
},
];

function ButtonCell({ item }: { item: ButtonShowcase }) {
Expand Down
2 changes: 2 additions & 0 deletions components/mdx-custom-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -127,5 +128,6 @@ export function getCustomMDXComponents(): MDXComponents {
MorphStatusButtonFailDemo,
CooldownButton,
PillButton,
HoldConfirmButton,
};
}
67 changes: 67 additions & 0 deletions content/docs/hold-confirm-button.mdx
Original file line number Diff line number Diff line change
@@ -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

<PreviewCard title="HoldConfirmButton" note="Press and hold">
<HoldConfirmButton />
</PreviewCard>

### Fast hold

<PreviewCard title="Fast hold" note="Hold for 1.2s">
<HoldConfirmButton duration={1200} label="Hold to deploy" />
</PreviewCard>

## Install

Add the item with the shadcn CLI.

<Cmd>@evilbuttons/hold-confirm-button</Cmd>

## Usage

```tsx
import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button";

export function ButtonDemo() {
return (
<HoldConfirmButton
label="Hold to confirm"
duration={2000}
onConfirm={() => 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.
20 changes: 20 additions & 0 deletions public/r/hold-confirm-button.json
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>,\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<HoldConfirmState>(\"idle\");\n\n const buttonRef = React.useRef<HTMLButtonElement | null>(null);\n const ringRef = React.useRef<SVGSVGElement | null>(null);\n const circleRef = React.useRef<SVGCircleElement | null>(null);\r\n const animationRef = React.useRef<ReturnType<typeof animate> | null>(null);\r\n const stateRef = React.useRef<HoldConfirmState>(\"idle\");\r\n const resetTimeoutRef = React.useRef<number | null>(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 <div\r\n className=\"relative inline-flex items-center justify-center\"\r\n style={{ width: ringSize, height: ringSize }}\r\n >\r\n <svg\n ref={ringRef}\n className=\"pointer-events-none absolute inset-0 -rotate-90\"\n width={ringSize}\n height={ringSize}\n viewBox={`0 0 ${ringSize} ${ringSize}`}\n aria-hidden\n >\n <circle\n ref={circleRef}\n cx={center}\n cy={center}\n r={radius}\n fill=\"none\"\n stroke={ringColor}\n strokeWidth={ringStrokeWidth}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n style={{\n filter: `drop-shadow(0 0 6px ${ringColor})`,\n }}\n />\n </svg>\n\r\n <button\r\n ref={setButtonRef}\r\n type={type}\r\n disabled={disabled}\r\n aria-live=\"polite\"\r\n data-state={state}\r\n className={cn(\r\n \"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\",\r\n \"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\",\r\n isSuccess && \"bg-emerald-50 text-emerald-700\",\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {displayLabel}\r\n </button>\r\n </div>\r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;"
}
],
"dependencies": [
"clsx",
"tailwind-merge",
"motion"
]
}
9 changes: 9 additions & 0 deletions public/r/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
Loading
Loading