Rotaptcha
Human-friendly verification for modern forms
A CAPTCHA that feels easy and doesn't infriinge privacy.
Rotaptcha swaps sterile checkbox patterns for a tactile rotation challenge that blends into your interface while keeping the verification loop clear, secure, and easy to theme.
3-step
challenge flow that feels familiar and fast to complete
Typed
React props for theming, callbacks, and difficulty control
API-first
works with your own create and verify endpoints
Live demo
Verification without the visual downgrade.
The widget stays compact, offers immediate feedback, and can live naturally inside signup, contact, or checkout flows instead of feeling like a detached add-on.
Interactive challenge
Try the live component with the current API routes.
Configurable
Tune angles, retries, and expiry per form.
Callback aware
Wire success, failure, and error handling into your submission flow.
Polished UX
Looks like product UI, not an interruption.
Warm surfaces, clear feedback, and a compact interaction keep verification from feeling bolted on.
Adjustable Security
Tune rotation, expiry, and retry behavior.
Use the config surface to balance completion speed and difficulty for each form or funnel step.
Framework Friendly
The widget is just one integration layer.
Rotaptcha stays language agnostic underneath, so your backend contract can stay consistent across stacks.
Why it lands better
Security that keeps your first impression intact.
Instead of throwing users into a generic third-party wall, Rotaptcha lets you keep the visual language, motion, and tone of your interface while preserving a familiar verification loop.
Bring your own API routes for challenge creation and verification.
Theme the widget so it matches the rest of the form surface.
Capture success, failure, and error events with callbacks you already need.
Adjust angle range, expiry, and regeneration behavior without rewriting the component.
Create
Generate a rotated image challenge from your own backend whenever the user opens the verifier.
Align
Let users solve the rotation with a simple tactile control that translates well across screen sizes.
Verify
Post the selected angle, resolve success or failure, and regenerate only when your policy requires it.
Integration
Drop it into a Next.js flow in minutes.
The widget surface is intentionally small: point it at your create and verify endpoints, adjust the challenge config, and wire the callbacks you already need for form state.
What ships in the demo
Endpoints
2 routes
Hooks
3 callbacks
Theming
Custom colors
Config
Angle + expiry
Code preview
Choose the snippet you want to start from.
'use client';
import { useState, useRef, useEffect } from 'react';
export interface CaptchaConfig {
width?: number;
height?: number;
minValue?: number;
maxValue?: number;
step?: number;
wobbleIntensity?: number;
noise?: boolean;
strokeWidth?: number;
canvasBg?: string;
noiseDensity?: number;
expiryTime?: number;
}
interface RotaptchaWidgetProps {
createUrl: string;
verifyUrl: string;
config?: CaptchaConfig;
autoRegenerate?: boolean;
onVerifySuccess?: (result: VerifyResult) => void;
onVerifyFailure?: (result: VerifyResult) => void;
onError?: (error: Error) => void;
theme?: {
primary?: string;
primaryHover?: string;
secondary?: string;
background?: string;
};
}
interface VerifyResult {
success: boolean;
message?: string;
}
export default function RotaptchaWidget({
createUrl,
verifyUrl,
config = {},
autoRegenerate = true,
onVerifySuccess,
onVerifyFailure,
onError,
theme = {},
}: RotaptchaWidgetProps) {
const [captchaImage, setCaptchaImage] = useState<string | null>(null);
const [captchaId, setCaptchaId] = useState<string | null>(null);
const [sliderValue, setSliderValue] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const [showPopover, setShowPopover] = useState(false);
const [failedAttempts, setFailedAttempts] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const {
primary = '#4285f4',
primaryHover = '#3367d6',
background = '#f8f9fa',
} = theme;
const defaultConfig: CaptchaConfig = {
width: 300,
height: 300,
minValue: 20,
maxValue: 90,
step: 10,
wobbleIntensity: 2,
noise: true,
strokeWidth: 2,
canvasBg: '#FFFFFF',
noiseDensity: 3,
expiryTime: 300000,
};
const mergedConfig = { ...defaultConfig, ...config };
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
const checkbox = document.getElementById('rotaptcha-checkbox');
if (checkbox && !checkbox.contains(event.target as Node)) {
setShowPopover(false);
}
}
};
if (showPopover) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showPopover]);
const fetchCaptcha = async () => {
setIsLoading(true);
try {
const response = await fetch(createUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mergedConfig),
});
if (!response.ok) throw new Error('Failed to generate captcha');
const data = await response.json();
setCaptchaImage(data.captcha);
setCaptchaId(data.id);
setSliderValue(0);
} catch (error) {
onError?.(error instanceof Error ? error : new Error('Unknown error'));
} finally {
setIsLoading(false);
}
};
const verifyCaptcha = async () => {
if (!captchaId) return;
setIsLoading(true);
try {
const response = await fetch(verifyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: captchaId, value: sliderValue }),
});
const result: VerifyResult = await response.json();
if (result.success) {
setIsVerified(true);
setShowPopover(false);
setFailedAttempts(0);
onVerifySuccess?.(result);
} else {
setFailedAttempts((prev) => prev + 1);
onVerifyFailure?.(result);
if (autoRegenerate && failedAttempts >= 1) {
fetchCaptcha();
}
}
} catch (error) {
onError?.(error instanceof Error ? error : new Error('Verification failed'));
} finally {
setIsLoading(false);
}
};
const handleCheckboxClick = () => {
if (isVerified) return;
setShowPopover(true);
fetchCaptcha();
};
const handleReset = () => {
setIsVerified(false);
setCaptchaImage(null);
setCaptchaId(null);
setSliderValue(0);
setFailedAttempts(0);
};
return (
<div className="relative inline-block">
{/* Checkbox UI */}
<div
id="rotaptcha-checkbox"
onClick={handleCheckboxClick}
className="flex items-center gap-3 px-4 py-3 bg-white border border-gray-300 rounded-md shadow-sm cursor-pointer hover:bg-gray-50 transition-colors select-none"
style={{ minWidth: '280px' }}
>
<div
className="w-6 h-6 border-2 rounded flex items-center justify-center transition-all"
style={{
borderColor: isVerified ? '#22c55e' : '#d1d5db',
backgroundColor: isVerified ? '#22c55e' : 'transparent',
}}
>
{isVerified && (
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-gray-700 text-sm font-medium flex-1">I'm a Human</span>
<div className="flex flex-col items-end">
<span className="text-xs font-bold text-gray-500">rotaptcha</span>
</div>
{isVerified && (
<button
onClick={(e) => {
e.stopPropagation();
handleReset();
}}
className="ml-1 text-gray-400 hover:text-gray-600 text-xs"
title="Reset"
>
×
</button>
)}
</div>
{/* Popover */}
{showPopover && !isVerified && (
<div
ref={popoverRef}
className="absolute top-full left-0 mt-2 bg-white border border-gray-200 rounded-lg shadow-xl z-50"
style={{ minWidth: '320px' }}
>
{/* Close button */}
<button
onClick={() => setShowPopover(false)}
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600 z-10"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="p-4">
{/* Captcha Image */}
<div
className="relative rounded-lg overflow-hidden mb-4 flex items-center justify-center"
style={{
backgroundColor: background,
minHeight: mergedConfig.height,
}}
>
{isLoading || !captchaImage ? (
<div className="flex items-center justify-center" style={{ height: mergedConfig.height }}>
<div className="w-8 h-8 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
</div>
) : (
<img
src={captchaImage}
alt="Captcha"
className="max-w-full"
style={{
transform: `rotate(${sliderValue}deg)`,
transition: 'transform 0.1s ease-out',
}}
draggable={false}
/>
)}
</div>
{/* Slider */}
<div className="mb-4">
<input
type="range"
min={-(mergedConfig.maxValue || 90)}
max={mergedConfig.maxValue || 90}
step={mergedConfig.step || 10}
value={sliderValue}
onChange={(e) => setSliderValue(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
disabled={isLoading || !captchaImage}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={verifyCaptcha}
disabled={isLoading || !captchaImage}
className="flex-1 py-2 px-3 text-white text-sm font-medium rounded transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
style={{
backgroundColor: primary,
}}
onMouseOver={(e) => (e.currentTarget.style.backgroundColor = primaryHover)}
onMouseOut={(e) => (e.currentTarget.style.backgroundColor = primary)}
>
{isLoading ? 'Verifying...' : 'Verify'}
</button>
<button
onClick={fetchCaptcha}
disabled={isLoading}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors cursor-pointer disabled:opacity-50"
title="Refresh"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
</div>
)}
</div>
);
}