States

How a form looks while it's actually being used: focused, invalid, submitting, and after it succeeds.

Focus

Already part of the baseline in Inputs: focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25. Worth calling out on its own, because it's the one state every field needs and the one most often skipped: a field with no visible focus state fails basic accessibility, and it's the first thing a keyboard user notices.

Validation and error

Toggle aria-invalid on the field itself (screen readers announce it, and it's a real, inspectable signal, unlike a CSS class alone) and show the error text right below the field:

Remove aria-invalid, its border/ring classes, and the <p> once the field becomes valid again; don't leave a stale error showing after the visitor fixes it.

Enter a valid email address.

<div class="flex flex-col gap-1">
  <label for="email" class="text-sm font-medium">Your email</label>
  <input id="email" name="email" type="email" required aria-invalid="true" aria-describedby="email-error"
         class="rounded-md border border-red-400 bg-white px-3 py-2 outline-none focus:border-red-500 focus:ring-2 focus:ring-red-500/25 dark:border-red-500 dark:bg-white/5">
  <p id="email-error" class="text-sm text-red-600 dark:text-red-400">Enter a valid email address.</p>
</div>

Success

A field-level success state (a green border and a checkmark) for a field that's been validated inline, separate from the form-level "your message was sent" state below:

The whole-form success state, after a real submission, usually replaces the form entirely rather than decorating it:

<div class="flex flex-col gap-1">
  <label for="email" class="text-sm font-medium">Your email</label>
  <div class="relative">
    <input id="email" name="email" type="email" required value="you@example.com"
           class="w-full rounded-md border border-green-400 bg-white px-3 py-2 pr-9 outline-none focus:border-green-500 focus:ring-2 focus:ring-green-500/25 dark:border-green-500 dark:bg-white/5">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-green-500"><path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.58l7.3-7.3a1 1 0 0 1 1.4 0Z" clip-rule="evenodd"/></svg>
  </div>
</div>
<div class="rounded-lg border border-green-200 bg-green-50 p-6 text-center dark:border-green-500/30 dark:bg-green-500/10">
  <p class="font-medium text-green-800 dark:text-green-400">Thanks - your message was sent.</p>
  <p class="mt-1 text-sm text-green-700/80 dark:text-green-400/70">We usually reply within a day.</p>
</div>

Submitting

Disable the button and swap its label, so a slow network doesn't invite a double-submit:

disabled:cursor-not-allowed only does anything while the disabled attribute is actually present; toggle the attribute itself from the submit handler, not just a class.

<button type="submit" disabled
        class="inline-flex items-center gap-2 self-start rounded-md bg-brand-500 px-5 py-2 font-medium text-white opacity-70 disabled:cursor-not-allowed">
  <svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.37 0 0 5.37 0 12h4Z"/>
  </svg>
  Sending…
</button>

Dark mode

Every example above already carries its own dark: classes; the pattern to keep consistent across a whole form:

- Borders: an explicit gray (border-gray-300 / dark:border-gray-600), never an opacity-based color alone (border-black/15), which gets harder to see, not easier, on a dark background. - Backgrounds: an explicit bg-white / dark:bg-white/5, not "no background," so the field doesn't blend into the page behind it in either mode. - Status colors (red/green above): the -400/-500 shade on a dark background instead of the same -600/-700 shade used in light mode, which reads as too dark and low-contrast once the page itself is dark.