Teseria
All elements

Password

A floating-label field whose border is an SVG ring with a gap that opens for the label. Under it a strength bar fills on a spring and warms from red to green, and each rule's dot fills and ticks the moment you satisfy it.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

Password.tsx231 lines
"use client";
// Password: a floating-label field whose border is an SVG ring with a gap that opens for the label. Under it a strength bar fills on
// a spring and warms from red to green, and each rule's dot fills and ticks the moment you satisfy it. The eye flips the field through a blur.
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Eye, EyeOff } from "lucide-react";

export type PasswordCue = "pip" | "done" | "lock" | "unlock";

export type PasswordRule = {
  /** Unique id, used as the React key. */
  id: string;
  /** Text of the checklist row. */
  label: string;
  /** True when the password satisfies the rule. */
  test: (v: string) => boolean;
};

export type PasswordProps = {
  /** The password (controlled). */
  value?: string;
  /** The starting password (uncontrolled). */
  defaultValue?: string;
  /** Called with the new text on every keystroke. */
  onChange?: (v: string) => void;
  /** The checklist. The strength bar and word follow how many pass. */
  rules?: PasswordRule[];
  /** Floating label text. */
  label?: string;
  /** Width in px. */
  width?: number;
  /** Sound hook: "pip" as each rule passes (with `at`), "done" when the last one passes, "unlock" / "lock" on show / hide. */
  onSound?: (cue: PasswordCue, opts?: { at?: number }) => void;
  className?: string;
  style?: CSSProperties;
};

export const DEFAULT_RULES: PasswordRule[] = [
  { id: "len", label: "At least 8 characters", test: (v) => v.length >= 8 },
  { id: "case", label: "Upper and lower case", test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v) },
  { id: "num", label: "A number", test: (v) => /\d/.test(v) },
  { id: "sym", label: "A symbol", test: (v) => /[^A-Za-z0-9]/.test(v) },
];
const LEVELS = ["", "Weak", "Fair", "Good", "Strong"] as const;
const FIELD_H = 52;
const FIELD_R = FIELD_H / 2;
const SCALE = 0.78; // label scale when it sits on the ring
const PAD = 0.75; // half stroke, keeps the ring crisp
const INSET = FIELD_R + 4; // where the label and the text start; the bar and the checklist line up on it too

