Forms
PasswordReveal
A password field whose eye button flips the dots into letters one by one. It stays a real password input the rest of the time, so password managers keep working.
Download
- React
- Tailwind CSS v4
- motion
components/ui/password-reveal.tsx
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type InputHTMLAttributes } from "react";
import { motion, useReducedMotion } from "motion/react";
type PasswordRevealProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "className"> & {
/** Field name shown above the box. */
label: string;
/** Small help text under the field. */
hint?: string;
/** Error text under the field. Turns the field red and sets aria-invalid. */
error?: string;
/** Colour of the focus border and eye button. */
accent?: string;
/** Wrapper look (width etc.). */
className?: string;
};
const FLIP_MS = 420;
/* PasswordReveal: a password field whose eye button flips the dots into letters one by one, left to right. It stays a real password input the rest of the time, so password managers keep working. */
export function PasswordReveal({
label,
hint,
error,
accent = "#a78bfa",
className = "w-full max-w-sm",
id,
autoComplete = "current-password",
...rest
}: PasswordRevealProps) {
const reduce = useReducedMotion();
const autoId = useId();
const inputId = id ?? autoId;
const noteId = `${inputId}-note`;
const note = error ?? hint;
const input = useRef<HTMLInputElement>(null);
const timer = useRef(0);
const [shown, setShown] = useState(false);
const [flip, setFlip] = useState<{ chars: string[]; offset: number } | null>(null);
useEffect(() => () => window.clearTimeout(timer.current), []);
const toggle = () => {
const el = input.current;
setShown(!shown);
if (reduce || !el?.value) return;
setFlip({ chars: Array.from(el.value), offset: el.scrollLeft });
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setFlip(null), FLIP_MS + 120);
};
const gap = flip ? Math.min(0.03, FLIP_MS / 1000 / flip.chars.length) : 0;
return (
<div className={className} style={{ "--pr": error ? "#f87171" : accent } as CSSProperties}>
<label htmlFor={inputId} className="mb-2 block text-sm font-medium text-white/80">
{label}
</label>
<div className="relative">
<input
ref={input}
id={inputId}
type={shown ? "text" : "password"}
autoComplete={autoComplete}
spellCheck={false}
aria-invalid={error ? true : undefined}
aria-describedby={note ? noteId : undefined}
style={flip ? { color: "transparent" } : undefined}
className="h-12 w-full rounded-xl border border-white/12 bg-white/[0.04] pr-12 pl-4 font-mono text-base text-white caret-[var(--pr)] outline-none transition-colors duration-200 hover:border-white/25 focus:border-[var(--pr)] aria-invalid:border-red-400/70 motion-reduce:transition-none"
{...rest}
/>
{flip && (
<span aria-hidden className="pointer-events-none absolute inset-y-0 right-12 left-4 flex items-center overflow-hidden font-mono text-base text-white">
<span className="whitespace-pre" style={{ transform: `translateX(${-flip.offset}px)` }}>
{flip.chars.map((c, i) => (
<span key={i} className="relative inline-block w-[1ch] text-center">
<motion.span
className="inline-block"
initial={{ opacity: shown ? 0 : 1, rotateX: shown ? 90 : 0 }}
animate={{ opacity: shown ? 1 : 0, rotateX: shown ? 0 : 90 }}
transition={{ duration: 0.2, delay: i * gap }}
>
{c}
</motion.span>
<motion.span
className="absolute inset-0"
initial={{ opacity: shown ? 1 : 0, scale: shown ? 1 : 0.4 }}
animate={{ opacity: shown ? 0 : 1, scale: shown ? 0.4 : 1 }}
transition={{ duration: 0.2, delay: i * gap }}
>
•
</motion.span>
</span>
))}
</span>
</span>
)}
<button
type="button"
aria-label="Show password"
aria-pressed={shown}
aria-controls={inputId}
onMouseDown={(e) => e.preventDefault()}
onClick={toggle}
className="absolute top-1/2 right-1.5 grid size-9 -translate-y-1/2 place-items-center rounded-lg text-white/55 outline-none transition-colors duration-150 hover:text-white focus-visible:text-[var(--pr)] focus-visible:ring-2 focus-visible:ring-[var(--pr)] motion-reduce:transition-none"
>
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-5">
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<motion.circle cx="12" cy="12" r="3" initial={false} animate={{ scale: shown ? 1 : 0.6 }} transition={{ duration: reduce ? 0 : 0.2 }} />
<motion.path d="m4 4 16 16" initial={false} animate={{ pathLength: shown ? 0 : 1, opacity: shown ? 0 : 1 }} transition={{ duration: reduce ? 0 : 0.22 }} />
</svg>
</button>
</div>
{note && (
<p id={noteId} className={`mt-1.5 px-1 text-xs ${error ? "text-red-300" : "text-white/45"}`}>
{note}
</p>
)}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/password-reveal.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/password-reveal-demo.tsx
"use client"; import { useState } from "react"; import { PasswordReveal } from "@/components/ui/password-reveal"; export default function PasswordRevealExample() { const [password, setPassword] = useState("Sunrise-7-Rocket"); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <PasswordReveal label="Password" hint="Press the eye to flip it" value={password} onChange={(e) => setPassword(e.target.value)} name="password" autoComplete="new-password" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| label | string | — | Field name above the box. |
| hint | string | — | Small help text under the field. |
| error | string | — | Error text. Turns the field red and sets aria-invalid. |
| accent | string | "#a78bfa" | Focus border and eye button colour. |
| autoComplete | string | "current-password" | Use "new-password" on sign-up forms. |
| className | string | "w-full max-w-sm" | Wrapper look. |
| ...rest | input props | — | value, onChange, name, required and so on go to the input. |