Underline Input
Original · freeminimal underline inputs with animated bar
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/inp-underline.json"use client";
import { type ChangeEvent, type ReactNode, useId, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type IconProps = { className?: string };
function IconUser({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
</svg>
);
}
function IconMail({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="m3.5 7 8.5 5.5L20.5 7" />
</svg>
);
}
function IconLock({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="4.5" y="10" width="15" height="10" rx="2.5" />
<path d="M8 10V7.5a4 4 0 0 1 8 0V10" />
</svg>
);
}
function IconSearch({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.35-4.35" />
</svg>
);
}
function IconEye({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function IconEyeOff({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M3 3l18 18" />
<path d="M10.6 10.6a3 3 0 0 0 4.2 4.2" />
<path d="M9.9 4.6A9.9 9.9 0 0 1 12 4.5c6.5 0 10 7 10 7a17.2 17.2 0 0 1-3.2 4.1" />
<path d="M6.3 6.3A17 17 0 0 0 2 11.5s3.5 7 10 7a9.9 9.9 0 0 0 4-.8" />
</svg>
);
}
function IconClose({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
}
function IconCheck({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m4 12.5 5 5L20 6.5" />
</svg>
);
}
function scorePassword(pw: string): number {
let s = 0;
if (pw.length >= 8) s++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;
if (/\d/.test(pw)) s++;
if (/[^A-Za-z0-9]/.test(pw)) s++;
return s;
}
type FieldProps = {
label: string;
name: string;
value: string;
onChange: (value: string) => void;
type?: string;
inputMode?: "text" | "email" | "search" | "numeric" | "tel" | "url";
autoComplete?: string;
helper?: string;
error?: string;
valid?: boolean;
leading?: ReactNode;
trailing?: ReactNode;
multiline?: boolean;
rows?: number;
onBlur?: () => void;
};
const LABEL_MOVE =
"pointer-events-none absolute left-0 top-5 origin-left text-[15px] transition-all duration-200 ease-out peer-focus:top-0 peer-focus:text-xs peer-focus:font-medium peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:text-xs peer-[:not(:placeholder-shown)]:font-medium";
const FIELD_BASE =
"peer block w-full appearance-none border-0 bg-transparent px-0 pt-5 pb-1.5 text-[15px] leading-6 text-slate-900 outline-none transition-colors placeholder:text-transparent focus-visible:rounded-lg focus-visible:bg-indigo-500/[0.04] focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:text-slate-100 dark:focus-visible:ring-indigo-400/40";
function UnderlineField(props: FieldProps) {
const reduce = useReducedMotion();
const [focused, setFocused] = useState(false);
const rawId = useId();
const id = `inpud-${rawId}`;
const helperId = `${id}-desc`;
const hasError = Boolean(props.error);
const isValid = Boolean(props.valid) && !hasError;
const active = focused || hasError || isValid;
const barColor = hasError
? "bg-rose-500 dark:bg-rose-400"
: isValid
? "bg-emerald-500 dark:bg-emerald-400"
: "bg-indigo-500 dark:bg-indigo-400";
const accentText = hasError
? "text-rose-500 dark:text-rose-400"
: isValid
? "text-emerald-500 dark:text-emerald-400"
: focused
? "text-indigo-500 dark:text-indigo-400"
: "text-slate-400 dark:text-slate-500";
const labelColor = hasError
? "text-rose-600 dark:text-rose-400"
: isValid
? "text-emerald-600 dark:text-emerald-400"
: "text-slate-500 peer-focus:text-indigo-600 dark:text-slate-400 dark:peer-focus:text-indigo-400";
const describedBy = hasError || props.helper ? helperId : undefined;
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => props.onChange(e.currentTarget.value);
const handleBlur = () => {
setFocused(false);
props.onBlur?.();
};
return (
<div>
<div className="relative">
<div className="flex items-end gap-3">
{props.leading ? <span aria-hidden className={`pb-2 transition-colors ${accentText}`}>{props.leading}</span> : null}
<div className="relative flex-1">
{props.multiline ? (
<textarea
id={id}
name={props.name}
rows={props.rows ?? 3}
value={props.value}
onChange={handleChange}
onFocus={() => setFocused(true)}
onBlur={handleBlur}
placeholder=" "
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
className={`${FIELD_BASE} resize-none`}
/>
) : (
<input
id={id}
name={props.name}
type={props.type ?? "text"}
inputMode={props.inputMode}
autoComplete={props.autoComplete}
value={props.value}
onChange={handleChange}
onFocus={() => setFocused(true)}
onBlur={handleBlur}
placeholder=" "
aria-invalid={hasError || undefined}
aria-describedby={describedBy}
className={FIELD_BASE}
/>
)}
<label htmlFor={id} className={`${LABEL_MOVE} ${labelColor}`}>
{props.label}
</label>
</div>
{props.trailing ? <div className="pb-1.5">{props.trailing}</div> : null}
</div>
<span aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-slate-300 dark:bg-slate-700" />
<motion.span
aria-hidden
initial={false}
animate={{ scaleX: active ? 1 : 0, opacity: active ? 1 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
className={`pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-center overflow-hidden rounded-full ${barColor}`}
>
{focused && !hasError && !reduce ? <span className="inpud-shimmer absolute inset-0" aria-hidden /> : null}
</motion.span>
</div>
<div className="mt-1.5 min-h-[1.1rem]">
<AnimatePresence initial={false} mode="wait">
{hasError || props.helper ? (
<motion.p
key={hasError ? "error" : "helper"}
id={helperId}
initial={reduce ? false : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: reduce ? 0 : 0.18 }}
className={`text-xs ${hasError ? "text-rose-600 dark:text-rose-400" : "text-slate-500 dark:text-slate-400"}`}
>
{props.error ?? props.helper}
</motion.p>
) : null}
</AnimatePresence>
</div>
</div>
);
}
export default function UnderlineInputField() {
const reduce = useReducedMotion();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [emailTouched, setEmailTouched] = useState(false);
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [search, setSearch] = useState("");
const [message, setMessage] = useState("");
const [submitted, setSubmitted] = useState(false);
const emailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]);
const pwScore = useMemo(() => scorePassword(password), [password]);
const pwLevel = password === "" ? 0 : pwScore <= 1 ? 1 : pwScore <= 3 ? 2 : 3;
const emailError = emailTouched && email.length > 0 && !emailValid ? "That doesn't look right — check the @ and the domain." : undefined;
const canSubmit = name.trim().length >= 2 && emailValid && pwLevel >= 2;
const strengthLabels = ["", "Weak — add length or a symbol.", "Fair — one more class helps.", "Strong password."];
const strengthColor = pwLevel === 1 ? "bg-rose-500 dark:bg-rose-400" : pwLevel === 2 ? "bg-amber-500 dark:bg-amber-400" : "bg-emerald-500 dark:bg-emerald-400";
const panel = "rounded-2xl border border-slate-200 bg-white/70 p-6 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50 sm:p-8";
const iconBtn =
"rounded-md p-1 text-slate-400 transition-colors hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-500 dark:hover:text-slate-300 dark:focus-visible:ring-offset-slate-900";
const styles = `
@keyframes inpud-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
@keyframes inpud-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.inpud-shimmer {
background-image: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.9) 50%, transparent 100%);
background-size: 200% 100%;
animation: inpud-shimmer 1.5s linear infinite;
}
.inpud-rise { animation: inpud-rise 0.55s cubic-bezier(0.22,1,0.36,1) both; }
@media (prefers-reduced-motion: reduce) {
.inpud-shimmer { animation: none; }
.inpud-rise { animation: none; }
}
`;
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-white to-slate-50 px-4 py-16 dark:from-slate-950 dark:to-slate-900 sm:py-24">
<style>{styles}</style>
<div
aria-hidden
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[36rem] max-w-full -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-400/20 via-violet-400/20 to-sky-400/20 blur-3xl dark:from-indigo-500/10 dark:via-violet-500/10 dark:to-sky-500/10"
/>
<div className="relative mx-auto max-w-5xl">
<div className={`mx-auto max-w-2xl text-center ${reduce ? "" : "inpud-rise"}`}>
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Inputs · Underline
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">Underline fields with an animated bar</h2>
<p className="mx-auto mt-3 max-w-xl text-base text-slate-600 dark:text-slate-400">
Minimal, borderless inputs. A single bar springs in on focus and shifts colour for validity — floating labels, keyboard-friendly toggles, and live feedback included.
</p>
</div>
<div className="mt-12 grid gap-8 lg:grid-cols-2">
<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) setSubmitted(true);
}}
className={panel}
>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Create your account</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">Start a 14-day trial. No card required.</p>
<div className="mt-6 space-y-6">
<UnderlineField
label="Full name"
name="name"
autoComplete="name"
leading={<IconUser className="h-5 w-5" />}
value={name}
onChange={(v) => {
setName(v);
setSubmitted(false);
}}
/>
<UnderlineField
label="Email address"
name="email"
type="email"
inputMode="email"
autoComplete="email"
leading={<IconMail className="h-5 w-5" />}
value={email}
onChange={(v) => {
setEmail(v);
setSubmitted(false);
}}
onBlur={() => setEmailTouched(true)}
valid={emailValid}
error={emailError}
trailing={emailValid ? <IconCheck className="h-5 w-5 text-emerald-500 dark:text-emerald-400" /> : undefined}
/>
<div>
<UnderlineField
label="Password"
name="password"
type={showPw ? "text" : "password"}
autoComplete="new-password"
leading={<IconLock className="h-5 w-5" />}
value={password}
onChange={(v) => {
setPassword(v);
setSubmitted(false);
}}
valid={pwLevel === 3}
trailing={
<button
type="button"
onClick={() => setShowPw((s) => !s)}
aria-pressed={showPw}
aria-label={showPw ? "Hide password" : "Show password"}
className={iconBtn}
>
{showPw ? <IconEyeOff className="h-5 w-5" /> : <IconEye className="h-5 w-5" />}
</button>
}
/>
<div className="mt-3 flex items-center gap-1.5" aria-hidden>
{[1, 2, 3].map((i) => (
<span key={i} className={`h-1 flex-1 rounded-full transition-colors ${i <= pwLevel ? strengthColor : "bg-slate-200 dark:bg-slate-800"}`} />
))}
</div>
<p className="mt-1.5 text-xs text-slate-500 dark:text-slate-400" aria-live="polite">
{pwLevel === 0 ? "Use 8+ characters with a number and a symbol." : strengthLabels[pwLevel]}
</p>
</div>
<div className="pt-1">
<button
type="submit"
disabled={!canSubmit}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-indigo-600 dark:focus-visible:ring-offset-slate-900"
>
Create account
</button>
</div>
<AnimatePresence initial={false}>
{submitted ? (
<motion.div
key="ok"
role="status"
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.2 }}
className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-2.5 text-sm font-medium text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300"
>
<IconCheck className="h-4 w-4 shrink-0" />
<span>Account created — check {emailValid ? email : "your inbox"} to confirm.</span>
</motion.div>
) : null}
</AnimatePresence>
</div>
</form>
<div className="space-y-8">
<div className={panel}>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Quick search</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">Clears with the button when you have a query.</p>
<div className="mt-6">
<UnderlineField
label="Search projects, people, files"
name="search"
type="search"
inputMode="search"
leading={<IconSearch className="h-5 w-5" />}
value={search}
onChange={setSearch}
helper={search.length > 0 ? `Searching for "${search}"` : 'Try "Q3 roadmap" or "invoice #1042".'}
trailing={
search.length > 0 ? (
<button type="button" onClick={() => setSearch("")} aria-label="Clear search" className={iconBtn}>
<IconClose className="h-5 w-5" />
</button>
) : undefined
}
/>
</div>
</div>
<div className={panel}>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Leave a note</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">The bar spans a multi-line field too.</p>
<div className="mt-6">
<UnderlineField
label="What are you building?"
name="message"
multiline
rows={4}
value={message}
onChange={(v) => setMessage(v.slice(0, 500))}
helper="Markdown is supported."
/>
<div className="mt-1 flex justify-end text-xs tabular-nums text-slate-400 dark:text-slate-500">
<span>
{message.length}
/500
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Set Input
Originaldefault, hover, focus, disabled, error text inputs

Floating Label Input
Originallabel that floats up on focus/fill

Filled Input
Originalfilled material-style inputs

Search Input
Originalsearch box with icon and clear

Search Suggest Input
Originalsearch with a live suggestions dropdown

Password Toggle Input
Originalpassword field with show/hide toggle

Textarea Autosize Input
Originaltextarea that grows with content

Addon Group Input
Originalinput group with leading/trailing addons

Currency Input
Originalcurrency input with formatting

Tags Input
Originaltag input, add and remove chips

OTP Input
Original6-box one-time-code input with paste

Phone Input
Originalphone input with country prefix select

