Password Reveal Toggle
Dots hand over to the real characters one cell at a time as the eye takes its slash.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Password Reveal Toggle
*
* The dots hand over to the real characters one cell at a time, left to
* right, while the eye picks up its slash. Hiding runs the same wave
* backwards.
*
* The field is a real `<input>` whose `type` still switches between
* `password` and `text`, so password managers and assistive technology
* behave exactly as they would without the effect. Its own glyphs are
* transparent because the two crossfading layers above it are what the
* reader sees; both of those inherit the page's text color.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `label`, `defaultValue`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PasswordRevealToggleProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label. */
label?: string;
/** Starting value. */
defaultValue?: string;
/** Shown while the field is empty. */
placeholder?: string;
/** Focus ring and eye colour. */
accent?: string;
/** Fires whenever the reader shows or hides the value. */
onVisibilityChange?: (visible: boolean) => void;
};
type VariantConfig = {
/** Seconds between one cell handing over and the next. */
stagger: number;
/** Seconds a single cell takes to cross over. */
crossSeconds: number;
/** Seconds the slash on the eye takes to draw. */
slashSeconds: number;
};
// Quality rule: the characters are text, so they crossfade in place —
// they never scale, slide or bounce into their cells. Nothing springs;
// a password field that wobbles while it reveals itself is the opposite
// of reassuring. Variants change only the pace of the wave.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Effectively simultaneous. For a sign-in form people use daily.
subtle: { stagger: 0.008, crossSeconds: 0.1, slashSeconds: 0.14 },
// A wave you can follow but never wait for. The all-purpose setting.
default: { stagger: 0.022, crossSeconds: 0.16, slashSeconds: 0.2 },
// A slower sweep across the cells, for a one-off credential screen.
playful: { stagger: 0.038, crossSeconds: 0.22, slashSeconds: 0.28 },
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** Monospace with no extra tracking: every cell is exactly 1ch wide, so
* a dot and the character it stands in for occupy the same box and the
* native caret lands where the reader expects. */
const CELL_FONT =
"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
export default function PasswordRevealToggle({
variant = "default",
label = "Password",
defaultValue = "harbour-42-lantern",
placeholder = "Enter your password",
accent = "#5B5BD6",
onVisibilityChange,
}: PasswordRevealToggleProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [value, setValue] = useState(defaultValue);
const [visible, setVisible] = useState(false);
const [fieldRing, setFieldRing] = useState(false);
const [buttonRing, setButtonRing] = useState(false);
const inputId = useId();
const characters = [...value];
const toggle = () => {
const next = !visible;
setVisible(next);
onVisibilityChange?.(next);
};
return (
<div style={{ width: 280, color: "inherit" }}>
<label
htmlFor={inputId}
style={{
display: "block",
marginBottom: 6,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.2,
opacity: 0.6,
}}
>
{label}
</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "9px 9px 9px 12px",
borderRadius: 10,
border: `1px solid ${fieldRing ? accent : tone(15)}`,
background: tone(6),
boxShadow: fieldRing ? `0 0 0 3px ${tone(14)}` : "none",
}}
>
<div style={{ position: "relative", flex: 1, minWidth: 0, height: 20 }}>
{/* What the reader actually sees: one cell per character, each
holding a dot and its glyph stacked in the same box. */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
overflow: "hidden",
fontFamily: CELL_FONT,
fontSize: 14,
letterSpacing: 0,
lineHeight: "20px",
pointerEvents: "none",
}}
>
{characters.length === 0 && (
<span style={{ opacity: 0.38, fontFamily: "inherit" }}>
{placeholder}
</span>
)}
{characters.map((character, index) => {
// The wave runs outward on reveal and back on hide, so the
// two directions are legibly the same gesture reversed.
const delay = reduceMotion
? 0
: (visible ? index : characters.length - 1 - index) *
cfg.stagger;
const cross = {
duration: reduceMotion ? 0.1 : cfg.crossSeconds,
delay,
ease: "easeOut" as const,
};
return (
<span
key={index}
style={{
position: "relative",
display: "inline-block",
flex: "0 0 auto",
width: "1ch",
height: 20,
}}
>
<motion.span
initial={false}
animate={{ opacity: visible ? 0 : 0.75 }}
transition={cross}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
}}
>
•
</motion.span>
<motion.span
initial={false}
animate={{ opacity: visible ? 1 : 0 }}
transition={cross}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
}}
>
{character}
</motion.span>
</span>
);
})}
</div>
<input
id={inputId}
type={visible ? "text" : "password"}
value={value}
onChange={(event) => setValue(event.target.value)}
onFocus={(event) =>
setFieldRing(event.currentTarget.matches(":focus-visible"))
}
onBlur={() => setFieldRing(false)}
autoComplete="current-password"
spellCheck={false}
style={{
position: "relative",
width: "100%",
height: 20,
padding: 0,
margin: 0,
fontFamily: CELL_FONT,
fontSize: 14,
letterSpacing: 0,
lineHeight: "20px",
// The input keeps its real type and its real value; only
// its own glyphs are transparent, because the crossfading
// cells above are what gets read. The caret stays visible
// and stays the page's ink.
color: "transparent",
caretColor: "currentColor",
background: "transparent",
border: "none",
outline: "none",
}}
/>
</div>
<button
type="button"
onClick={toggle}
aria-pressed={visible}
aria-controls={inputId}
aria-label={visible ? "Hide password" : "Show password"}
onFocus={(event) =>
setButtonRing(event.currentTarget.matches(":focus-visible"))
}
onBlur={() => setButtonRing(false)}
style={{
display: "grid",
placeItems: "center",
flex: "0 0 auto",
width: 26,
height: 26,
padding: 0,
color: visible ? accent : "inherit",
background: "transparent",
border: "none",
borderRadius: 7,
cursor: "pointer",
boxShadow: buttonRing ? `0 0 0 3px ${tone(20)}` : "none",
outline: "none",
transition: "color 160ms ease-out",
}}
>
<svg width="17" height="17" viewBox="0 0 18 18" fill="none" aria-hidden>
<path
d="M1.6 9S4.5 4 9 4s7.4 5 7.4 5-2.9 5-7.4 5S1.6 9 1.6 9Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
opacity="0.8"
/>
<circle cx="9" cy="9" r="2.1" stroke="currentColor" strokeWidth="1.4" />
{/* The slash is the state, so it draws itself on rather than
appearing: the eye is being closed, not swapped. */}
<motion.path
d="M3.2 15 14.8 3"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
initial={false}
animate={{ pathLength: visible ? 1 : 0, opacity: visible ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0 : cfg.slashSeconds,
ease: "easeOut",
}}
/>
</svg>
</button>
</div>
<div
role="status"
style={{ marginTop: 6, fontSize: 11, opacity: 0.45, minHeight: 15 }}
>
{visible ? "Password is visible on screen" : "Password is hidden"}
</div>
</div>
);
}About this pattern
Checking what you typed, without the field blinking. Each masked cell crossfades to the character it was standing in for, left to right, and hiding runs the same wave backwards so the two directions read as one gesture reversed. The eye does not swap for a different icon — its slash draws itself on. The field stays a real input whose type still switches between password and text, so managers and assistive technology behave normally; only its own glyphs are transparent, because the crossfading cells above are what gets read. Monospace cells keep a dot and its character in exactly the same box.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Settings
Concealed values reveal in place with the eye control changing state.
Related patterns
- Radio Select DotThe chosen dot springs out of its ring while the previous one lets go a little faster.
- Checkbox Check DrawThe box fills from its center and the tick strokes itself in over the fill.
- Conditional Section RevealPicking an option unfolds the extra fields it requires, the section easing its height open.