Skip to content

Animated Password Reveal Component

Интерактивен компонент за показване/скриване на парола с анимирана eye иконка, която следва движението на мишката и плавна reveal анимация от ляво на дясно.

Функционалности

  • ✅ Eye иконка, която следва движението на мишката (parallax ефект) - работи независимо от разстоянието
  • ✅ Плавна reveal анимация от ляво на дясно при показване на паролата
  • ✅ Окото се затваря когато паролата е видима, и се отваря когато е скрита
  • ✅ Вертикална линия "|" от ляво на окото (между input полето и окото)
  • ✅ По-голяма eye иконка (h-5 w-5 вместо h-4 w-4)
  • ✅ Увеличено движение на иконката (12px максимален диапазон)

Файлове

1. Компонент: src/components/ui/password-input.tsx

tsx
import * as React from "react";
import { Eye } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "./input";

interface PasswordInputProps extends React.ComponentProps<"input"> {
  className?: string;
}

const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
  ({ className, ...props }, ref) => {
    const [showPassword, setShowPassword] = React.useState(false);
    const [eyePosition, setEyePosition] = React.useState({ x: 0, y: 0 });
    const inputRef = React.useRef<HTMLInputElement>(null);
    const containerRef = React.useRef<HTMLDivElement>(null);
    const eyeRef = React.useRef<HTMLButtonElement>(null);

    // Combine refs
    React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);

    const handleMouseMove = React.useCallback(
      (e: MouseEvent) => {
        if (!containerRef.current || !eyeRef.current) return;

        const container = containerRef.current;
        const rect = container.getBoundingClientRect();
        const eye = eyeRef.current;
        const eyeRect = eye.getBoundingClientRect();

        // Calculate mouse position relative to container
        const mouseX = e.clientX - rect.left;
        const mouseY = e.clientY - rect.top;

        // Calculate center of eye icon
        const eyeCenterX = eyeRect.left - rect.left + eyeRect.width / 2;
        const eyeCenterY = eyeRect.top - rect.top + eyeRect.height / 2;

        // Calculate distance from eye center to mouse
        const deltaX = mouseX - eyeCenterX;
        const deltaY = mouseY - eyeCenterY;

        // Calculate distance
        const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);

        // Limit movement range (maxRange pixels max movement)
        const maxRange = 12;
        const limitedDistance = Math.min(distance, maxRange);

        // Always calculate position - eye follows mouse anywhere in container
        if (distance > 0.1) {
          const angle = Math.atan2(deltaY, deltaX);
          const limitedX = Math.cos(angle) * limitedDistance;
          const limitedY = Math.sin(angle) * limitedDistance;

          setEyePosition({ x: limitedX, y: limitedY });
        } else {
          setEyePosition({ x: 0, y: 0 });
        }
      },
      []
    );

    const handleMouseLeave = React.useCallback(() => {
      setEyePosition({ x: 0, y: 0 });
    }, []);

    React.useEffect(() => {
      const container = containerRef.current;
      if (!container) return;

      container.addEventListener("mousemove", handleMouseMove);
      container.addEventListener("mouseleave", handleMouseLeave);

      return () => {
        container.removeEventListener("mousemove", handleMouseMove);
        container.removeEventListener("mouseleave", handleMouseLeave);
      };
    }, [handleMouseMove, handleMouseLeave]);

    return (
      <div
        ref={containerRef}
        className={cn("pw-password-container relative", className)}
      >
        <div className="pw-password-wrapper relative">
          <div className="pw-password-input-wrapper relative overflow-hidden rounded-md">
            <Input
              ref={inputRef}
              type="password"
              className="pw-password-input-base pr-12"
              {...props}
            />
            <div
              className={cn(
                "pw-password-reveal absolute inset-0 pointer-events-none overflow-hidden rounded-md",
                showPassword ? "pw-reveal-active" : ""
              )}
              style={{
                clipPath: showPassword ? "inset(0 0% 0 0%)" : "inset(0 100% 0 0%)",
                transition: "clip-path 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
              }}
            >
              <Input
                type="text"
                value={props.value as string}
                className="pw-password-reveal-input w-full h-full bg-background border-0 pr-12"
                readOnly
                tabIndex={-1}
                style={{ color: "inherit" }}
              />
            </div>
          </div>
        </div>
        <span className="pw-password-separator absolute right-12 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none z-10">
          |
        </span>
        <button
          ref={eyeRef}
          type="button"
          className="pw-password-toggle absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-sm transition-colors duration-200"
          onClick={(e) => {
            e.preventDefault();
            setShowPassword(!showPassword);
            // Focus back to input after toggle
            inputRef.current?.focus();
          }}
          tabIndex={-1}
          aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
        >
          <span
            className="pw-eye-icon inline-block transition-transform duration-300"
            style={{
              transform: `translate(${eyePosition.x}px, ${eyePosition.y}px)`,
            }}
          >
            <Eye
              className={cn(
                "pw-eye-icon-base h-5 w-5 transition-all duration-300",
                showPassword ? "pw-eye-closed" : "pw-eye-open"
              )}
            />
          </span>
        </button>
      </div>
    );
  }
);

