Componentform1.3 KB gzipZero dependencies

EmailInput

Email input with live validation and typo domain suggestions

Preview

Live Preview

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add email-input

Or install the full npm package:

npm install bigbullui
import { EmailInput } from "@/components/ui/email-input";

<EmailInput defaultValue="test@example.com" validate onValueChange={(v) => {}} />

Usage guidance

Use EmailInput when a ticket stub surface needs Email input with live validation and typo domain suggestions Keep props minimal and prefer composition with sibling stubs.

Avoid EmailInput for long form flows or dense data grids where a dedicated layout block fits better.

Source code

email-input.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface EmailInputProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  validate?: boolean;
  className?: string;
}

const SUGGESTION_DOMAINS = [
  { domain: "gmail.com", suggestion: "DID YOU MEAN" },
  { domain: "yahoo.com", suggestion: "DID YOU MEAN" },
  { domain: "outlook.com", suggestion: "DID YOU MEAN" },
];

const validateEmail = (email: string) => {
  const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return re.test(email);
};

export function EmailInput({
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  validate = true,
  className,
}: EmailInputProps) {
  const [internalValue, setInternalValue] = React.useState(defaultValue);
  const [touched, setTouched] = React.useState(false);
  const [error, setError] = React.useState(false);
  const [showSuggestion, setShowSuggestion] = React.useState(false);
  const [suggestionDomain, setSuggestionDomain] = React.useState("");

  const isControlled = controlledValue !== undefined;
  const currentValue = isControlled ? controlledValue : internalValue;

  React.useEffect(() => {
    if (isControlled) {
      setInternalValue(controlledValue);
      setError(false);
      setTouched(false);
      setShowSuggestion(false);
    }
  }, [isControlled, controlledValue]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const val = e.target.value;
    setInternalValue(val);
    setError(false);
    setShowSuggestion(false);
    onValueChange?.(val);
  };

  const handleBlur = () => {
    setTouched(true);
    if (validate && !validateEmail(currentValue)) {
      setError(true);
    }
  };

  const detectDomain = () => {
    const match = currentValue.match(/@([a-zA-Z0-9.-]+)$/);
    if (match) {
      const domain = match[1];
      const suggestion = SUGGESTION_DOMAINS.find((s) => s.domain === domain);
      if (suggestion) {
        setSuggestionDomain(suggestion.domain);
        setShowSuggestion(true);
      } else {
        setShowSuggestion(false);
      }
    } else {
      setShowSuggestion(false);
    }
  };

  React.useEffect(detectDomain, [currentValue]);

  const classes = cn(
    "relative rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm transition-colors",
    "ring-2 ring-ring focus-visible:outline-none focus-visible:ring-2",
    className,
  );

  const errorClasses = cn(
    "ring-1 ring-destructive/50",
    error && "placeholder-destructive",
    !error && "",
  );

  return (
    <div className={cn("space-y-1.5", className)}>
      <input
        type="email"
        value={currentValue}
        onChange={handleChange}
        onBlur={handleBlur}
        className={classes}
        disabled={validate && error}
        aria-invalid={error || undefined}
        aria-describedby={error ? "email-error" : undefined}
      />
      {error && (
        <p id="email-error" className="text-[10px] uppercase tracking-[0.15em] text-destructive">
          Please enter a valid email address
        </p>
      )}
      {showSuggestion && !error && (
        <div
          onMouseLeave={() => setShowSuggestion(false)}
          className="absolute right-2 top-full mt-1 rounded-md bg-secondary p-1.5 shadow-xs text-[10px] uppercase tracking-[0.15em] text-secondary-foreground animate-[slide-in_0.2s_ease-out_both] z-20"
        >
          <span className="font-mono me-1">@</span>
          <span className="font-medium">{suggestionDomain}</span>
        </div>
      )}
    </div>
  );
}

EmailInput.displayName = "EmailInput";

Props

PropTypeDescription
value?string
defaultValue?string
onValueChange?(value: string) => void
validate?boolean
className?string