// OTP Input <Input variation="otp" count={6} onOtpComplete={(otp) => console.log(otp)} /> // Password <Input variation="password" title="Create Password" placeholder="••••••••" color="primary" size="lg" /> // Search <Input variation="search" placeholder="Search products..." /> // Default with error <Input title="Email" type="email" error="Invalid email address" color="error" /> // Styled variations <Input color="secondary" size="sm" placeholder="Small input" /> <Input color="success" size="lg" placeholder="Large success state" />
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Eye, EyeOff, AlertCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// --- Utility ---
export function cn(...inputs: (string | undefined | null | false)[]) {
return inputs.filter(Boolean).join(' ');
}
// --- CVA Variants ---
const inputVariants = cva(
'relative w-full rounded-xl border-2 bg-white/80 backdrop-blur-sm transition-all duration-300 ease-out',
{
variants: {
color: {
primary: 'border-slate-900 focus:border-indigo-600 focus:ring-4 focus:ring-indigo-600/10',
secondary: 'border-slate-300 focus:border-slate-900 focus:ring-4 focus:ring-slate-900/10',
error: 'border-rose-500 focus:border-rose-600 focus:ring-4 focus:ring-rose-600/10',
success: 'border-emerald-600 focus:border-emerald-700 focus:ring-4 focus:ring-emerald-700/10',
ghost: 'border-transparent bg-slate-100/50 hover:bg-slate-100 focus:bg-white focus:border-slate-900',
},
size: {
sm: 'h-10 px-3 text-sm',
md: 'h-12 px-4 text-base',
lg: 'h-14 px-6 text-lg',
},
variation: {
default: '',
password: '',
otp: '',
search: '',
textarea: '',
},
},
defaultVariants: {
color: 'primary',
size: 'md',
variation: 'default',
},
}
);
const labelVariants = cva(
'mb-2 block font-bold tracking-tight text-slate-900',
{
variants: {
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
},
},
}
);
// --- Types ---
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'color'>,
VariantProps<typeof inputVariants> {
variation?: 'default' | 'password' | 'otp' | 'search';
title?: string;
error?: string;
count?: number; // for OTP
onOtpComplete?: (otp: string) => void;
}
// --- OTP Input Component ---
const OTPInput = React.forwardRef<HTMLInputElement, InputProps>(
({ count = 6, color, size, onOtpComplete, className, ...props }, ref) => {
const [otp, setOtp] = React.useState<string[]>(new Array(count).fill(''));
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
const handleChange = (index: number, value: string) => {
if (isNaN(Number(value))) return;
const newOtp = [...otp];
newOtp[index] = value.substring(value.length - 1);
setOtp(newOtp);
if (value && index < count - 1) {
inputRefs.current[index + 1]?.focus();
}
const otpValue = newOtp.join('');
if (otpValue.length === count) {
onOtpComplete?.(otpValue);
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !otp[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text').slice(0, count).split('');
const newOtp = [...otp];
pastedData.forEach((char, index) => {
if (index < count && !isNaN(Number(char))) {
newOtp[index] = char;
}
});
setOtp(newOtp);
const nextEmptyIndex = newOtp.findIndex((val) => !val);
inputRefs.current[nextEmptyIndex === -1 ? count - 1 : nextEmptyIndex]?.focus();
const otpValue = newOtp.join('');
if (otpValue.length === count) {
onOtpComplete?.(otpValue);
}
};
return (
<div className="flex gap-2 sm:gap-3" onPaste={handlePaste}>
{otp.map((digit, index) => (
<motion.input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
if (typeof ref === 'function') ref(el);
else if (ref) ref.current = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
className={cn(
inputVariants({ color, size, variation: 'otp' }),
'w-12 h-12 sm:w-14 sm:h-14 text-center text-2xl font-bold tracking-wider shadow-sm',
'focus:scale-110 focus:z-10',
className
)}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.05, type: 'spring', stiffness: 300 }}
{...props}
/>
))}
</div>
);
}
);
OTPInput.displayName = 'OTPInput';
// --- Password Input Component ---
const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
({ title, color, size, className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative group">
{title && (
<label className={cn(labelVariants({ size }))}>
{title}
</label>
)}
<div className="relative">
<input
ref={ref}
type={showPassword ? 'text' : 'password'}
className={cn(
inputVariants({ color, size, variation: 'password' }),
'pr-12 font-mono tracking-widest',
className
)}
{...props}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={cn(
'absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-lg',
'text-slate-500 hover:text-slate-900 hover:bg-slate-100',
'transition-colors duration-200'
)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
<AnimatePresence mode="wait">
<motion.div
key={showPassword ? 'eye' : 'eye-off'}
initial={{ rotate: -90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: 90, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</motion.div>
</AnimatePresence>
</button>
</div>
</div>
);
}
);
PasswordInput.displayName = 'PasswordInput';
// --- Search Input Component ---
const SearchInput = React.forwardRef<HTMLInputElement, InputProps>(
({ color, size, className, ...props }, ref) => {
return (
<div className="relative">
<input
ref={ref}
type="search"
className={cn(
inputVariants({ color: color || 'ghost', size, variation: 'search' }),
'pl-11',
className
)}
{...props}
/>
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
);
}
);
SearchInput.displayName = 'SearchInput';
// --- Main Input Component ---
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ variation = 'default', title, error, className, ...props }, ref) => {
// Render specialized variations
if (variation === 'otp') {
return <OTPInput ref={ref} variation={variation} {...props} />;
}
if (variation === 'password') {
return <PasswordInput ref={ref} variation={variation} title={title} {...props} />;
}
if (variation === 'search') {
return <SearchInput ref={ref} variation={variation} {...props} />;
}
// Default input
return (
<div className="w-full">
{title && (
<label className={cn(labelVariants({ size: props.size || 'md' }))}>
{title}
</label>
)}
<div className="relative">
<input
ref={ref}
className={cn(
inputVariants({
color: error ? 'error' : props.color,
size: props.size,
variation
}),
error && 'animate-shake',
className
)}
{...props}
/>
{error && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-rose-500"
>
<AlertCircle className="h-5 w-5" />
</motion.div>
)}
</div>
{error && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1.5 text-sm font-medium text-rose-600"
>
{error}
</motion.p>
)}
</div>
);
}
);
Input.displayName = 'Input';
export default Input;Views
Lines
Characters
Likes