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
npm i framer-motion lucide-react
2. Files
"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. 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;
}
}
/*
* 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
import { Password } from "./Password";
import "./Password.css";
export default function Demo() {
return <Password />;
}