const cn = (...a: (string | false | null | undefined)[]) => a.filter(Boolean).join(" ");
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/** Re-targetable spring: the target can change every frame and velocity carries over. 0..100 knob sets stiffness. */
function useSpringValue(target: number, knob: number, still: boolean) {
  const [v, setV] = useState(target);
  const pos = useRef(target);
  const vel = useRef(0);
  useEffect(() => {
    if (still) {
      pos.current = target;
      vel.current = 0;
      setV(target);
      return;
    }
    const k = 0.08 + (knob / 100) * 0.16;
    const d = 0.62 + (knob / 100) * 0.2;
    let raf = 0;
    let last = 0;
    const frame = (t: number) => {
      const dt = last ? Math.min(2.5, Math.max(0, (t - last) / 16.67)) : 1;
      last = t;
      vel.current += (target - pos.current) * k * dt;
      vel.current *= d ** dt;
      pos.current += vel.current * dt;
      if (Math.abs(target - pos.current) < 0.02 && Math.abs(vel.current) < 0.02) {
        pos.current = target;
        vel.current = 0;
        setV(target);
        return;
      }
      setV(pos.current);
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [target, knob, still]);
  return v;
}

function useStill() {
  const [still, setStill] = useState(false);
  useEffect(() => setStill(!!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches), []);
  return still;
}

export function Password({ value, defaultValue = "", onChange, rules = DEFAULT_RULES, label = "Password", width = 280, onSound, className, style }: PasswordProps) {
  const [inner, setInner] = useState(defaultValue);
  const v = value ?? inner;
  const still = useStill();
  const passed = rules.map((r) => r.test(v));
  const n = passed.filter(Boolean).length;
  const score = v.length === 0 ? 0 : clamp((n + Math.min(1, v.length / 12) * 0.5) / (rules.length + 0.5), 0.06, 1);
  const level = v.length === 0 || rules.length === 0 ? 0 : clamp(Math.ceil((n / rules.length) * 4), 1, 4);
  const fill = useSpringValue(score, 48, still);
  const prev = useRef(passed);
  useEffect(() => {
    if (rules.length > 0 && n === rules.length && prev.current.some((p) => !p)) onSound?.("done"); // the last rule lands as "done", not a pip too
    else passed.forEach((p, i) => p && !prev.current[i] && onSound?.("pip", { at: i / Math.max(1, rules.length - 1) }));
    prev.current = passed;
  }, [passed.join()]);

  return (
    <div className={cn("tpwd-root", className)} data-level={level} style={{ width, "--tpwd-x": `${INSET}px`, ...style } as CSSProperties}>
      <Field label={label} value={v} width={width} onChange={(s) => (value === undefined && setInner(s), onChange?.(s))} onSound={onSound} />
      <div className="tpwd-meter">
        <div className="tpwd-bar" aria-hidden>
          <div className="tpwd-fill" style={{ transform: `scaleX(${clamp(fill, 0, 1).toFixed(4)})` }} />
        </div>
        <div className="tpwd-word" aria-live="polite">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span key={level} initial={{ opacity: 0, y: 6, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, y: -6, filter: "blur(3px)" }} transition={{ duration: 0.18 }}>
              {LEVELS[level]}
            </motion.span>
          </AnimatePresence>
        </div>
      </div>
      <ul className="tpwd-rules">
        {rules.map((r, i) => (
          <Rule key={r.id} label={r.label} ok={passed[i]} still={still} />
        ))}
      </ul>
    </div>
  );
}

function Rule({ label, ok, still }: { label: string; ok: boolean; still: boolean }) {
  const d = useSpringValue(ok ? 1 : 0, 60, still);
  const o = clamp(d, 0, 1);
  return (
    <li className="tpwd-rule" data-ok={ok || undefined}>
      <span className="tpwd-dot">
        <span className="tpwd-dotfill" style={{ transform: `scale(${clamp(d, 0, 1.15).toFixed(4)})` }} />
        <svg viewBox="0 0 24 24" aria-hidden>
          <path d="M6 12.5l4 4L18 8" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - clamp((o - 0.2) / 0.8, 0, 1)} />
        </svg>
      </span>
      <span className="tpwd-text">{label}</span>
      <span className="tpwd-sr">{ok ? ", met" : ", not met"}</span>
    </li>
  );
}

/**
 * The field: a pill outline drawn as SVG, interrupted at the top-left where the label rests once it floats up. The interruption is
 * two short dashed segments that slide closed/open, so the label seems to sit in the line. Each letter hops once on focus.
 */
function Field({ label, value, width, onChange, onSound }: { label: string; value: string; width: number; onChange: (v: string) => void; onSound: PasswordProps["onSound"] }) {
  const id = useId();
  const [ui, setUi] = useState({ focus: false, reveal: false, flips: 0 });
  const [labelWidth, setLabelWidth] = useState(40);
  const labelEl = useRef<HTMLLabelElement>(null);
  useLayoutEffect(() => {
    if (labelEl.current) setLabelWidth(labelEl.current.offsetWidth);
  }, [label]);

  const radius = FIELD_R;
  const notchStart = Math.max(radius * 0.6, INSET - 5);
  const notchEnd = INSET + labelWidth * SCALE + 5;
  const top = PAD;
  const bottom = FIELD_H - PAD;
  const right = width - PAD;
  const arc = radius - PAD;
  const centre = width / 2;
  // clockwise from the notch over the right side to the bottom middle, and counter-clockwise from the other end of the notch
  const clockwise = `M${notchEnd},${top} H${width - radius} A${arc},${arc} 0 0 1 ${right},${radius} V${FIELD_H - radius} A${arc},${arc} 0 0 1 ${width - radius},${bottom} H${centre}`;
  const counter = `M${notchStart},${top} H${radius} A${arc},${arc} 0 0 0 ${PAD},${radius} V${FIELD_H - radius} A${arc},${arc} 0 0 0 ${radius},${bottom} H${centre}`;
  const middle = (notchStart + notchEnd) / 2;
  const notch = [`M${middle},${top} H${notchStart}`, `M${middle},${top} H${notchEnd}`];

  const raised = ui.focus || value.length > 0;
  const boxStyle = { width, height: FIELD_H, borderRadius: radius };

  return (
    <div className="tpwd-li" data-up={raised || undefined} data-focus={ui.focus || undefined}>
      <div className="tpwd-li-box" style={boxStyle}>
        <svg className="tpwd-li-ring" width={width} height={FIELD_H} viewBox={`0 0 ${width} ${FIELD_H}`} aria-hidden>
          <path d={clockwise} />
          <path d={counter} />
          {notch.map((d) => (
            <path key={d} className="tpwd-li-gap" d={d} pathLength={1} />
          ))}
        </svg>
        <label ref={labelEl} className="tpwd-li-label" htmlFor={id}>
          {Array.from(label, (letter, n) => (
            <span key={n} style={{ "--tpwd-i": n } as CSSProperties}>
              {letter}
            </span>
          ))}
        </label>
        <input
          id={id}
          className="tpwd-li-field"
          data-flip={ui.flips % 2}
          type={ui.reveal ? "text" : "password"}
          value={value}
          autoComplete="new-password"
          autoCorrect="off"
          autoCapitalize="off"
          spellCheck={false}
          style={{ paddingRight: 48 }}
          onChange={(e) => onChange(e.currentTarget.value)}
          onFocus={() => setUi((u) => ({ ...u, focus: true }))}
          onBlur={() => setUi((u) => ({ ...u, focus: false }))}
        />
        <button
          type="button"
          className="tpwd-li-eye"
          data-show={ui.reveal || undefined}
          aria-label={ui.reveal ? "Hide password" : "Show password"}
          onPointerDown={(e) => e.preventDefault()}
          onClick={() => (setUi((u) => ({ ...u, reveal: !u.reveal, flips: u.flips + 1 })), onSound?.(ui.reveal ? "lock" : "unlock"))}
        >
          {[Eye, EyeOff].map((Icon, n) => (
            <Icon key={n} size={16} strokeWidth={2} aria-hidden />
          ))}
        </button>
      </div>
    </div>
  );
}
Password.css286 lines
/* Password. Prefix tpwd-. */
.tpwd-root, .tpwd-root *, .tpwd-root *::before, .tpwd-root *::after { box-sizing: border-box; }
.tpwd-root { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.tpwd-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; }
:where(.tpwd-root) :focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }

