Password Input
April 2025
A password input component with an animated hide/reveal interaction and animated SVG icon button. Accessible and interruptible. Built with Motion and Tailwind.
Example
*(required)
Code
Main component
/**
* A password input component with an animated reveal/hide functionality.
* Features:
* - Animated character masking using bullet points
* - Show/hide password toggle with eye icon
* - Synchronized scrolling between input and masked overlay
* - Keyboard and mouse focus handling
*/
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { TextRoll } from "./text-roll";
import { EyeIcon } from "./eye-icon";
export function PasswordInput() {
// State for password visibility and value
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [value, setValue] = useState("");
const [scrollLeft, setScrollLeft] = useState(0);
// Refs for DOM elements and animation frame
const inputRef = useRef<HTMLInputElement>(null);
const frameRef = useRef<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// IDs for accessibility
const inputId = "password-input";
const toggleButtonId = "password-toggle";
// Synchronizes scroll position between the input and the masked overlay
const syncScroll = useCallback(() => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
}
frameRef.current = requestAnimationFrame(() => {
if (inputRef.current) {
setScrollLeft(inputRef.current.scrollLeft);
}
});
}, []);
// Cleanup animation frame on component unmount
useEffect(() => {
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
}
};
}, []);
// Event handlers for input synchronization
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
syncScroll();
};
const handleSelect = syncScroll;
const handleKeyUp = syncScroll;
const handleClick = syncScroll;
// Mouse interaction handlers for focus styling
const handleMouseDown = useCallback(() => {
// Add a data attribute when mouse is used for focus
containerRef.current?.setAttribute("data-mouse-focus", "true");
}, []);
const handleBlur = useCallback(() => {
// Remove the data attribute when input loses focus
containerRef.current?.removeAttribute("data-mouse-focus");
}, []);
return (
<div className="flex flex-col justify-center">
<div className="mb-0.5">
<label htmlFor={inputId} className="font-medium text-sm text-neutral-800 dark:text-neutral-100">
Password
</label>
<span aria-hidden="true" className="text-sm text-neutral-500 dark:text-neutral-300">
*
</span>
<span className="sr-only">(required)</span>
</div>
<div ref={containerRef} className="w-full max-w-64 relative">
{/* Actual password input field - transparent text */}
<input
ref={inputRef}
value={value}
required
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
type="password"
id={inputId}
aria-label="Password"
placeholder="Password"
aria-describedby={toggleButtonId}
onChange={handleChange}
onSelect={handleSelect}
onKeyUp={handleKeyUp}
onClick={handleClick}
onMouseDown={handleMouseDown}
onBlur={handleBlur}
className="flex h-13 w-full rounded-[14px] bg-neutral-100 dark:bg-neutral-800 tabular-nums font-code text-transparent caret-neutral-800 dark:caret-neutral-100 pl-5 py-2 pr-12 text-[17px] transition-colors placeholder:text-neutral-400 dark:placeholder:text-neutral-500 focus-visible:outline-none [&:focus-visible:not([data-mouse-focus]_&)]:ring-2 [&:focus-visible:not([data-mouse-focus]_&)]:ring-neutral-500"
/>
{/* Animated text overlay */}
<div
aria-hidden
className="font-code tabular-nums absolute inset-0 pl-5 py-2 pr-12 text-[17px] flex items-center pointer-events-none"
>
<div className="overflow-hidden w-full flex">
<TextRoll
className="whitespace-nowrap w-full leading-none"
initialText={value}
rollingText={value
.split("")
.map((_, i) => "•")
.join("")}
isRolling={!isPasswordVisible}
scrollLeft={scrollLeft}
/>
</div>
</div>
{/* Show/hide password toggle button */}
<div className="absolute top-1/2 -translate-y-1/2 right-3">
<motion.button
type="button"
aria-label={!isPasswordVisible ? "Show password" : "Hide password"}
id={toggleButtonId}
aria-controls={inputId}
aria-pressed={isPasswordVisible}
className={cn(
"size-7 flex justify-center items-center rounded-md transition-colors duration-200 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-500",
{
"text-neutral-600 dark:text-neutral-400": !isPasswordVisible,
"text-neutral-900 dark:text-neutral-100": isPasswordVisible,
}
)}
onClick={() => {
setIsPasswordVisible(!isPasswordVisible);
}}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", bounce: 0 }}
>
<EyeIcon open={isPasswordVisible} size={20} />
</motion.button>
</div>
</div>
</div>
);
}
Text roll component
/**
* TextRoll is a component that creates a rolling/flipping text animation effect.
* It's used for password masking animations, where characters flip between
* their actual value and bullet points (•).
*/
"use client";
import {
motion,
VariantLabels,
Target,
TargetAndTransition,
Transition,
} from "motion/react";
export type TextRollProps = {
// The text to show initially or when not rolling
initialText: string;
// The text to show during the rolling animation
rollingText: string;
// Controls whether the rolling animation is active
isRolling: boolean;
// Optional function to customize the delay for each character's enter animation
getEnterDelay?: (index: number) => number;
// Optional function to customize the delay for each character's exit animation
getExitDelay?: (index: number) => number;
className?: string;
// Custom transition settings for the animation
transition?: Transition;
// Custom animation variants for enter/exit states
variants?: {
enter: {
initial: Target | VariantLabels | boolean;
animate: TargetAndTransition | VariantLabels;
};
exit: {
initial: Target | VariantLabels | boolean;
animate: TargetAndTransition | VariantLabels;
};
};
onAnimationComplete?: () => void;
// Horizontal scroll position to sync with input field
scrollLeft: number;
};
export function TextRoll({
initialText,
rollingText,
isRolling,
getEnterDelay = (i) => i * 0.045,
getExitDelay = (i) => i * 0.045,
className,
transition = { duration: 0.45, ease: [0.645, 0.045, 0.355, 1] },
onAnimationComplete,
variants,
scrollLeft = 0,
}: TextRollProps) {
// Default 3D flip animation variants if none provided
const defaultVariants = {
enter: {
initial: { rotateX: 0, opacity: 1, filter: "blur(0px)" },
animate: { rotateX: 90, opacity: 0, filter: "blur(1px)" },
},
exit: {
initial: { rotateX: 90, opacity: 0, filter: "blur(1px)" },
animate: { rotateX: 0, opacity: 1, filter: "blur(0px)" },
},
} as const;
// Ensure both texts have the same length by padding with spaces
const maxLength = Math.max(initialText.length, rollingText.length);
const initialLetters = initialText.padEnd(maxLength, " ").split("");
const rollingLetters = rollingText.padEnd(maxLength, " ").split("");
return (
<motion.span
style={{
transform: `translateX(-${scrollLeft}px)`,
transition: "transform 0s", // Instant scroll sync
whiteSpace: "nowrap",
}}
className={className}
>
{initialLetters.map((_, i) => {
const initialLetter = initialLetters[i];
const rollingLetter = rollingLetters[i];
return (
<span
key={i}
className="relative inline-block [perspective:10000px] [transform-style:preserve-3d] [width:auto]"
aria-hidden="true"
>
{/* Top half of the flipping animation */}
<motion.span
className="absolute inline-block [backface-visibility:hidden] [transform-origin:50%_25%]"
initial={false}
animate={
isRolling
? variants?.enter?.animate ?? defaultVariants.enter.animate
: variants?.enter?.initial ?? defaultVariants.enter.initial
}
transition={{
...transition,
delay: getEnterDelay(i),
}}
>
{initialLetter === " " ? "\u00A0" : initialLetter}
</motion.span>
{/* Bottom half of the flipping animation */}
<motion.span
className="absolute inline-block [backface-visibility:hidden] [transform-origin:50%_125%]"
initial={false}
animate={
isRolling
? variants?.exit?.animate ?? defaultVariants.exit.animate
: variants?.exit?.initial ?? defaultVariants.exit.initial
}
transition={{
...transition,
delay: getExitDelay(i),
}}
onAnimationComplete={
initialLetters.length === i + 1
? onAnimationComplete
: undefined
}
>
{rollingLetter === " " ? "\u00A0" : rollingLetter}
</motion.span>
{/* Invisible span to maintain proper spacing */}
<span className="invisible">
{initialLetter === " " ? "\u00A0" : initialLetter}
</span>
</span>
);
})}
{/* Screen reader text for accessibility */}
<span className="sr-only">{isRolling ? rollingText : initialText}</span>
</motion.span>
);
}
Animated eye icon component
/**
* An animated eye icon component that smoothly transitions between open and closed states.
* Features:
* - Morphing animation between open and closed eye states
* - Animated strike-through line for the closed state
* - Smooth pupil animation using Flubber for path interpolation
*/
"use client";
import type { Variants } from "motion/react";
import type { HTMLAttributes } from "react";
import { useEffect } from "react";
import { interpolate } from "flubber";
import {
animate,
motion,
MotionValue,
useMotionValue,
useTransform,
useAnimation,
} from "motion/react";
import { cn } from "@/lib/utils";
export interface EyeOffIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface EyeOffIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
open: boolean;
}
// Animation variants for the strike-through line
const pathVariants: Variants = {
normal: {
pathLength: 1,
opacity: 1,
pathOffset: 0,
transition: { duration: 0.5, ease: [0.19, 1, 0.22, 1] },
},
animate: {
pathLength: 0,
opacity: 0,
pathOffset: 1,
transition: { duration: 0.5, ease: [0.19, 1, 0.22, 1] },
},
};
export function EyeIcon({ onMouseEnter, onMouseLeave, className, size = 28, open, ...props }: EyeOffIconProps) {
const controls = useAnimation();
// Setup morphing animation for the pupil
const progress = useMotionValue(0);
const pupilPath = useFlubber(progress, pupilPaths);
// Animate between open and closed states
useEffect(() => {
let animation;
if (open) {
controls.start("animate");
animation = animate(progress, 1, {
duration: 0.25,
ease: [0.19, 1, 0.22, 1],
});
} else {
controls.start("normal");
animation = animate(progress, 0, {
duration: 0.25,
ease: [0.19, 1, 0.22, 1],
});
}
return () => animation.stop();
}, [open, controls, progress]);
return (
<div
className={cn(
`select-none flex items-center justify-center`,
className
)}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
{/* Animated pupil */}
<motion.path d={pupilPath} />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
{/* Full eye shape for open state */}
<path
d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
className={cn("opacity-0 transition-opacity duration-200", {
"opacity-100": open,
})}
/>
{/* Animated strike-through line */}
<motion.path
variants={pathVariants}
d="m2 2 20 20"
animate={controls}
/>
</svg>
</div>
);
}
// SVG paths for pupil states
const pupilOpenPath =
"M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z";
const pupilClosedPath = "M14.084 14.158a3 3 0 0 1-4.242-4.242";
const pupilPaths = [pupilClosedPath, pupilOpenPath];
EyeIcon.displayName = "EyeIcon";
// Helper function to get array indices for Flubber interpolation
const getIndex = (_: string, index: number) => index;
/**
* Custom hook that uses Flubber to smoothly interpolate between SVG paths.
* Creates a fluid morphing animation between the closed and open pupil states.
*/
function useFlubber(progress: MotionValue<number>, paths: string[]) {
return useTransform(progress, paths.map(getIndex), paths, {
mixer: (a, b) => interpolate(a, b, { maxSegmentLength: 0.1 }),
});
}