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 }),
  });
}