Components
Textarea
Textarea
Ultra-modern textarea component with auto-resize, character counting, resizable handles, and enhanced user experience features.
Basic Features
Basic Textarea
Characters: 0
With Description
Please share your thoughts about our service
Error State
Please enter some text
Disabled State
Auto-Resize Feature
This textarea will automatically grow as you type more content
0/1000
Try it: Type multiple lines of text above. The textarea will automatically adjust its height to fit the content.
Resizable Feature
You can manually resize this textarea by dragging the handle
Try it: Look for the resize handle (⋮) in the bottom-right corner and drag to resize the textarea.
Character Limits & Validation
Character Count
0/200
Limited Text (10-100 chars)
0/100
Variants
Default Variant
Filled Variant
Outlined Variant
Sizes
Interactive Form Demo
Contact Form
Tell us how we can help you
0/500
Keyboard Navigation
Try these keyboard shortcuts:
- Tab: Move focus between textareas
- Enter: Create new lines
- Ctrl+A: Select all text
- Ctrl+Z: Undo last action
Installation
1
Install the packages
npm i motion clsx tailwind-merge lucide-react2
Add util file
lib/util.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3
Copy and paste the following code into your project
textarea.tsx
"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useRef, useEffect, useId } from "react";
import { cn } from "@/lib/utils";
import { AlertCircle, GripVertical } from "lucide-react";
interface TextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'size'> {
label?: string;
description?: string;
error?: string;
maxLength?: number;
minLength?: number;
autoResize?: boolean;
resizable?: boolean;
showCount?: boolean;
size?: "sm" | "md" | "lg";
variant?: "default" | "filled" | "outlined";
}
const Textarea: React.FC<TextareaProps> = ({
label,
description,
error,
maxLength,
minLength,
autoResize = false,
resizable = false,
showCount = false,
size = "md",
variant = "default",
className,
value,
onChange,
rows = 3,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const [currentValue, setCurrentValue] = useState(value || "");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const textareaId = useId();
const hasError = !!error;
const sizeClasses = {
sm: "px-3 py-2 text-sm",
md: "px-4 py-3 text-base",
lg: "px-4 py-4 text-lg",
};
const variantClasses = {
default: "border border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:focus:border-blue-400",
filled: "border-transparent bg-gray-100 focus:bg-white focus:border-blue-500 focus:ring-blue-500/20 dark:bg-gray-800 dark:focus:bg-gray-900",
outlined: "border-2 border-gray-300 bg-transparent focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-600",
};
const errorClasses = "border-red-500 focus:border-red-500 focus:ring-red-500/20";
// Auto-resize functionality
useEffect(() => {
if (autoResize && textareaRef.current) {
const textarea = textareaRef.current;
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}
}, [currentValue, autoResize]);
// Handle value changes
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setCurrentValue(newValue);
if (onChange) {
onChange(e);
}
};
// Handle focus/blur
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
setIsFocused(true);
props.onFocus?.(e);
};
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
setIsFocused(false);
props.onBlur?.(e);
};
// Character count
const characterCount = currentValue.length;
const isOverLimit = maxLength && characterCount > maxLength;
const isUnderLimit = minLength && characterCount < minLength && characterCount > 0;
return (
<div className={cn("relative", className)}>
{/* Label */}
{label && (
<motion.label
htmlFor={textareaId}
className={cn(
"block text-sm font-medium mb-2 transition-colors duration-200",
hasError ? "text-red-600 dark:text-red-400" :
"text-gray-700 dark:text-gray-300"
)}
animate={{
scale: isFocused || currentValue ? 1 : 1,
y: isFocused || currentValue ? 0 : 0,
}}
transition={{ duration: 0.2 }}
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</motion.label>
)}
{/* Textarea Container */}
<div className="relative">
<motion.textarea
ref={textareaRef}
id={textareaId}
value={currentValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
rows={rows}
maxLength={maxLength}
minLength={minLength}
className={cn(
"w-full rounded-lg transition-all duration-200 bg-transparent border outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
sizeClasses[size],
variantClasses[variant],
hasError && errorClasses,
isFocused && "ring-2 ring-blue-500/20",
resizable && "resize",
autoResize && "overflow-hidden",
className
)}
animate={{
scale: isFocused ? 1.01 : 1,
boxShadow: isFocused
? "0 0 0 3px rgba(59, 130, 246, 0.1)"
: "0 0 0 0px rgba(0, 0, 0, 0)",
}}
transition={{ duration: 0.2 }}
{...props}
/>
{/* Resize Handle */}
{resizable && (
<motion.div
className="absolute bottom-2 right-2 cursor-se-resize opacity-50 hover:opacity-100 transition-opacity duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<GripVertical className="w-4 h-4 text-gray-400" />
</motion.div>
)}
{/* Character Count */}
{showCount && maxLength && (
<motion.div
className={cn(
"absolute bottom-2 right-2 text-xs transition-colors duration-200",
isOverLimit ? "text-red-500" :
characterCount > maxLength * 0.8 ? "text-yellow-500" :
"text-gray-400"
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{characterCount}/{maxLength}
</motion.div>
)}
{/* Focus Ring Animation */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 rounded-lg border-2 border-blue-500 pointer-events-none"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</div>
{/* Description */}
{description && (
<motion.p
className={cn(
"mt-2 text-sm transition-colors duration-200",
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-600 dark:text-gray-400"
)}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2, delay: 0.05 }}
>
{description}
</motion.p>
)}
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
</AnimatePresence>
{/* Validation Messages */}
<AnimatePresence>
{isUnderLimit && minLength && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-yellow-600 dark:text-yellow-400 flex items-center gap-1"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
Minimum {minLength} characters required
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Textarea;"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useRef, useEffect, useId } from "react";
import { cn } from "@/lib/utils";
import { AlertCircle, GripVertical } from "lucide-react";
interface TextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'size'> {
label?: string;
description?: string;
error?: string;
maxLength?: number;
minLength?: number;
autoResize?: boolean;
resizable?: boolean;
showCount?: boolean;
size?: "sm" | "md" | "lg";
variant?: "default" | "filled" | "outlined";
}
const Textarea: React.FC<TextareaProps> = ({
label,
description,
error,
maxLength,
minLength,
autoResize = false,
resizable = false,
showCount = false,
size = "md",
variant = "default",
className,
value,
onChange,
rows = 3,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const [currentValue, setCurrentValue] = useState(value || "");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const textareaId = useId();
const hasError = !!error;
const sizeClasses = {
sm: "px-3 py-2 text-sm",
md: "px-4 py-3 text-base",
lg: "px-4 py-4 text-lg",
};
const variantClasses = {
default: "border border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-800 dark:focus:border-blue-400",
filled: "border-transparent bg-gray-100 focus:bg-white focus:border-blue-500 focus:ring-blue-500/20 dark:bg-gray-800 dark:focus:bg-gray-900",
outlined: "border-2 border-gray-300 bg-transparent focus:border-blue-500 focus:ring-blue-500/20 dark:border-gray-600",
};
const errorClasses = "border-red-500 focus:border-red-500 focus:ring-red-500/20";
// Auto-resize functionality
useEffect(() => {
if (autoResize && textareaRef.current) {
const textarea = textareaRef.current;
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}
}, [currentValue, autoResize]);
// Handle value changes
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setCurrentValue(newValue);
if (onChange) {
onChange(e);
}
};
// Handle focus/blur
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
setIsFocused(true);
props.onFocus?.(e);
};
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
setIsFocused(false);
props.onBlur?.(e);
};
// Character count
const characterCount = currentValue.length;
const isOverLimit = maxLength && characterCount > maxLength;
const isUnderLimit = minLength && characterCount < minLength && characterCount > 0;
return (
<div className={cn("relative", className)}>
{/* Label */}
{label && (
<motion.label
htmlFor={textareaId}
className={cn(
"block text-sm font-medium mb-2 transition-colors duration-200",
hasError ? "text-red-600 dark:text-red-400" :
"text-gray-700 dark:text-gray-300"
)}
animate={{
scale: isFocused || currentValue ? 1 : 1,
y: isFocused || currentValue ? 0 : 0,
}}
transition={{ duration: 0.2 }}
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</motion.label>
)}
{/* Textarea Container */}
<div className="relative">
<motion.textarea
ref={textareaRef}
id={textareaId}
value={currentValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
rows={rows}
maxLength={maxLength}
minLength={minLength}
className={cn(
"w-full rounded-lg transition-all duration-200 bg-transparent border outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
sizeClasses[size],
variantClasses[variant],
hasError && errorClasses,
isFocused && "ring-2 ring-blue-500/20",
resizable && "resize",
autoResize && "overflow-hidden",
className
)}
animate={{
scale: isFocused ? 1.01 : 1,
boxShadow: isFocused
? "0 0 0 3px rgba(59, 130, 246, 0.1)"
: "0 0 0 0px rgba(0, 0, 0, 0)",
}}
transition={{ duration: 0.2 }}
{...props}
/>
{/* Resize Handle */}
{resizable && (
<motion.div
className="absolute bottom-2 right-2 cursor-se-resize opacity-50 hover:opacity-100 transition-opacity duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<GripVertical className="w-4 h-4 text-gray-400" />
</motion.div>
)}
{/* Character Count */}
{showCount && maxLength && (
<motion.div
className={cn(
"absolute bottom-2 right-2 text-xs transition-colors duration-200",
isOverLimit ? "text-red-500" :
characterCount > maxLength * 0.8 ? "text-yellow-500" :
"text-gray-400"
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{characterCount}/{maxLength}
</motion.div>
)}
{/* Focus Ring Animation */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 rounded-lg border-2 border-blue-500 pointer-events-none"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</div>
{/* Description */}
{description && (
<motion.p
className={cn(
"mt-2 text-sm transition-colors duration-200",
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-600 dark:text-gray-400"
)}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2, delay: 0.05 }}
>
{description}
</motion.p>
)}
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
</AnimatePresence>
{/* Validation Messages */}
<AnimatePresence>
{isUnderLimit && minLength && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-yellow-600 dark:text-yellow-400 flex items-center gap-1"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
Minimum {minLength} characters required
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default Textarea;4
Update the import paths to match your project setup
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | The value of the textarea. |
| onChange | (value: string) => void | () => {} | Callback function when textarea value changes. |
| placeholder | string | undefined | Placeholder text when textarea is empty. |
| label | string | undefined | Label text for the textarea. |
| description | string | undefined | Description text below the label. |
| disabled | boolean | false | Disable the textarea interaction. |
| required | boolean | false | Mark textarea as required. |
| error | string | undefined | Error message to display. |
| maxLength | number | undefined | Maximum number of characters allowed. |
| minLength | number | undefined | Minimum number of characters required. |
| rows | number | 3 | Number of visible text lines. |
| autoResize | boolean | false | Automatically resize textarea based on content. |
| resizable | boolean | false | Allow manual resizing with drag handles. |
| showCount | boolean | false | Show character count. |
| size | 'sm' | 'md' | 'lg' | 'md' | Size of the textarea. |
| variant | 'default' | 'filled' | 'outlined' | 'default' | Visual style variant. |