craftui
uiotp-input-ripplesource: ok · refreshed
OTP Input - Ripple
Each box wears an SVG outline that behaves like a liquid surface — typing nudges it, moving to the next box hands the ripple off toward its right edge. Pass a validate function and the component choreographs the pending ring, the staggered success wave and the red shake by itself, then calls your onSuccess / onFailure.
npx shadcn add "https://www.craftui.space/r/otp-input-ripple.json"free
Visual style
playful
Interaction
text-animation
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
https://www.craftui.space/r/craftui-base.json
Files2 · source cached 10/8/2026, 1:37:21 AM
components/craftui/ui/otp-input/ripple.tsx
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { RippleBorder, RippleBorderHandle } from "./ripple-border";
export interface RippleOtpHandle {
/** Drive the result animation manually when validating outside `validate`. */
notifyResult: (result: "valid" | "invalid") => void;
/** Clear digits, colors and focus the first box. */
reset: () => void;
}
export type CaretShape = "bar" | "block" | "underscore";
interface RippleOtpProps {
/** Number of boxes. */
length?: number;
/** Controlled value — pair with `onChange`. Omit for uncontrolled usage. */
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
/** Fires once every time all boxes become filled, before validation. */
onComplete?: (value: string) => void;
/**
* Called with the full code on completion. Return (or resolve) `true` for
* success, `false` for failure — the component runs the pending ring while
* a promise is in flight and choreographs the result animation itself.
*/
validate?: (value: string) => boolean | Promise<boolean>;
/** Fires after the success animation finishes. */
onSuccess?: (value: string) => void;
/** Fires after the failure animation finishes. */
onFailure?: (value: string) => void;
/** Clear digits and refocus the first box after a failure. */
autoClearOnFailure?: boolean;
/** Animate the border on every keystroke. Off by default — the ripple only plays for the pending / success / failure states. */
rippleOnType?: boolean;
/** Show a low-amplitude oscillating ring while an async validate is pending. Off by default — pending is indicated by border color only. */
pendingRing?: boolean;
/** Render digits as dots. */
masked?: boolean;
/** Which characters the boxes accept. */
mode?: "numeric" | "alphanumeric";
disabled?: boolean;
autoFocus?: boolean;
/** Success wave timing — delay between each box's green pulse, in ms. */
staggerMs?: number;
/** Corner radius in px, shared by the box and its ripple outline. */
radius?: number;
strokeWidth?: number;
/** Ripple height ceiling in px. */
maxAmplitude?: number;
/** How localized the ripple bulge is (fraction of the perimeter). */
bumpWidth?: number;
/** Ripple settle speed — higher is snappier. */
decay?: number;
/** Failure shake travel in px. */
shakeDistance?: number;
caretColor?: string;
caretShape?: CaretShape;
/** Accessible name for the group. */
label?: string;
className?: string;
}
const SHAKE_DURATION_MS = 280; // dur-a * 2 + dur-b * 2, keep in sync with ri-shake
const FAILURE_SETTLE_MS = 620;
const SUCCESS_SETTLE_MS = 500;
export const RippleOtp = forwardRef<RippleOtpHandle, RippleOtpProps>(
function RippleOtp(
{
length = 6,
value: valueProp,
defaultValue = "",
onChange,
onComplete,
validate,
onSuccess,
onFailure,
autoClearOnFailure = true,
rippleOnType = false,
pendingRing = false,
masked = false,
mode = "numeric",
disabled = false,
autoFocus = false,
staggerMs = 70,
radius = 14,
strokeWidth = 1.5,
maxAmplitude = 5,
bumpWidth = 0.1,
decay = 0.06,
shakeDistance = 6,
caretColor = "#3B82F6",
caretShape = "bar",
label = "One-time password",
className = "",
},
ref,
) {
const lineRefs = useRef<(RippleBorderHandle | null)[]>([]);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = valueProp !== undefined;
const value = isControlled ? valueProp : internalValue;
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [status, setStatus] = useState<
"idle" | "pending" | "success" | "failure"
>("idle");
const [announcement, setAnnouncement] = useState("");
const statusRef = useRef(status);
useEffect(() => {
statusRef.current = status;
}, [status]);
// Guards the complete → validate flow from firing twice for the same code
// (6th keystroke + Enter, or a paste that re-fills the same value).
const lastCompletedCode = useRef<string | null>(null);
const digits = Array.from({ length }, (_, i) => value[i] ?? "");
const locked = disabled || status === "pending";
const filterChars = useCallback(
(raw: string) =>
mode === "numeric"
? raw.replace(/[^0-9]/g, "")
: raw.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(),
[mode],
);
const clearTimers = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
};
const after = (ms: number, fn: () => void) => {
timers.current.push(setTimeout(fn, ms));
};
useEffect(() => clearTimers, []);
const setValue = (next: string) => {
if (!isControlled) setInternalValue(next);
onChange?.(next);
};
const resetField = useCallback(() => {
clearTimers();
lineRefs.current.forEach((line) => line?.setColorState("idle"));
containerRef.current?.classList.remove("is-shaking");
lastCompletedCode.current = null;
setStatus("idle");
setAnnouncement("");
}, []);
const runSuccess = useCallback(
(code: string) => {
clearTimers();
setStatus("success");
setAnnouncement("Code accepted.");
lineRefs.current.forEach((line, i) => {
after(i * staggerMs, () => {
line?.setColorState("success");
line?.pulse({ from: 0, to: 1, magnitude: 9 });
});
});
after((length - 1) * staggerMs + SUCCESS_SETTLE_MS, () =>
onSuccess?.(code),
);
},
[length, staggerMs, onSuccess],
);
const runFailure = useCallback(
(code: string) => {
clearTimers();
setStatus("failure");
setAnnouncement("Code incorrect. Try again.");
lineRefs.current.forEach((line) => {
line?.setColorState("destructive");
line?.pulseDouble({ from: 0.5, to: 0.5, magnitude: 10 });
});
const field = containerRef.current;
if (field) {
field.classList.remove("is-shaking");
// Force a reflow so re-adding the class restarts the animation.
void field.offsetWidth;
field.classList.add("is-shaking");
after(SHAKE_DURATION_MS, () => field.classList.remove("is-shaking"));
}
after(FAILURE_SETTLE_MS, () => {
if (autoClearOnFailure) {
setValue("");
lastCompletedCode.current = null;
lineRefs.current.forEach((line) => line?.setColorState("idle"));
setStatus("idle");
inputRefs.current[0]?.focus();
}
onFailure?.(code);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[autoClearOnFailure, onFailure],
);
const handleComplete = useCallback(
(code: string) => {
if (statusRef.current === "pending") return;
if (lastCompletedCode.current === code) return;
lastCompletedCode.current = code;
onComplete?.(code);
if (!validate) return;
const outcome = validate(code);
if (typeof outcome === "boolean") {
if (outcome) runSuccess(code);
else runFailure(code);
return;
}
setStatus("pending");
setAnnouncement("Checking code…");
lineRefs.current.forEach((line) => {
line?.setColorState("pending");
if (pendingRing) line?.startIdle();
});
outcome
.then((ok) => {
lineRefs.current.forEach((line) => line?.stopIdle());
if (ok) runSuccess(code);
else runFailure(code);
})
.catch(() => {
lineRefs.current.forEach((line) => line?.stopIdle());
runFailure(code);
});
},
[onComplete, validate, pendingRing, runSuccess, runFailure],
);
useImperativeHandle(ref, () => ({
notifyResult: (result) => {
const code = digits.join("");
if (result === "valid") runSuccess(code);
else runFailure(code);
},
reset: () => {
resetField();
setValue("");
inputRefs.current[0]?.focus();
},
}));
/** Writes `chars` into the boxes starting at `index` — shared by typing, paste and SMS autofill. */
const fillFrom = (index: number, chars: string) => {
const next = digits.slice();
let cursor = index;
for (const ch of chars) {
if (cursor >= length) break;
next[cursor] = ch;
cursor++;
}
const joined = next.join("").slice(0, length);
setValue(joined);
const focusIndex = Math.min(cursor, length - 1);
inputRefs.current[focusIndex]?.focus();
if (joined.length === length && !next.includes("")) {
handleComplete(joined);
}
};
const handleChange = (
index: number,
e: React.ChangeEvent<HTMLInputElement>,
) => {
if (locked) return;
if (statusRef.current !== "idle") resetField();
const chars = filterChars(e.target.value);
// SMS autofill / paste lands as a multi-char change — distribute it.
if (chars.length > 1) {
fillFrom(index, chars);
return;
}
const digit = chars.slice(-1);
if (rippleOnType) {
lineRefs.current[index]?.setColorState("primary");
lineRefs.current[index]?.nudge(digit ? 3 : -3);
}
const next = digits.slice();
next[index] = digit;
const joined = next.join("").slice(0, length);
setValue(joined);
if (digit && index < length - 1) {
if (rippleOnType) {
// ripple travels toward the box's right edge, handing off to the next box
lineRefs.current[index]?.pulse({ from: 0.05, to: 0.5, magnitude: 5 });
}
inputRefs.current[index + 1]?.focus();
}
if (digit && !next.includes("")) handleComplete(joined);
};
const handlePaste = (
index: number,
e: React.ClipboardEvent<HTMLInputElement>,
) => {
e.preventDefault();
if (locked) return;
if (statusRef.current !== "idle") resetField();
const chars = filterChars(e.clipboardData.getData("text"));
if (!chars) return;
// A full-length code always fills from the first box, regardless of
// which box received the paste.
fillFrom(chars.length >= length ? 0 : index, chars);
};
const handleKeyDown = (
index: number,
e: React.KeyboardEvent<HTMLInputElement>,
) => {
if (locked) return;
if (e.key === "Backspace" && !digits[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
} else if (e.key === "ArrowLeft" && index > 0) {
e.preventDefault();
inputRefs.current[index - 1]?.focus();
} else if (e.key === "ArrowRight" && index < length - 1) {
e.preventDefault();
inputRefs.current[index + 1]?.focus();
} else if (e.key === "Home") {
e.preventDefault();
inputRefs.current[0]?.focus();
} else if (e.key === "End") {
e.preventDefault();
inputRefs.current[length - 1]?.focus();
} else if (e.key === "Enter") {
e.preventDefault();
const code = digits.join("");
if (code.length === length) handleComplete(code);
}
};
const showFakeCaret = caretShape !== "bar";
return (
<div
ref={containerRef}
role="group"
aria-label={`${label}, ${length} characters`}
className={`ri-field flex gap-2 ${className}`}
style={
{
"--shake-distance": `${shakeDistance}px`,
"--ri-caret-color": caretColor,
} as React.CSSProperties
}
>
{digits.map((digit, i) => (
<div key={i} className="relative w-11">
<input
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
inputMode={mode === "numeric" ? "numeric" : "text"}
autoComplete={i === 0 ? "one-time-code" : "off"}
autoFocus={autoFocus && i === 0}
disabled={disabled}
aria-label={`Character ${i + 1} of ${length}`}
aria-invalid={status === "failure" || undefined}
value={masked && digit ? "•" : digit}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={(e) => handlePaste(i, e)}
onFocus={(e) => {
setFocusedIndex(i);
e.target.select();
}}
onBlur={() => setFocusedIndex((f) => (f === i ? null : f))}
className={[
"bg-input/30 w-11 border border-transparent px-0 py-2.5 text-center",
"text-foreground text-lg font-medium outline-none",
"transition-shadow duration-150",
"focus-visible:ring-ring/50 focus-visible:ring-[1px]",
"disabled:opacity-50",
].join(" ")}
style={{
borderRadius: radius,
caretColor: showFakeCaret ? "transparent" : caretColor,
}}
/>
{showFakeCaret && focusedIndex === i && (
<span
// Remounts when the digit changes so the blink restarts solid,
// the way a native caret does.
key={`caret-${digit}`}
aria-hidden="true"
className={`ri-caret ri-caret-${caretShape}`}
style={
caretShape === "block"
? { borderRadius: radius - 4 }
: undefined
}
/>
)}
<RippleBorder
ref={(el) => {
lineRefs.current[i] = el;
}}
radius={radius}
strokeWidth={strokeWidth}
maxAmplitude={maxAmplitude}
bumpWidth={bumpWidth}
decay={decay}
/>
</div>
))}
<span aria-live="polite" className="sr-only">
{announcement}
</span>
</div>
);
},
);
components/craftui/ui/otp-input/ripple-border.tsx
"use client";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { useReducedMotion } from "motion/react";
export type RippleColorState =
| "idle"
| "primary"
| "success"
| "destructive"
| "pending";
export interface RippleBorderHandle {
/** Fire a scripted, decaying ripple that travels around the box's own outline. `from`/`to` are 0..1 fractions of the perimeter. */
pulse: (opts?: { from?: number; to?: number; magnitude?: number }) => void;
/** Two ripples in quick succession — a sharper "wrong answer" bounce, held in place. */
pulseDouble: (opts?: {
from?: number;
to?: number;
magnitude?: number;
}) => void;
/** Live per-keystroke nudge — accumulates into the current envelope. */
nudge: (dy: number) => void;
/** Non-decaying low-amplitude ring, for an async "checking…" state. */
startIdle: (magnitude?: number) => void;
/** Lets the idle ring decay back to flat. */
stopIdle: () => void;
setColorState: (state: RippleColorState) => void;
}
interface RippleBorderProps {
className?: string;
/** Corner radius in px — should match the input's own rounded-* class. */
radius?: number;
/** Border stroke width in px. */
strokeWidth?: number;
/** Ripple height ceiling in px — pulses and nudges clamp to this. */
maxAmplitude?: number;
/** How localized the bulge is, as a fraction of the perimeter (0.05 tight … 0.25 wide). */
bumpWidth?: number;
/** Envelope decay per frame (0.02 lingering … 0.2 snappy). */
decay?: number;
}
const SAMPLES = 96;
const IDLE_AMPLITUDE = 1.5;
const TIME_STEP = 0.4;
const DRIFT_RATE = 0.045;
const STOP_THRESHOLD = 0.03;
const DOUBLE_PULSE_GAP_MS = 90;
const DEFAULT_X = 0.12; // resting bulge position, along the top edge
const COLOR_VAR: Record<Exclude<RippleColorState, "idle">, string> = {
primary: "var(--primary)",
success: "var(--success, oklch(0.723 0.219 149.579))",
destructive: "var(--destructive)",
pending: "var(--muted-foreground)",
};
type Seg =
| {
kind: "line";
x0: number;
y0: number;
x1: number;
y1: number;
len: number;
nx: number;
ny: number;
}
| {
kind: "arc";
cx: number;
cy: number;
r: number;
a0: number;
a1: number;
len: number;
};
function buildPerimeter(w: number, h: number, radius: number) {
const r = Math.max(Math.min(radius, w / 2, h / 2), 0);
const straightW = Math.max(w - 2 * r, 0);
const straightH = Math.max(h - 2 * r, 0);
const cornerArc = (Math.PI / 2) * r;
const segs: Seg[] = [
{ kind: "line", x0: r, y0: 0, x1: w - r, y1: 0, len: straightW, nx: 0, ny: -1 },
{ kind: "arc", cx: w - r, cy: r, r, a0: -90, a1: 0, len: cornerArc },
{ kind: "line", x0: w, y0: r, x1: w, y1: h - r, len: straightH, nx: 1, ny: 0 },
{ kind: "arc", cx: w - r, cy: h - r, r, a0: 0, a1: 90, len: cornerArc },
{ kind: "line", x0: w - r, y0: h, x1: r, y1: h, len: straightW, nx: 0, ny: 1 },
{ kind: "arc", cx: r, cy: h - r, r, a0: 90, a1: 180, len: cornerArc },
{ kind: "line", x0: 0, y0: h - r, x1: 0, y1: r, len: straightH, nx: -1, ny: 0 },
{ kind: "arc", cx: r, cy: r, r, a0: 180, a1: 270, len: cornerArc },
];
const total = segs.reduce((sum, s) => sum + s.len, 0) || 1;
return { segs, total };
}
function pointAt(segs: Seg[], total: number, sFrac: number) {
let d = (((sFrac % 1) + 1) % 1) * total;
for (const seg of segs) {
if (d <= seg.len || seg === segs[segs.length - 1]) {
const t = seg.len === 0 ? 0 : d / seg.len;
if (seg.kind === "line") {
return {
x: seg.x0 + (seg.x1 - seg.x0) * t,
y: seg.y0 + (seg.y1 - seg.y0) * t,
nx: seg.nx,
ny: seg.ny,
};
}
const angle = ((seg.a0 + (seg.a1 - seg.a0) * t) * Math.PI) / 180;
return {
x: seg.cx + seg.r * Math.cos(angle),
y: seg.cy + seg.r * Math.sin(angle),
nx: Math.cos(angle),
ny: Math.sin(angle),
};
}
d -= seg.len;
}
const first = segs[0] as Extract<Seg, { kind: "line" }>;
return { x: first.x0, y: first.y0, nx: first.nx, ny: first.ny };
}
export const RippleBorder = forwardRef<RippleBorderHandle, RippleBorderProps>(
function RippleBorder(
{
className = "",
radius = 14,
strokeWidth = 1.5,
maxAmplitude = 5,
bumpWidth = 0.1,
decay = 0.06,
},
ref,
) {
const svgRef = useRef<SVGSVGElement>(null);
const pathRef = useRef<SVGPathElement>(null);
const geometry = useRef<{ segs: Seg[]; total: number } | null>(null);
// Tunables live in a ref so the rAF loop always reads the latest prop
// values without re-creating the loop on every render.
const cfg = useRef({ maxAmplitude, bumpWidth, decay });
useEffect(() => {
cfg.current = { maxAmplitude, bumpWidth, decay };
}, [maxAmplitude, bumpWidth, decay]);
const envelope = useRef(0);
const time = useRef(Math.PI / 2);
const xPos = useRef(DEFAULT_X);
const xTarget = useRef(DEFAULT_X);
const idleMode = useRef(false);
const colorState = useRef<RippleColorState>("idle");
const lastValue = useRef(0);
const rafId = useRef<number | null>(null);
const doubleTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const reducedMotion = useReducedMotion();
const lerp = (a: number, b: number, t: number) => a * (1 - t) + b * t;
const draw = (value: number) => {
const path = pathRef.current;
const geo = geometry.current;
if (!path || !geo) return;
lastValue.current = value;
const { maxAmplitude: maxAmp, bumpWidth: bw } = cfg.current;
let d = "";
for (let i = 0; i <= SAMPLES; i++) {
const s = i / SAMPLES;
let dist = Math.abs(s - xPos.current);
if (dist > 0.5) dist = 1 - dist;
const falloff = Math.exp(-(dist * dist) / (2 * bw * bw));
const pt = pointAt(geo.segs, geo.total, s);
const bulge = value * falloff;
const x = pt.x + pt.nx * bulge;
const y = pt.y + pt.ny * bulge;
d += `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)} `;
}
d += "Z";
path.setAttribute("d", d);
const magnitude = Math.min(Math.abs(value) / maxAmp, 1);
const baseOpacity = colorState.current === "idle" ? 0.35 : 0.55;
path.style.opacity = String(Math.min(baseOpacity + magnitude * 0.45, 1));
path.style.stroke =
colorState.current === "idle"
? "currentColor"
: COLOR_VAR[colorState.current];
};
const stopLoop = () => {
if (rafId.current !== null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
if (doubleTimeout.current !== null) {
clearTimeout(doubleTimeout.current);
doubleTimeout.current = null;
}
};
const decayLoop = () => {
const value = envelope.current * Math.sin(time.current);
if (!idleMode.current) {
envelope.current = lerp(envelope.current, 0, cfg.current.decay);
xPos.current = lerp(xPos.current, xTarget.current, DRIFT_RATE);
}
time.current += TIME_STEP;
draw(value);
if (idleMode.current || Math.abs(envelope.current) > STOP_THRESHOLD) {
rafId.current = requestAnimationFrame(decayLoop);
} else {
envelope.current = 0;
time.current = Math.PI / 2;
xTarget.current = DEFAULT_X;
draw(0);
rafId.current = null;
}
};
const firePulse = (magnitude: number, fromX: number, toX: number) => {
idleMode.current = false;
envelope.current = magnitude;
time.current = Math.PI / 2; // sin() starts at 1 — immediate snap outward
xPos.current = fromX;
xTarget.current = toX;
if (rafId.current === null) {
rafId.current = requestAnimationFrame(decayLoop);
}
};
/** Reduced-motion fallback: instant, non-oscillating shape + color change. */
const instantState = (magnitude: number) => {
stopLoop();
idleMode.current = false;
envelope.current = 0;
time.current = Math.PI / 2;
xPos.current = DEFAULT_X;
xTarget.current = DEFAULT_X;
const bow =
Math.min(Math.abs(magnitude), cfg.current.maxAmplitude) * 0.35;
draw(magnitude === 0 ? 0 : bow);
};
useImperativeHandle(ref, () => ({
pulse: (opts = {}) => {
const magnitude = opts.magnitude ?? cfg.current.maxAmplitude;
if (reducedMotion) {
instantState(magnitude);
return;
}
stopLoop();
firePulse(magnitude, opts.from ?? 0, opts.to ?? 1);
},
pulseDouble: (opts = {}) => {
const magnitude = opts.magnitude ?? cfg.current.maxAmplitude * 1.2;
if (reducedMotion) {
instantState(magnitude);
return;
}
stopLoop();
const from = opts.from ?? DEFAULT_X;
const to = opts.to ?? DEFAULT_X;
firePulse(magnitude, from, to);
doubleTimeout.current = setTimeout(() => {
firePulse(magnitude, from, to);
doubleTimeout.current = null;
}, DOUBLE_PULSE_GAP_MS);
},
nudge: (dy) => {
if (reducedMotion) {
instantState(envelope.current + dy);
return;
}
idleMode.current = false;
envelope.current = Math.min(
Math.max(envelope.current + dy, -cfg.current.maxAmplitude),
cfg.current.maxAmplitude,
);
time.current = Math.PI / 2;
if (rafId.current === null) {
rafId.current = requestAnimationFrame(decayLoop);
}
},
startIdle: (magnitude = IDLE_AMPLITUDE) => {
if (reducedMotion) {
instantState(magnitude);
return;
}
stopLoop();
idleMode.current = true;
envelope.current = magnitude;
time.current = Math.PI / 2;
rafId.current = requestAnimationFrame(decayLoop);
},
stopIdle: () => {
idleMode.current = false;
},
setColorState: (state) => {
colorState.current = state;
draw(lastValue.current);
},
}));
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
if (width <= 0 || height <= 0) return;
geometry.current = buildPerimeter(width, height, radius);
draw(lastValue.current);
});
ro.observe(svg);
return () => {
ro.disconnect();
stopLoop();
};
}, [radius]);
return (
<svg
ref={svgRef}
aria-hidden="true"
className={`pointer-events-none absolute inset-0 h-full w-full overflow-visible ${className}`}
>
<path
ref={pathRef}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
className="text-neutral-300 dark:text-neutral-700 transition-[stroke,opacity] duration-150 ease-out"
/>
</svg>
);
},
);