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за по-добра производителност