shadimage
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 · medium
animationmotion

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>
    );
  },
);

More from craftui

Browse all components in this library →