Forms

Input

Text field with a floating label

Preview

Name
size
type
label
required
error
disabled

Code

import css from "./Input.module.css";
import { useId, useRef, useState } from "react";

type Size = "xs" | "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface InputProps {
  type: "text" | "number" | "email" | "password";
  name: string;
  autoComplete?: string;
  size: Size | ResponsiveSize;
  label: string;
  required?: boolean;
  error?: boolean;
  disabled?: boolean;
  value: string;
  onChange: (value: string) => void;
}

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function Input({
  type,
  name,
  autoComplete,
  size,
  label,
  required,
  error,
  disabled,
  value,
  onChange,
}: InputProps) {
  const [focused, setFocused] = useState(false);
  const [tabbed, setTabbed] = useState(false);
  const pointer = useRef(false);
  const fieldId = useId();
  const active = focused || value.length > 0;

  const onFocus = () => {
    setFocused(true);
    setTabbed(!pointer.current);
  };

  const onBlur = () => {
    setFocused(false);
    setTabbed(false);
    pointer.current = false;
  };

  const onPointerDown = () => {
    pointer.current = true;
  };

  const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) =>
    onChange(ev.target.value);

  return (
    <div
      className={`
        ${css.input}
        ${getSizeClasses(size)}
        ${active ? css.active : ""}
        ${tabbed ? css.tabbed : ""}
        ${error ? css.error : ""}
        ${disabled ? css.disabled : ""}
      `}
      onPointerDown={onPointerDown}
    >
      <input
        id={fieldId}
        type={type}
        name={name}
        autoComplete={autoComplete}
        value={value}
        required={required}
        disabled={disabled}
        aria-invalid={error}
        onFocus={onFocus}
        onBlur={onBlur}
        onChange={handleChange}
      />
      <fieldset className={css.input_fieldset}>
        <legend className={css.fieldset_legend}>{label}</legend>
      </fieldset>
      <label htmlFor={fieldId} className={css.input_label}>
        {label}
        {required && <span aria-hidden="true">*</span>}
      </label>
    </div>
  );
}

Props

PropTypeRequiredDescription
type"text" | "number" | "email" | "password"requiredNative field type.
namestringrequiredField name, for forms and browser autofill.
autoCompletestring—Autocomplete hint for the browser (e.g. "email").
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredField height and its text size. Fixed or responsive.
labelstringrequiredFloating label that rises on focus or when filled.
requiredboolean—Marks the field as required (adds * to the label and the native required).
errorboolean—Visual error state (red border, aria-invalid).
disabledboolean—Disables the field (not editable, reduced opacity).
valuestringrequiredField value (controlled).
onChange(value: string) => voidrequiredCallback with the new value on every change.