Skip to content

Instantly share code, notes, and snippets.

@ArrayIterator
Last active April 25, 2025 18:15
Show Gist options
  • Save ArrayIterator/beb21f398546a20f74d21fe62bf5469d to your computer and use it in GitHub Desktop.
Save ArrayIterator/beb21f398546a20f74d21fe62bf5469d to your computer and use it in GitHub Desktop.
React + Tailwind -> Puzle Captcha Image - Based on Sliding Verification & Tolerance
import React, { ReactNode, useEffect, useRef, useState } from 'react';
export type PlacementResultProperties = {
valid: boolean;
tolerance: number;
puzzle: {
x: number;
y: number;
},
piece: {
x: number;
y: number;
},
};
export type SlideCaptchaProperties = {
/**
* Callback when a result succeeds
* @param {Readonly<PlacementResultProperties>} result
*/
onSuccess: (result: Readonly<PlacementResultProperties>) => void;
/**
* Callback when a result failed
* @param {Readonly<PlacementResultProperties>} result
*/
onFail?: (result: Readonly<PlacementResultProperties>) => void;
/**
* Callback when button sliding
* @param {Readonly<PlacementResultProperties>} result
*/
onSlide?: (result: Readonly<PlacementResultProperties>) => void;
/**
* onStart callback, when the event started drag
* @param {React.MouseEvent<HTMLDivElement>|React.TouchEvent<HTMLDivElement>} e m
*/
onStart?: (e: React.MouseEvent<HTMLDivElement>) => void;
/**
* The image url (optional) - better to leave empty
* Default used: https://picsum.photos/${width}/${height}?_=${Math.random()
*/
imageUrl?: string;
/**
* The tolerance between x position and canvas
* default: 4, allowed is: 2-20
*/
tolerance?: number;
/**
* ReactNode will shown when succeed
*/
children?: ReactNode;
}
type CanvasResultProperties<T extends HTMLCanvasElement = HTMLCanvasElement> = {
percent: number;
canvas: T;
piece: HTMLCanvasElement,
pieceSize: number;
lineWidth: number;
strokeColor: string;
fillColor: string;
startX: number;
endX: number;
pieceX: number;
pieceY: number;
x: number;
y: number
}
const redraw = <T extends HTMLCanvasElement = HTMLCanvasElement>(
canvas: T,
piece: HTMLCanvasElement,
offsetStartX: number,
offsetEndX: number,
imageURL?: string
): Promise<CanvasResultProperties<T> | null> => {
const computed = window.getComputedStyle(canvas.parentNode as HTMLDivElement);
const width = Number(computed.width.replace('px', ''));
const height = Number(computed.height.replace('px', ''));
const image = imageURL || `https://picsum.photos/${width}/${height}?_=${Math.random()}`;
return new Promise((resolve: (value: null | CanvasResultProperties<T>) => void, reject: (reason: Error) => void) => {
const canvasCtx = canvas.getContext('2d', { willReadFrequently: true });
const pieceCtx = piece.getContext('2d', { willReadFrequently: true });
if (!canvasCtx || !pieceCtx) {
resolve(null);
return;
}
const img = new Image();
img.onerror = () => reject(new Error('Unable to load image'));
img.crossOrigin = 'anonymous'; // Enable CORS
img.onload = () => {
// Set canvas dimensions
canvas.width = width;
canvas.height = height;
// Puzzle piece dimensions
const toSubtract = width > height ? height : width;
// range from 3.2 to 4
const start = 3.2;
const end = 4;
const subTractRange = Math.round(Math.random() * (end - start) + start);
const pieceSize = toSubtract / subTractRange;
let pieceX = Math.random() * (offsetEndX - offsetStartX - pieceSize) + offsetStartX;
if (pieceX + pieceSize > offsetEndX) {
pieceX = offsetEndX - pieceSize;
}
if (pieceX <= (offsetStartX + pieceSize)) {
pieceX = offsetStartX + pieceSize + 10;
}
const pieceY = Math.random() * (height - pieceSize * 2) + pieceSize;
// const commonImageColor = getMostCommonColor(img);
// let color = {
// r: 255, g: 255, b: 255
// };
// if (commonImageColor) {
// color = getColorContrast(commonImageColor.r, commonImageColor.g, commonImageColor.b);
// }
// const { r, g, b } = color;
// const baseColor = `${r}, ${g}, ${b}`;
const lineWidth = 2;
// const strokeColor = `rgba(${baseColor}, 1)`;
// const fillColor = `rgba(${baseColor}, 0.9)`;
const strokeColor = `rgba(255, 255, 255, 0.7)`;
const fillColor = `rgba(255, 255, 255, 0.7)`;
piece.height = height;
piece.width = width;
const drawPath = (
{
ctx,
x,
y,
operation
}: {
ctx: CanvasRenderingContext2D,
x: number,
y: number,
operation: 'fill' | 'clip'
}) => {
const l = pieceSize; // Length of the square side
const r = pieceSize / 4; // Radius for arcs
const PI = Math.PI;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI); // Top arc
ctx.lineTo(x + l, y);
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI); // Right arc
ctx.lineTo(x + l, y + l);
ctx.lineTo(x, y + l);
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true); // Left arc
ctx.lineTo(x, y);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = strokeColor;
ctx.fillStyle = fillColor;
ctx.stroke();
ctx.globalCompositeOperation = 'destination-over';
if (operation === 'fill') {
ctx.fill();
} else {
ctx.clip(); // Apply clipping
}
};
drawPath({
ctx: canvasCtx,
x: pieceX,
y: pieceY,
operation: 'fill'
});
drawPath({
ctx: pieceCtx,
x: pieceX,
y: pieceY,
operation: 'clip'
});
canvasCtx.drawImage(img, 0, 0, width, height);
pieceCtx.drawImage(img, 0, 0, width, height);
const y1 = pieceY - pieceSize / 2 - offsetStartX;
const sw = pieceSize * 2;
const ImageData = pieceCtx.getImageData(pieceX, y1, sw, height);
piece.width = sw;
pieceCtx.putImageData(ImageData, offsetStartX / 2, y1);
pieceCtx.save();
resolve({
canvas: canvas as T,
piece,
fillColor,
strokeColor,
lineWidth,
startX: offsetStartX,
endX: offsetEndX,
percent: 0,
x: 0,
y: pieceY,
pieceX,
pieceY,
pieceSize
});
};
img.src = image;
});
};
export function SlideCaptcha(props: SlideCaptchaProperties) {
const { imageUrl, onSuccess, onFail, onSlide, onStart, children } = props;
const tolerance = typeof props.tolerance === 'number' ? (Math.min(20, Math.max(props.tolerance, 2))) : 4;
// ref
const refFill = useRef<HTMLCanvasElement>(null);
const refClip = useRef<HTMLCanvasElement>(null);
const refSlider = useRef<HTMLDivElement>(null);
// states
const [error, setError] = useState<Error | null>(null);
const [drawableCanvas, setDrawableCanvas] = useState<CanvasResultProperties | null>(null);
const [offsetXStartEnd, setOffsetXStartEnd] = useState<{ startX: number; endX: number; } | null>(null);
const [validated, setValidated] = useState<boolean>(false);
const [placements, setPlacements] = useState<PlacementResultProperties>({
valid: false,
tolerance,
puzzle: {
x: 0,
y: 0
},
piece: {
x: 0,
y: 0
}
});
const { valid } = placements;
useEffect(() => {
if (!refFill.current || !refSlider.current) {
return;
}
const canvasWidth = refFill.current.offsetWidth;
const parent = refSlider.current.parentNode as HTMLDivElement;
const parentWidth = parent.offsetWidth;
const currentWidth = refSlider.current.offsetWidth;
const maxLeft = parentWidth - currentWidth;
// calculate minXon canvas and maxX on canvas
const minX = (canvasWidth - maxLeft) / 2;
const maxX = canvasWidth - minX;
setOffsetXStartEnd({ startX: minX, endX: maxX });
}, [refSlider, refFill]);
useEffect(() => {
if (!refFill.current
|| !refClip.current
|| drawableCanvas
|| !offsetXStartEnd
|| validated
|| error
) {
return;
}
const ref = refSlider.current;
if (ref) {
const timer = 200;
ref.style.transition = `transform ease-in-out ${timer}ms`;
ref.style.transform = 'translateX(0)';
setTimeout(() => ref.style.removeProperty('transition'), timer + 5);
}
setValidated(false);
setPlacements({
valid: false,
tolerance,
puzzle: {
x: 0,
y: 0
},
piece: {
x: 0,
y: 0
}
});
const clip = refClip.current;
const fill = refFill.current;
redraw(fill, clip, offsetXStartEnd['startX'], offsetXStartEnd['endX'], imageUrl)
.then((res) => {
setDrawableCanvas(res);
})
.catch((reason: Error) => {
// setError
setDrawableCanvas(null);
setValidated(false);
setError(reason);
})
.finally(() => {
// refSlider.current!.style.transition = 'transform linear .2s';
});
}, [refFill, imageUrl, drawableCanvas, offsetXStartEnd, tolerance, validated, error]);
useEffect(() => {
if (!drawableCanvas || !refClip.current) {
return;
}
const { x, y, startX, pieceX } = drawableCanvas;
const currentRealX = pieceX - startX / 2;
refClip.current.style.transform = `translateX(${x}px)`;
const valid = x > currentRealX ? (
x - currentRealX < tolerance
) : (
currentRealX - x < tolerance
);
const result: PlacementResultProperties = {
valid,
tolerance: tolerance,
puzzle: {
x: x,
y: y
},
piece: {
x: currentRealX,
y: y
}
};
setPlacements(result);
if (onSlide) {
onSlide(result);
}
}, [drawableCanvas, onSlide, tolerance]);
useEffect(() => {
if (!validated) {
return;
}
const { valid } = placements;
if (valid) {
if (onSuccess) {
onSuccess(placements);
}
} else {
if (onFail) {
onFail(placements);
}
}
}, [validated, placements, onSuccess, onFail]);
if (validated && valid) {
return children;
}
return (
<div
className="captcha swipe-captcha flex flex-col relative rounded-sm shadow-sm overflow-hidden bg-neutral-200 dark:bg-gray-500">
<div className={'relative flex flex-col max-w-full'}>
<div className="relative flex flex-col w-96 h-44 max-w-full">
<div
className={'captcha-svg-image items-center justify-center relative w-full h-36 flex flex-col p-0 m-0 basis-auto grow shrink overflow-hidden group' +
''}>
<canvas
data-canvas={'fill'}
ref={refFill}
className={'w-full h-full z-20' + (!drawableCanvas ? 'invisible' : '')}
/>
<canvas
data-canvas={'clip'}
ref={refClip}
className={'absolute h-full left-0 z-30' + (!drawableCanvas ? 'invisible' : '')}
/>
{!drawableCanvas ? (
<div
className={'z-40 items-center justify-center absolute t-0 l-0 w-full h-full bg-neutral-200 dark:bg-gray-500'}>
{!error
? <div
className={'absolute animate-spin w-10 h-10 rounded-full border-3 border-gray-500 border-t-transparent dark:border-neutral-200 dark:border-t-transparent border-opacity-25 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2'} />
: (
<div
className={'flex flex-col h-full justify-center items-center text-center text-sm'}>
<p>{error.message}</p>
<p>Click <span
className={'cursor-pointer font-bold'}
onClick={() => {
setDrawableCanvas(null);
setValidated(false);
setError(null);
}}
>Here</span> to retry</p>
</div>
)
}
</div>
) : (!validated || !valid ? (
<div
onClick={() => {
setDrawableCanvas(null);
setValidated(false);
}}
className={'captcha-refresh z-40 absolute top-2 right-2 bg-neutral-200 dark:bg-gray-500 opacity-10 rounded-full p-1 cursor-pointer flex items-center justify-center hover:opacity-100 transition-opacity duration-200 group-hover:opacity-100'}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="w-4 h-4"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
</div>
) : null)}
</div>
<div
className="captcha__slider flex flex-col justify-center relative basis-auto h-8 m-2 rounded-full bg-neutral-300 dark:bg-gray-600">
<div className="captcha__slider__bar w-full h-8 flex items-center">
<div className="captcha__slider_bar_content w-full h-full flex items-center mx-1 p-0">
<div
ref={refSlider}
className={'captcha__slider__handle rounded-full w-6 h-6 bg-gray-500 dark:bg-neutral-200 relative '
+ (!drawableCanvas ? 'cursor-wait pointer-events-none touch-none ' : (
valid ? ('bg-green-500' + (validated ? ' cursor-default' : ' cursor-grab')) : (
validated && !valid ? 'bg-red-500 cursor-not-allowed' : 'cursor-grab'
)
))}
onMouseDown={(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
if (validated) {
return;
}
const target = e.currentTarget as HTMLDivElement;
if (!drawableCanvas) {
target.style.transform = `translateX(0)`;
return;
}
if (onStart) {
onStart(e);
}
const parent = target.parentNode as HTMLDivElement;
const xParent = parent.getBoundingClientRect().left;
const onMouseMove = (moveEvent: MouseEvent) => {
target.setAttribute('data-sliding', 'true');
target.style.removeProperty('transition');
const parentWidth = parent.offsetWidth;
const currentWidth = target.offsetWidth;
const maxLeft = parentWidth - currentWidth;
const x = Math.min(
maxLeft,
Math.max(moveEvent.clientX - xParent - currentWidth / 2, 0)
);
target.style.transform = `translateX(${x}px)`;
const percent = (x / maxLeft) * 100;
setDrawableCanvas({ ...drawableCanvas, percent, x });
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
target.removeAttribute('data-sliding');
setValidated(true);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}}
onTouchStart={(e) => {
e.stopPropagation();
const target = e.currentTarget as HTMLDivElement;
if (!drawableCanvas) {
target.style.transform = `translateX(0)`;
return;
}
const parent = target.parentNode as HTMLDivElement;
const xParent = parent.getBoundingClientRect().left;
const onTouchMove = (moveEvent: TouchEvent) => {
moveEvent.preventDefault();
moveEvent.stopPropagation();
target.setAttribute('data-sliding', 'true');
const touchMove = moveEvent.touches[0];
const parentWidth = parent.offsetWidth;
const currentWidth = target.offsetWidth;
const maxLeft = parentWidth - currentWidth;
const x = Math.min(
maxLeft,
Math.max(touchMove.clientX - xParent - currentWidth / 2, 0)
);
target.style.transform = `translateX(${x}px)`;
const percent = (x / maxLeft) * 100;
setDrawableCanvas({ ...drawableCanvas, percent, x });
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
target.removeAttribute('data-sliding');
setValidated(true);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd, { passive: false });
}}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment