Password Strength Tide

Input

Password field with a canvas water tank behind the masked text: entropy raises the tide on a damped spring, keystrokes slosh a real 1D heightfield wave, deletions pull the level back down.

Install
npx shadcn add https://design.helpmarq.com/r/password-strength-tide.json
Source
registry/core/password-strength-tide/component.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// TideGaugePassword — password field with a canvas water tank behind the
// masked text. Live entropy score drives the water level through a damped
// spring (k=90 s^-2, zeta=0.55, ~8% overshoot), each keystroke sloshes a real
// 1D heightfield wave at the caret's approximate x, deletions bump the surface
// and let the tide recede. Fill hue runs muted -> accent with strength. Full
// clear + redraw each frame (no accumulating alpha), canvas sized with
// explicit style.width/height (replaced-element dpr trap), rAF sleeps at a
// velocity epsilon with a 1.2 s forced-settle deadline, pauses offscreen. All
// ink derives from CSS tokens at mount and re-derives live on documentElement
// class changes. Input semantics are fully standard: real <input>, visible
// label, polite live region for strength, reveal toggle with aria-pressed.
// ---------------------------------------------------------------------------

const DX = 4; // heightfield column pitch px
const WAVE_C = 140; // wave speed px/s
const DAMP = 0.985; // per-frame velocity damping
const KEY_IMPULSE = -6; // px surface dip per keystroke
const DEL_IMPULSE = 4; // px surface bump per deletion
const TOGGLE_IMPULSE = 4; // px ripple at the eye icon
const SPRING_K = 90; // s^-2
const SPRING_ZETA = 0.55; // ~8% overshoot then settle
const MAX_FILL = 0.94; // strength 100 fills to 94% so the surface line shows
const SETTLE_MS = 1200; // forced-settle deadline after last excitement
const HARD_SNAP_MS = 2000; // absolute cutoff — snap flat, sleep
const V_EPS = 0.02; // px/frame sleep epsilon
const LEVEL_EPS = 0.3; // px distance-to-target sleep epsilon
const FILL_ALPHA = 0.2;
const LINE_ALPHA = 0.6;
const RIM_ALPHA = 0.3; // faint empty-tank baseline shown at zero entropy
// splash spread across five neighboring columns
const KERNEL = [0.25, 0.6, 1, 0.6, 0.25] as const;

type Vec3 = readonly [number, number, number];

function parseColor(raw: string): Vec3 | null {
  const s = raw.trim();
  if (s.startsWith("#")) {
    const hex = s.slice(1);
    if (hex.length === 3) {
      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);
      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);
      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    if (hex.length >= 6) {
      const r = parseInt(hex.slice(0, 2), 16);
      const g = parseInt(hex.slice(2, 4), 16);
      const b = parseInt(hex.slice(4, 6), 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    return null;
  }
  const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

function mix(a: Vec3, b: Vec3, t: number): Vec3 {
  return [
    Math.round(a[0] + (b[0] - a[0]) * t),
    Math.round(a[1] + (b[1] - a[1]) * t),
    Math.round(a[2] + (b[2] - a[2]) * t),
  ];
}

// length + charset-class entropy, mapped 0-100 (72 bits caps the gauge)
function scorePassword(v: string): number {
  if (!v) return 0;
  let pool = 0;
  if (/[a-z]/.test(v)) pool += 26;
  if (/[A-Z]/.test(v)) pool += 26;
  if (/[0-9]/.test(v)) pool += 10;
  if (/[^a-zA-Z0-9]/.test(v)) pool += 33;
  const bits = v.length * Math.log2(pool || 1);
  return Math.max(0, Math.min(100, (bits / 72) * 100));
}

type Band = "Weak" | "Fair" | "Strong" | null;

function bandOf(score: number): Band {
  if (score <= 0) return null;
  if (score < 34) return "Weak";
  if (score < 67) return "Fair";
  return "Strong";
}

export function TideGaugePassword({
  label = "Password",
  name = "password",
  placeholder = "",
  defaultValue = "",
  value,
  onValueChange,
  autoComplete = "new-password",
  required = false,
  disabled = false,
  className = "",
}: {
  /** visible label text above the field */
  label?: string;
  name?: string;
  placeholder?: string;
  /** uncontrolled initial value */
  defaultValue?: string;
  /** controlled value; omit for uncontrolled */
  value?: string;
  onValueChange?: (value: string) => void;
  autoComplete?: string;
  required?: boolean;
  disabled?: boolean;
  className?: string;
}) {
  const uid = useId();
  const inputId = `tide-pw-${uid}`;
  const liveId = `tide-pw-live-${uid}`;
  const frameRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const toggleRef = useRef<HTMLButtonElement>(null);
  const engineRef = useRef<{
    input: (v: string, instant?: boolean) => void;
    ripple: (x: number) => void;
  } | null>(null);

  const isControlled = value !== undefined;
  const initial = isControlled ? value : defaultValue;
  const [revealed, setRevealed] = useState(false);
  const [band, setBand] = useState<Band>(() => bandOf(scorePassword(initial)));

  useEffect(() => {
    const frame = frameRef.current;
    const canvas = canvasRef.current;
    const input = inputRef.current;
    if (!frame || !canvas || !input) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const motionMq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = motionMq.matches;

    // -- token-derived ink: read at mount, re-derived on theme class change --
    let mutedC: Vec3 = [143, 143, 143];
    let accentC: Vec3 = [0, 107, 255];
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      mutedC = parseColor(cs.getPropertyValue("--muted")) ?? mutedC;
      accentC = parseColor(cs.getPropertyValue("--accent")) ?? accentC;
    };
    derive();

    // -- hot-path state: locals only, never React state ---------------------
    let w = 0;
    let h = 0;
    let dpr = 1;
    let sized = false;
    let raf = 0;
    let last = 0;
    let visible = true;
    let cols = 0;
    let hf = new Float32Array(0); // surface offset px, + is up
    let vf = new Float32Array(0); // px/s
    let level = 0; // water height px
    let levelV = 0; // px/s
    let target = 0; // px
    let exciteAt = 0; // performance.now() of last impulse/target change
    let prevLen = 0;
    let maskW = 8; // measured mask-glyph advance px
    let padLeft = 12;

    const measure = () => {
      const csIn = getComputedStyle(input);
      padLeft = parseFloat(csIn.paddingLeft) || 12;
      ctx.font = `${csIn.fontWeight} ${csIn.fontSize} ${csIn.fontFamily}`;
      const m = ctx.measureText("•").width;
      if (m > 0) maskW = m;
    };

    const drawFrame = () => {
      if (!sized) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h); // full clear + redraw — nothing accumulates
      if (level < 0.5) {
        // zero-entropy idle: no fill, but a faint rim a couple px above the
        // frame's own border (the outermost row is clipped by the frame's
        // overflow-hidden) so the tank floor reads as a gauge even at rest.
        ctx.beginPath();
        ctx.moveTo(0, h - 3);
        ctx.lineTo(w, h - 3);
        ctx.strokeStyle = `rgba(${mutedC[0]},${mutedC[1]},${mutedC[2]},${RIM_ALPHA})`;
        ctx.lineWidth = 1;
        ctx.stroke();
        return;
      }
      const yBase = h - level;
      const t = Math.max(0, Math.min(1, level / (h * MAX_FILL)));
      const c = mix(mutedC, accentC, t);
      // fill body
      ctx.beginPath();
      ctx.moveTo(0, yBase - (hf[0] ?? 0));
      for (let i = 1; i < cols; i++) ctx.lineTo(i * DX, yBase - (hf[i] ?? 0));
      ctx.lineTo(w, yBase - (hf[cols - 1] ?? 0));
      ctx.lineTo(w, h);
      ctx.lineTo(0, h);
      ctx.closePath();
      ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${FILL_ALPHA})`;
      ctx.fill();
      // surface line
      ctx.beginPath();
      ctx.moveTo(0, yBase - (hf[0] ?? 0));
      for (let i = 1; i < cols; i++) ctx.lineTo(i * DX, yBase - (hf[i] ?? 0));
      ctx.lineTo(w, yBase - (hf[cols - 1] ?? 0));
      ctx.strokeStyle = `rgba(${c[0]},${c[1]},${c[2]},${LINE_ALPHA})`;
      ctx.lineWidth = 1.5;
      ctx.lineJoin = "round";
      ctx.stroke();
    };

    const snapFlat = () => {
      hf.fill(0);
      vf.fill(0);
      level = target;
      levelV = 0;
      drawFrame();
    };

    const loop = (now: number) => {
      raf = 0;
      if (!sized || !visible || document.hidden) return; // wake() resumes
      // dt clamp respects the wave CFL bound: c*dt/dx = 140*0.025/4 < 1,
      // so dropped frames slow the sim instead of blowing up the heightfield
      const dt = last ? Math.min(0.025, (now - last) / 1000) : 1 / 60;
      last = now;

      if (now - exciteAt > HARD_SNAP_MS) {
        snapFlat(); // absolute forced-settle — sleep flat
        return;
      }
      const overdue = now - exciteAt > SETTLE_MS;

      // damped level spring
      const cDamp = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);
      levelV += (-SPRING_K * (level - target) - cDamp * levelV) * dt;
      level += levelV * dt;

      // 1D heightfield: neighbor coupling, per-frame damping
      const damp = overdue ? 0.85 : DAMP;
      const c2 = (WAVE_C * WAVE_C) / (DX * DX);
      let vMax = 0;
      for (let i = 0; i < cols; i++) {
        const l = hf[Math.max(0, i - 1)] ?? 0;
        const r = hf[Math.min(cols - 1, i + 1)] ?? 0;
        const acc = c2 * (l + r - 2 * (hf[i] ?? 0));
        vf[i] = ((vf[i] ?? 0) + acc * dt) * damp;
      }
      for (let i = 0; i < cols; i++) {
        hf[i] = (hf[i] ?? 0) + (vf[i] ?? 0) * dt;
        if (overdue) hf[i] = (hf[i] ?? 0) * 0.9; // pull flat past deadline
        const pf = Math.abs((vf[i] ?? 0) * dt);
        if (pf > vMax) vMax = pf;
      }

      drawFrame();

      const settled =
        vMax < V_EPS &&
        Math.abs(level - target) < LEVEL_EPS &&
        Math.abs(levelV * dt) < V_EPS;
      if (settled) {
        snapFlat();
        return;
      }
      raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (!raf && !reduced && visible && !document.hidden) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const splash = (x: number, amp: number) => {
      if (!sized) return;
      const i0 = Math.round(x / DX);
      for (let k = 0; k < KERNEL.length; k++) {
        const i = i0 + k - 2;
        if (i < 0 || i >= cols) continue;
        hf[i] = (hf[i] ?? 0) + amp * (KERNEL[k] ?? 0);
      }
    };

    const caretX = (len: number) =>
      Math.max(4, Math.min(w - 8, padLeft + len * maskW));

    const inputFn = (v: string, instant = false) => {
      const s = scorePassword(v);
      target = h * MAX_FILL * (s / 100);
      const dLen = v.length - prevLen;
      prevLen = v.length;
      if (reduced || instant || !sized) {
        // static gauge: instant height, no waves
        hf.fill(0);
        vf.fill(0);
        level = target;
        levelV = 0;
        drawFrame();
        return;
      }
      // keystroke sloshes at the caret's approximate x; delete bumps + recedes
      if (dLen > 0) splash(caretX(v.length), KEY_IMPULSE);
      else if (dLen < 0) splash(caretX(v.length), DEL_IMPULSE);
      exciteAt = performance.now();
      wake();
    };

    const rippleFn = (x: number) => {
      if (reduced) return;
      splash(x, TOGGLE_IMPULSE);
      exciteAt = performance.now();
      wake();
    };

    const resize = () => {
      const rect = frame.getBoundingClientRect();
      if (rect.width < 4 || rect.height < 4) {
        sized = false; // zero-size guard — never simulate into nothing
        return;
      }
      w = rect.width;
      h = rect.height;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      // replaced element: inset does not size it — explicit CSS size + bitmap
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      const nextCols = Math.floor(w / DX) + 1;
      if (nextCols !== cols) {
        cols = nextCols;
        hf = new Float32Array(cols);
        vf = new Float32Array(cols);
      }
      sized = true;
      measure();
      // re-anchor level to the (possibly new) height
      const s = scorePassword(input.value);
      target = h * MAX_FILL * (s / 100);
      if (!raf) level = target;
      drawFrame();
    };
    resize();

    // -- init: seed level from the initial value, no waves ------------------
    prevLen = input.value.length;
    inputFn(input.value, true);

    engineRef.current = { input: inputFn, ripple: rippleFn };

    // -- landing-page card demo (autoplay) -----------------------------------
    // The shared autoplay driver (app/preview/[name]/autoplay-driver.tsx) only
    // synthesises Pointer/Mouse events. This component's entire demonstration
    // is value-driven — typed characters via <input onChange> — which no
    // pointer/scroll/press/drag descriptor can reach, and the one
    // pointer-reachable surface (the reveal toggle -> ripple) is invisible on
    // an empty tank. So meta.json declares "mode": "none" (no shared driver
    // mounts, no data-autoplay-root/inert change) and, gated strictly on the
    // ?autoplay=1 query param the embed sets, this scripts a password being
    // typed and deleted by calling the exact same `inputFn` real typing
    // already calls — no new rendering path, no bespoke canvas animation.
    // Real users never see this: the param is absent on both the plain
    // /preview page and on plain ?embed=1.
    let scriptTimer = 0;
    let scriptCancelled = false;
    if (!isControlled && !reduced) {
      const isAutoplay =
        new URLSearchParams(window.location.search).get("autoplay") === "1";
      if (isAutoplay) {
        const script = "Tr0ub4dor&3";
        const TYPE_MS = 190;
        const DELETE_MS = 110;
        const HOLD_MS = 850;
        const REST_MS = 700;
        const setVal = (v: string) => {
          input.value = v;
          setBand((prev) => {
            const b = bandOf(scorePassword(v));
            return prev === b ? prev : b;
          });
          inputFn(v);
        };
        const typeStep = (i: number) => {
          if (scriptCancelled) return;
          setVal(script.slice(0, i));
          scriptTimer = window.setTimeout(
            () => (i < script.length ? typeStep(i + 1) : deleteStep(script.length)),
            i < script.length ? TYPE_MS : HOLD_MS
          );
        };
        const deleteStep = (len: number) => {
          if (scriptCancelled) return;
          const next = len - 1;
          setVal(script.slice(0, Math.max(0, next)));
          scriptTimer = window.setTimeout(
            () => (next > 0 ? deleteStep(next) : typeStep(1)),
            next > 0 ? DELETE_MS : REST_MS
          );
        };
        scriptTimer = window.setTimeout(() => typeStep(1), 500);
      }
    }

    // -- observers ----------------------------------------------------------
    const ro = new ResizeObserver(resize);
    ro.observe(frame);
    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) wake();
    });
    io.observe(frame);
    // live theme re-derive: watch documentElement class flips
    const mo = new MutationObserver(() => {
      derive();
      if (raf) return; // running loop repaints next frame anyway
      drawFrame();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });
    const onVis = () => {
      if (!document.hidden) wake();
    };
    document.addEventListener("visibilitychange", onVis);
    // live OS-setting re-derive, mirroring the documentElement class observer
    const onMotionChange = () => {
      reduced = motionMq.matches;
      if (reduced) {
        cancelAnimationFrame(raf);
        raf = 0;
        snapFlat();
      }
    };
    motionMq.addEventListener("change", onMotionChange);

    return () => {
      cancelAnimationFrame(raf);
      scriptCancelled = true;
      window.clearTimeout(scriptTimer);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      motionMq.removeEventListener("change", onMotionChange);
      engineRef.current = null;
    };
  }, []);

  // controlled value set programmatically (autofill, reset) re-syncs the tide
  useEffect(() => {
    if (isControlled) engineRef.current?.input(value ?? "");
  }, [isControlled, value]);

  const handleInput = (v: string) => {
    const b = bandOf(scorePassword(v));
    setBand((prev) => (prev === b ? prev : b));
    engineRef.current?.input(v);
    onValueChange?.(v);
  };

  const handleToggle = () => {
    setRevealed((r) => !r);
    const btn = toggleRef.current;
    if (btn) engineRef.current?.ripple(btn.offsetLeft + btn.offsetWidth / 2);
  };

  return (
    <div className={className}>
      <label
        htmlFor={inputId}
        className="mb-1.5 block text-sm font-medium text-foreground"
      >
        {label}
        {required ? <span className="text-muted"> *</span> : null}
      </label>
      <div
        ref={frameRef}
        className={`relative overflow-hidden rounded-sm border border-border bg-surface transition-colors focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/30 ${
          disabled ? "opacity-60" : "hover:border-foreground/25"
        }`}
      >
        <canvas
          ref={canvasRef}
          aria-hidden
          className="pointer-events-none absolute left-0 top-0"
        />
        <input
          ref={inputRef}
          id={inputId}
          type={revealed ? "text" : "password"}
          name={name}
          placeholder={placeholder}
          autoComplete={autoComplete}
          required={required}
          disabled={disabled}
          aria-describedby={liveId}
          spellCheck={false}
          {...(isControlled ? { value } : { defaultValue })}
          onChange={(e) => handleInput(e.currentTarget.value)}
          className="relative z-10 w-full bg-transparent py-2 pl-3 pr-11 font-mono text-sm text-foreground outline-none placeholder:text-muted/70"
        />
        <button
          ref={toggleRef}
          type="button"
          aria-pressed={revealed}
          aria-label={revealed ? "Hide password" : "Show password"}
          disabled={disabled}
          onClick={handleToggle}
          className="absolute right-1.5 top-1/2 z-10 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-sm text-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none"
        >
          {revealed ? (
            <svg
              width="15"
              height="15"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden
            >
              <path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
              <path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
              <path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
              <line x1="2" x2="22" y1="2" y2="22" />
            </svg>
          ) : (
            <svg
              width="15"
              height="15"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden
            >
              <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
              <circle cx="12" cy="12" r="3" />
            </svg>
          )}
        </button>
      </div>
      <div className="mt-1.5 flex min-h-4 items-center justify-end">
        <span
          aria-hidden
          className={`font-mono text-[11px] uppercase tracking-[0.14em] transition-colors ${
            band === "Strong"
              ? "text-accent"
              : band === "Fair"
                ? "text-foreground/70"
                : band === "Weak"
                  ? "text-muted"
                  : "text-transparent"
          }`}
        >
          {band ?? "·"}
        </span>
      </div>
      <span id={liveId} aria-live="polite" className="sr-only">
        {band ? `Password strength: ${band}` : ""}
      </span>
    </div>
  );
}
Use when

a password field specifically — a canvas water tank behind the masked text where entropy raises the tide and keystrokes slosh a real wave; use when strength itself should be legible, not for general text input.

Build spec

Build a password field (real <input type=password>, visible label, standard semantics untouched) with a canvas 2D water tank behind the masked text, clipped to the field's rounded-sm 6px bounds. Size the canvas with EXPLICIT style.width/height plus dpr-scaled bitmap (replaced element — inset does not size it). Strength = length + charset-class entropy (pool 26/26/10/33, 72 bits caps the gauge) mapped 0-100 to water level percent; drive the level with a damped spring k=90 s^-2, zeta=0.55 (~8% overshoot then settle). Simulate a 1D heightfield with one column per 4 px: neighbor-coupling wave speed ~140 px/s, per-frame velocity damping 0.985; each keystroke injects a -6 px surface impulse at the caret's approximate x (charCount * measured mask-glyph width, spread over a 5-column kernel), deletions inject +4 px and lower the target, and the reveal toggle drops a 4 px ripple at the eye-icon x. Fill = mix(--muted, --accent, level fraction) at 20% alpha with a 1.5 px surface line at 60% alpha; full clear + redraw every frame — never accumulating destination-in alpha. rAF sleeps when max column |v| < 0.02 px/frame AND |level-target| < 0.3 px, with a 1.2 s forced-settle deadline (extra damping pulls flat, 2 s hard snap) and pauses offscreen via IntersectionObserver plus document visibility; guard zero-size containers before simulating. All canvas ink is read via getComputedStyle at mount and re-derived by a MutationObserver watching documentElement class changes so both themes render correctly. Reveal toggle is a real button with aria-pressed; aria-describedby points at a visually-hidden polite live region announcing Weak/Fair/Strong, mirrored by a small aria-hidden mono readout. Accent focus-within ring and token-relative hover border. At zero entropy the tank draws no fill but a faint --muted rim line at the base so the empty gauge silhouette still reads at rest. prefers-reduced-motion renders a static fill bar at target height with instant height changes and no waves, and is watched live via a matchMedia change listener (not just read once at mount) so a mid-session OS toggle snaps the tank flat immediately. Tear down every listener, observer, and rAF on unmount.

Tags
inputpasswordformcanvasphysicsmicro-interaction