Skip to content

Instantly share code, notes, and snippets.

@azaek
Created November 19, 2024 12:27
Show Gist options
  • Select an option

  • Save azaek/4e02b3661d94c5dcd23b86e006020d6a to your computer and use it in GitHub Desktop.

Select an option

Save azaek/4e02b3661d94c5dcd23b86e006020d6a to your computer and use it in GitHub Desktop.
A button which triggers on hold to confirm action
import { Delete01Icon } from 'hugeicons-react';
import { cn } from '@app/ui/cn';
import { PointerEvent, useRef, useState } from 'react';
import { animate, motion, useMotionValue, useTransform } from 'framer-motion';
const LongPressButton = ({
text: textFromProps,
confirmTimeout = 2,
onConfirm
}: {
text: string;
confirmTimeout?: number;
onConfirm?: () => void;
}) => {
const [state, setState] = useState<'idle' | 'inProgress' | 'complete'>('idle');
const ref = useRef<HTMLButtonElement>(null);
const progress = useMotionValue(0);
const fillRightOffset = useTransform(progress, (v) => `${(1 - v) * 100}%`);
const startCountdown = () => {
setState('inProgress');
animate(progress, 1, { duration: confirmTimeout, ease: 'linear' }).then(() => {
if (progress.get() !== 1) return; // Animation cancelled
setState('complete');
});
};
const cancelCountdown = () => {
progress.stop()
setState('idle');
animate(progress, 0, { duration: 0.2, ease: 'linear' });
};
const pointerUp = (e: PointerEvent<HTMLButtonElement>) => {
const target = document.elementFromPoint(e.clientX, e.clientY);
if (progress.get() === 1 && ref.current?.contains(target)) {
progress.jump(1);
setState('complete');
onConfirm?.();
} else {
cancelCountdown();
}
};
const pointerMove = (e: PointerEvent<HTMLButtonElement>) => {
// Mouse will be handled by onPointerLeave
if (e.pointerType === 'mouse') return;
const target = document.elementFromPoint(e.clientX, e.clientY);
if (!ref.current?.contains(target)) {
cancelCountdown();
}
};
const text = state === 'idle'
? textFromProps
: state === 'inProgress'
? 'Hold to confirm'
: 'Release to delete';
return (
<motion.button className={cn(
"relative",
"flex items-center min-h-[34px] outline-none border-none w-full justify-start group/btn space-x-1",
)}
ref={ref}
onPointerDown={startCountdown}
onPointerUp={pointerUp}
onPointerCancel={cancelCountdown}
onPointerLeave={(e) => {
// For touchscreen browser always generates PointerLeave at
// the end of touch, even if it ended on the element, so
// we handle only mouse leave here
if (e.pointerType === 'mouse') cancelCountdown();
}}
onPointerMove={pointerMove}
onContextMenuCapture={(e) => e.preventDefault()}
>
<motion.div
style={{
right: fillRightOffset,
}}
className='flex absolute inset-y-0 left-0 right-[100%] bg-white z-[0] pointer-events-none rounded mix-blend-exclusion' />
<motion.div className={cn(
"flex gap-2 px-2",
"text-secondary")}>
<Delete01Icon size={16} />
<p className={cn(
"text-[14px] text-start leading-[16px] font-primary font-medium"
)}>{text}</p>
</motion.div>
</motion.button>
);
}
export default LongPressButton;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment