Advance component with cva & cn

// 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" />

By Aashutosh Shrestha3h ago (Apr 1, 2026)
React
nextjs
tailwindcss
CVA
component
advance

Advance component with cva & cn

typescript
'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

5

Lines

310

Characters

9,465

Likes

1

Details

Language
Typescript
Created
Apr 1, 2026
Updated
3h ago
Size
9.2 KB

Build your snippet library

Join thousands of developers organizing and sharing code snippets.