Improve form validation UX with Tailwind CSS, no Javascript

Aug 3, 2024

This is the boiler code we will be working with. For this demo, the input have the minLength and required attributes indicating the user must enter a value and the minimum input length must be at least 3.

This code has no validation

Code Playground
export default function Example() {
return (
  <div className="p-4 w-full h-screen ">
    <label htmlFor="name" className="text-sm font medium">
      First Name
    </label>
    <input
      required
      minLength={3}
      type="text"
      id="name"
      className="mt-1 w-full rounded-md text-sm border-gray-300 border shadow-sm h-8 px-2"
    />
  </div>
)
}

valid and invalid modifiers

We can add the valid: and invalid: modifiers to the input class to change the border color based on the user's input.

Make the border green if the input is valid and red if it's invalid

Code Playground
export default function Example() {
return (
  <div className="p-4 w-full h-screen ">
    <label htmlFor="name" className="text-sm font medium">
      First Name
    </label>
    <input
      required
      minLength={3}
      type="text"
      id="name"
      className="valid:border-green-500 invalid:border-red-500 mt-1 w-full rounded-md text-sm border-gray-300 border shadow-sm h-8 px-2"
    />
  </div>
)
}

We have a problem now. Because the input is empty, it is considered invalid. So what can we do?

:user-valid and :user-invalid modifiers

⚠️
At the time of writing this post :user-valid and :user-invalid are not out yet. Use arbitrary properties to achieve the same effect.

We can replace valid: and invalid: with [&user-valid]: and [&user-invalid]: respectively.

Unlike valid: and invalid:, [&user-valid]: and [&user-invalid]: only matches once the user has interacted with it.

Code Playground
export default function Example() {
return (
  <div className="p-4 w-full h-screen ">
    <label htmlFor="name" className="text-sm font medium">
      First Name
    </label>
    <input
      required
      minLength={3}
      type="text"
      id="name"
      className="[&:user-valid]:border-green-500 [&:user-invalid]:border-red-500 mt-1 w-full rounded-md text-sm border-gray-300 border shadow-sm h-8 px-2"
    />
    <p className="hidden text-red-500 text-sm">
      This field is required
    </p>
  </div>
)
}

This is good enough for some use cases but we can make it even better.

peer modifier

We can add some text underneath the input when it's invalid and hide it when it's valid. This can be achieve using the peer modifier.

Code Playground
export default function Example() {
return (
  <div className="p-4 w-full h-screen ">
    <label htmlFor="name" className=" text-sm font medium">
      First Name
    </label>
    <input
      required
      minLength={3}
      type="text"
      id="name"
      className="peer [&:user-valid]:border-green-500 [&:user-invalid]:border-red-500 mt-1 w-full rounded-md text-sm border-gray-300 border shadow-sm h-8 px-2"
    />
    <p className="peer-invalid:block hidden text-red-500 text-sm mt-1">
      This field is required
    </p>
  </div>
)
}

peer allows us to style an element based on the state of a sibling element. In this example, we marked the sibling input with the peer class and the targeted element p with peer-invalid:

But now we run into the same issue as before when using invalid.

Let's replace invalid: with [&user-invalid]:

Code Playground
export default function Example() {
return (
  <div className="p-4 w-full h-screen ">
    <label htmlFor="name" className=" text-sm font medium">
      First Name
    </label>
    <input
      required
      minLength={3}
      required
      type="text"
      id="name"
      className="peer [&:user-valid]:border-green-500 [&:user-invalid]:border-red-500 mt-1 w-full rounded-md text-sm border-gray-300 border shadow-sm h-8 px-2"
    />
    <p className="peer-[&:user-invalid]:block hidden text-red-500 text-sm mt-1">
      This field is required
    </p>
  </div>
)
}