PasswordInput.displayName = "PasswordInput";

export { PasswordInput };

2. CSS Стилове: Добавете в src/index.css

css
/* Password reveal animation styles */
.pw-password-container {
  position: relative;
}

.pw-password-wrapper {
  position: relative;
}

.pw-password-separator {
  font-size: 1rem;
  line-height: 1;
  user-select: none;
}

.pw-password-input-wrapper {
  position: relative;
}

.pw-password-input-base {
  padding-left: 2rem;
  padding-right: 3rem;
}

.pw-password-reveal {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: transparent;
  border: inherit;
}

.pw-password-reveal-input {
  padding-left: 2rem;
  padding-right: 3rem;
  color: inherit;
  font-size: inherit;
  line-height: inherit;
}

.pw-password-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  background: transparent;
  border: none;
  cursor: pointer;
  z-index: 10;
}

.pw-password-toggle:focus-visible {
  outline: none;
  border-radius: 0.125rem;
}

.pw-eye-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  will-change: transform;
}

.pw-eye-icon-base {
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.pw-eye-open {
  opacity: 1;
  transform: scale(1);
}

.pw-eye-closed {
  opacity: 0.4;
  transform: scale(0.9);
}

.pw-eye-closed svg {
  filter: blur(0.5px);
}

3. Употреба: Заменете Input с PasswordInput

tsx
import { PasswordInput } from "@/components/ui/password-input";

// Вместо:
<Input
  id="password"
  type="password"
  value={password}
  onChange={(e) => setPassword(e.target.value)}
/>

// Използвайте:
<PasswordInput
  id="password"
  value={password}
  onChange={(e) => setPassword(e.target.value)}
/>

Настройки

Промяна на размера на движение

В password-input.tsx, редактирайте maxRange:

tsx
const maxRange = 12; // pixels - променете стойността за повече/по-малко движение

Промяна на размера на иконката

В password-input.tsx, редактирайте размера на Eye иконката:

tsx
<Eye className={cn(
  "pw-eye-icon-base h-5 w-5 transition-all duration-300", // h-5 w-5 = 20px, променете на h-6 w-6 за по-голямо
  showPassword ? "pw-eye-closed" : "pw-eye-open"
)} />

Промяна на скоростта на reveal анимацията

В password-input.tsx, редактирайте transition:

tsx
transition: "clip-path 0.5s cubic-bezier(0.4, 0, 0.2, 1)", // 0.5s = 500ms, променете за по-бързо/по-бавно

Зависимости

  • lucide-react - за Eye иконката
  • @/lib/utils - за cn функцията (classnames utility)
  • @/components/ui/input - базовия Input компонент

Бележки

  • Всички класове са с префикс pw- за избягване на конфликти
  • Компонентът запазва всички props на стандартния input
  • Поддържа ref forwarding за интеграция с form библиотеки
  • Анимациите са оптимизирани с will-change и transform за по-добра производителност

Released under Commercial License