.tpwd-root {
  --tpwd-x: 30px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  color: var(--ts-fg);
  --tpwd-tone: var(--ts-danger);
}
.tpwd-root[data-level="2"] {
  --tpwd-tone: var(--ts-warning);
}
.tpwd-root[data-level="3"] {
  --tpwd-tone: color-mix(in srgb, var(--ts-warning) 50%, var(--ts-success));
}
.tpwd-root[data-level="4"] {
  --tpwd-tone: var(--ts-success);
}
/* the bar and the checklist start where the field's text does; the strength word sits at the end of the first rule */
.tpwd-meter {
  position: relative;
  padding: 0 var(--tpwd-x);
}
.tpwd-bar {
  height: 4px;
  border-radius: 999px;
  background: rgba(var(--ts-fg-rgb), 0.1);
  overflow: hidden;
}
.tpwd-fill {
  width: 100%;
  height: 100%;
  border-radius: 999px;
  background: var(--tpwd-tone);
  transform-origin: 0 50%;
  transition: background 0.42s cubic-bezier(.22, 1, .36, 1);
}
.tpwd-word {
  position: absolute;
  top: calc(100% + 10px);
  right: var(--tpwd-x);
  height: 16px;
  line-height: 16px;
  text-align: right;
  font-size: 12px;
  font-weight: 500;
  color: var(--tpwd-tone);
  transition: color 0.42s cubic-bezier(.22, 1, .36, 1);
}
.tpwd-word > span {
  display: block;
}
.tpwd-rules {
  list-style: none;
  margin: 0;
  padding: 0 var(--tpwd-x);
  display: flex;
  flex-direction: column;
  gap: 7px;
}
.tpwd-rule {
  display: flex;
  align-items: center;
  gap: 9px;
  font-size: 13px;
  letter-spacing: -0.01em;
}
.tpwd-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.tpwd-dot {
  position: relative;
  width: 16px;
  height: 16px;
  border-radius: 999px;
  flex: none;
  display: grid;
  place-items: center;
  overflow: hidden;
  box-shadow: inset 0 0 0 1.5px rgba(var(--ts-fg-rgb), 0.2);
}
.tpwd-dotfill {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--ts-success);
  transform-origin: 50%;
}
.tpwd-dot svg {
  position: relative;
  width: 11px;
  height: 11px;
  fill: none;
  stroke: var(--ts-fg-inverse);
  stroke-width: 3px;
  stroke-linecap: round;
  stroke-linejoin: round;
  overflow: visible;
}
.tpwd-text {
  color: color-mix(
    in srgb,
    var(--ts-fg) 70%,
    var(--ts-raised)
  );
  transition: color 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tpwd-rule[data-ok] .tpwd-text {
  color: color-mix(
    in srgb,
    var(--ts-fg) 42%,
    var(--ts-raised)
  );
}

/* floating-label field */
.tpwd-li {
  --tpwd-ring: color-mix(in srgb, var(--ts-fg) 32%, var(--ts-raised));
  --tpwd-ring-w: 1px;
  --tpwd-lab: color-mix(in srgb, var(--ts-fg) 55%, var(--ts-raised));
  --tpwd-ease: cubic-bezier(.22, 1, .36, 1);
  display: grid;
  place-items: center;
  position: relative;
}
.tpwd-li[data-focus] {
  --tpwd-ring: var(--ts-fg);
  --tpwd-ring-w: 1.5px;
  --tpwd-lab: var(--ts-fg);
}
.tpwd-li[data-up]:not([data-focus]) {
  --tpwd-lab: color-mix(in srgb, var(--ts-fg) 68%, var(--ts-raised));
}
.tpwd-li-box {
  position: relative;
  cursor: text;
}
.tpwd-li-ring {
  pointer-events: none;
  overflow: visible;
  position: absolute;
  top: 0;
  left: 0;
}
.tpwd-li-ring path {
  fill: none;
  stroke: var(--tpwd-ring);
  stroke-width: var(--tpwd-ring-w);
  transition: stroke 0.22s var(--tpwd-ease), stroke-width 0.22s var(--tpwd-ease);
}
.tpwd-li-ring .tpwd-li-gap {
  stroke-dasharray: 1 2;
  transition-property: stroke, stroke-width, stroke-dashoffset;
}
.tpwd-li-ring .tpwd-li-gap {
  transition-duration: 0.22s, 0.22s, 0.42s;
}
.tpwd-li[data-up] .tpwd-li-gap {
  stroke-dashoffset: -1px;
}
.tpwd-li-label {
  position: absolute;
  left: var(--tpwd-x);
  top: 50%;
  translate: 0 -50%;
  transform-origin: 0 50%;
  color: var(--tpwd-lab);
  white-space: pre;
  user-select: none;
  cursor: text;
  letter-spacing: -0.01em;
  line-height: 1;
  font-size: 16px;
  transition: translate 0.42s var(--tpwd-ease), scale 0.42s var(--tpwd-ease), color 0.22s var(--tpwd-ease);
}
.tpwd-li[data-up] .tpwd-li-label {
  translate: 0 calc(-50% - 26px);
  scale: 0.78;
}
.tpwd-li-label span {
  --tpwd-i: 0;
  display: inline-block;
}
.tpwd-li[data-focus] .tpwd-li-label span {
  animation: tpwd-hop 0.42s var(--tpwd-ease) calc(24ms * var(--tpwd-i)) both;
}
@keyframes tpwd-hop {
  38% {
    translate: 0 -3px;
  }
}
.tpwd-li-field {
  position: absolute;
  inset: 0;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  padding-left: var(--tpwd-x);
  border: 0;
  border-radius: inherit;
  background: none;
  outline: 0;
  color: var(--ts-fg);
  font-family: inherit;
  letter-spacing: -0.01em;
  font-size: 16px;
}
.tpwd-li-field[data-flip="1"] {
  animation: tpwd-flip-a 0.22s var(--tpwd-ease);
}
.tpwd-li-field[data-flip="0"] {
  animation: tpwd-flip-b 0.22s var(--tpwd-ease);
}
@keyframes tpwd-flip-a {
  0% {
    opacity: 0.5;
    filter: blur(3px);
  }
  100% {
    opacity: 1;
    filter: blur(0);
  }
}
@keyframes tpwd-flip-b {
  0% {
    filter: blur(3px);
    opacity: 0.5;
  }
  100% {
    filter: blur(0);
    opacity: 1;
  }
}
.tpwd-li-eye {
  display: grid;
  place-items: center;
  border-radius: 999px;
  inset: 50% 10px auto auto;
  position: absolute;
  translate: 0 -50%;
  height: 30px;
  width: 30px;
  color: color-mix(in srgb, var(--ts-fg) 55%, var(--ts-raised));
  transition: color 0.16s var(--tpwd-ease), scale 0.22s var(--tpwd-ease);
}
.tpwd-li-eye:hover {
  color: var(--ts-fg);
}
.tpwd-li-eye:active {
  scale: 0.96;
}
.tpwd-li-eye > svg {
  grid-area: 1 / 1;
  transition: opacity 0.22s var(--tpwd-ease), scale 0.22s var(--tpwd-ease), filter 0.22s var(--tpwd-ease);
}
.tpwd-li-eye > svg:nth-child(2),
.tpwd-li-eye[data-show] > svg:nth-child(1) {
  opacity: 0;
  filter: blur(2px);
  scale: 0.7;
}
.tpwd-li-eye[data-show] > svg:nth-child(2) {
  opacity: 1;
  filter: none;
  scale: 1;
}
@media (prefers-reduced-motion: reduce) {
  .tpwd-li-ring path,
  .tpwd-li-label {
    transition-duration: 0.16s;
  }
  .tpwd-li[data-focus] .tpwd-li-label span,
  .tpwd-li-field {
    animation: none;
  }
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { Password } from "./Password";
import "./Password.css";

export default function Demo() {
  return <Password />;
}