Web InnoventixFreeCode

Underline Input

Original · free

minimal underline inputs with animated bar

byWeb InnoventixReact + Tailwind
inpunderlineinputs
Open

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
inp-underline.tsx
"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

motion

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 quote

Similar components

Browse all →