UI/UI
Input
Displays a form input field with enhanced features like password visibility toggle, clearable functionality, and Zod validation support.
<Input placeholder="Enter your text here" />
Installation
Install following dependencies:
npm install class-variance-authority
pnpm add class-variance-authority
yarn add class-variance-authority
bun add class-variance-authority
Copy and paste the following code into your project.
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Eye, EyeOff, X } from "lucide-react";
const inputVariants = cva(
"flex w-full rounded-[var(--radius)] border border-[hsl(var(--hu-border))] bg-[hsl(var(--hu-input))] px-3 py-2 text-sm ring-offset-[hsl(var(--hu-background))] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[hsl(var(--hu-muted-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--hu-ring))] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all",
{
variants: {
variant: {
default: "border-[hsl(var(--hu-border))]",
destructive:
"border-[hsl(var(--hu-destructive))] focus-visible:ring-[hsl(var(--hu-destructive))]",
ghost:
"border-transparent bg-[hsl(var(--hu-accent))] focus-visible:bg-[hsl(var(--hu-input))] focus-visible:border-[hsl(var(--hu-border))]",
},
size: {
default: "h-9 px-3 py-2",
sm: "h-8 px-2 py-1 text-xs",
lg: "h-10 px-4 py-2",
xl: "h-12 px-6 py-3 text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
VariantProps<typeof inputVariants> {
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
error?: boolean;
clearable?: boolean;
onClear?: () => void;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
className,
variant,
size,
type = "text",
leftIcon,
rightIcon,
error,
clearable,
onClear,
value,
...props
},
ref,
) => {
const [showPassword, setShowPassword] = React.useState(false);
const [internalValue, setInternalValue] = React.useState(
props.defaultValue || "",
);
const inputVariant = error ? "destructive" : variant;
const isPassword = type === "password";
const actualType = isPassword && showPassword ? "text" : type;
// Determine if this is a controlled component
const isControlled = value !== undefined;
const inputValue = isControlled ? value : internalValue;
const showClearButton =
clearable && inputValue && String(inputValue).length > 0;
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) {
setInternalValue(e.target.value);
}
props.onChange?.(e);
};
const handleClear = () => {
const clearEvent = {
target: { value: "" },
currentTarget: { value: "" },
} as React.ChangeEvent<HTMLInputElement>;
if (!isControlled) {
setInternalValue("");
}
onClear?.();
props.onChange?.(clearEvent);
};
const togglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
return (
<div className="relative">
{leftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--hu-muted-foreground))] [&_svg]:size-4 [&_svg]:shrink-0 z-10">
{leftIcon}
</div>
)}
<input
type={actualType}
className={cn(
inputVariants({ variant: inputVariant, size, className }),
leftIcon && "pl-10",
(rightIcon || isPassword || showClearButton) && "pr-10",
)}
ref={ref}
{...(isControlled
? { value: inputValue }
: { defaultValue: props.defaultValue })}
onChange={handleInputChange}
{...(({ defaultValue, ...rest }) => rest)(props)}
/>
{/* Right side icons container */}
{(rightIcon || isPassword || showClearButton) && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1 z-10">
{/* Custom right icon */}
{rightIcon && (
<div className="text-[hsl(var(--hu-muted-foreground))] [&_svg]:size-4 [&_svg]:shrink-0">
{rightIcon}
</div>
)}
{/* Clear button */}
{showClearButton && (
<button
type="button"
onClick={handleClear}
className="text-[hsl(var(--hu-muted-foreground))] hover:text-[hsl(var(--hu-foreground))] transition-colors [&_svg]:size-4 [&_svg]:shrink-0"
tabIndex={-1}
>
<X />
</button>
)}
{/* Password visibility toggle */}
{isPassword && (
<button
type="button"
onClick={togglePasswordVisibility}
className="text-[hsl(var(--hu-muted-foreground))] hover:text-[hsl(var(--hu-foreground))] transition-colors [&_svg]:size-4 [&_svg]:shrink-0"
tabIndex={-1}
>
{showPassword ? <EyeOff /> : <Eye />}
</button>
)}
</div>
)}
</div>
);
},
);
Input.displayName = "Input";
export { Input, inputVariants };
npx hextaui@latest add input
pnpm dlx hextaui@latest add input
yarn dlx hextaui@latest add input
bun x hextaui@latest add input
Usage
import { Input } from "@/components/ui/input";
<div className="grid w-full max-w-sm items-center gap-1.5">
<Input id="email" type="email" placeholder="Enter your email" />
</div>
Examples
Sizes
<Input placeholder="Small input" size="sm" />
<Input placeholder="Default input" />
<Input placeholder="Large input" size="lg" />
<Input placeholder="Extra large input" size="xl" />
Variants
<Input placeholder="Default input" />
<Input placeholder="Ghost input" variant="ghost" />
<Input placeholder="Error input" error />
Password Input with Visibility Toggle
import { Lock } from "lucide-react";
<Input
type="password"
placeholder="Enter password"
leftIcon={<Lock />}
/>
<Input
type="password"
placeholder="Confirm password"
leftIcon={<Lock />}
/>
Clearable Input
import { Search } from "lucide-react";
<Input
placeholder="Clearable input"
clearable
defaultValue="Clear me!"
/>
<Input
placeholder="Search with clear"
leftIcon={<Search />}
clearable
defaultValue="Search term"
/>
With Icons
import { Search, User, Mail } from "lucide-react";
<Input
placeholder="Search..."
leftIcon={<Search />}
clearable
/>
<Input
placeholder="Username"
leftIcon={<User />}
/>
<Input
placeholder="Email"
type="email"
leftIcon={<Mail />}
clearable
/>
Input Types
<Input type="text" placeholder="Text input" clearable />
<Input type="email" placeholder="Email input" clearable />
<Input type="password" placeholder="Password input" />
<Input type="number" placeholder="Number input" clearable />
<Input type="url" placeholder="URL input" clearable />
<Input type="tel" placeholder="Phone input" clearable />
Disabled State
<Input placeholder="Disabled input" disabled />
<Input
placeholder="Disabled with icon"
leftIcon={<User />}
disabled
/>
<Input
type="password"
placeholder="Disabled password"
disabled
/>
File Input
<Input type="file" />
Form Examples with Zod Validation
Basic Login Form
"use client";
import { z } from "zod";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Mail, Lock } from "lucide-react";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type LoginForm = z.infer<typeof loginSchema>;
export function LoginForm() {
const [formData, setFormData] = useState<Partial<LoginForm>>({});
const [errors, setErrors] = useState<
Partial<Record<keyof LoginForm, string>>
>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const result = loginSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: Partial<Record<keyof LoginForm, string>> = {};
result.error.issues.forEach((issue) => {
if (issue.path[0]) {
fieldErrors[issue.path[0] as keyof LoginForm] = issue.message;
}
});
setErrors(fieldErrors);
return;
}
setErrors({});
console.log("Valid form data:", result.data);
};
const updateField =
(field: keyof LoginForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="email" required>
Email
</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
leftIcon={<Mail />}
clearable
value={formData.email || ""}
onChange={updateField("email")}
error={!!errors.email}
size="lg"
/>
{errors.email && (
<p className="text-[hsl(var(--hu-destructive))] text-xs mt-1">
{errors.email}
</p>
)}
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="password" required>
Password
</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
leftIcon={<Lock />}
value={formData.password || ""}
onChange={updateField("password")}
error={!!errors.password}
size="lg"
/>
{errors.password && (
<p className="text-[hsl(var(--hu-destructive))] text-xs mt-1">
{errors.password}
</p>
)}
</div>
<Button
variant="default"
size="lg"
type="submit"
className="w-full bg-[hsl(var(--hu-primary))] text-[hsl(var(--hu-primary-foreground))] py-2 px-4 rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity"
>
Sign In
</Button>
</form>
);
}
Registration Form with Complex Validation
"use client";
import { z } from "zod";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { User, Mail, Lock } from "lucide-react";
const registrationSchema = z
.object({
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be less than 20 characters")
.regex(
/^[a-zA-Z0-9_]+$/,
"Username can only contain letters, numbers, and underscores",
),
email: z.string().email("Invalid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.regex(
/[^A-Za-z0-9]/,
"Password must contain at least one special character",
),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type RegistrationForm = z.infer<typeof registrationSchema>;
export function RegistrationForm() {
const [formData, setFormData] = useState<Partial<RegistrationForm>>({});
const [errors, setErrors] = useState<
Partial<Record<keyof RegistrationForm, string>>
>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const result = registrationSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: Partial<Record<keyof RegistrationForm, string>> = {};
result.error.issues.forEach((issue) => {
if (issue.path[0]) {
fieldErrors[issue.path[0] as keyof RegistrationForm] = issue.message;
}
});
setErrors(fieldErrors);
return;
}
setErrors({});
console.log("Valid registration data:", result.data);
};
const updateField =
(field: keyof RegistrationForm) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="username" required>
Username
</Label>
<Input
id="username"
type="text"
placeholder="Enter username"
leftIcon={<User />}
clearable
value={formData.username || ""}
onChange={updateField("username")}
error={!!errors.username}
size="lg"
/>
{errors.username && (
<p className="text-[hsl(var(--hu-destructive))] text-xs mt-1">
{errors.username}
</p>
)}
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="email" required>
Email
</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
leftIcon={<Mail />}
clearable
value={formData.email || ""}
onChange={updateField("email")}
error={!!errors.email}
size="lg"
/>
{errors.email && (
<p className="text-[hsl(var(--hu-destructive))] text-xs mt-1">
{errors.email}
</p>
)}
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="password" required>
Password
</Label>
<Input
id="password"
type="password"
placeholder="Create password"
leftIcon={<Lock />}
value={formData.password || ""}
onChange={updateField("password")}
error={!!errors.password}
size="lg"
/>
{errors.password && (
<p className="text-[hsl(var(--hu-destructive))] text-xs mt-1">
{errors.password}
</p>
)}
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="confirmPassword" required>
Confirm Password
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm password"
leftIcon={<Lock />}
value={formData.confirmPassword || ""}
onChange={updateField("confirmPassword")}
error={!!errors.confirmPassword}
size="lg"
/>
{errors.confirmPassword && (
<p className="text-[hsl(var(--hu-destructive))] text-xs mt-1">
{errors.confirmPassword}
</p>
)}
</div>
<Button
type="submit"
size="lg"
className="w-full bg-[hsl(var(--hu-primary))] text-[hsl(var(--hu-primary-foreground))] py-2 px-4 rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity"
>
Create Account
</Button>
</form>
);
}
Form Validation with Zod
The Input component works excellently with Zod for type-safe form validation:
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
// Use with error state for visual feedback
<Input type="email" error={!!errors.email} onChange={handleChange} />;
Props
Prop | Type | Default |
---|---|---|
onClear? | () => void | undefined |
clearable? | boolean | false |
error? | boolean | false |
rightIcon? | React.ReactNode | undefined |
leftIcon? | React.ReactNode | undefined |
size? | "sm" | "default" | "lg" | "xl" | "default" |
variant? | "default" | "destructive" | "ghost" | "default" |
Edit on GitHub
Last updated on