Forms, states and accessible styling

Style inputs consistently across browsers, use focus-visible and user-invalid correctly, and make required and invalid states legible without JavaScript.

Inputs across browsers

<label class="block">
  <span class="mb-1.5 block text-sm font-medium text-slate-700">Email</span>
  <input type="email" name="email" required autocomplete="email"
         class="block w-full rounded-md border border-slate-300 bg-white px-3 py-2
                text-sm text-slate-900 placeholder:text-slate-400
                focus:border-sky-500 focus:outline-2 focus:outline-offset-0 focus:outline-sky-500
                disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400
                read-only:bg-slate-50">
</label>
/* the forms plugin normalises the native appearance across browsers */
@import "tailwindcss";
@plugin "@tailwindcss/forms";
  • Browsers ship different default borders, paddings and focus rings on inputs. Normalising with a plugin is less work than overriding each one.
  • placeholder:text-slate-400 uses the placeholder variant; a bare text-* class will not colour the placeholder.
  • read-only: and disabled: are different states - a readonly field is still focusable and still submitted, which is usually what a review form wants.
  • file: styles the file input's button, which is otherwise unstyleable.
  • autofill: targets the browser's autofill styling, which overrides your background with its own yellow.
⚠️
Never remove the focus indicator without replacing it. focus:outline-none on its own makes a keyboard user lose their place entirely. Use focus-visible:outline-2 with an offset, which shows a clear ring for keyboard focus and stays quiet for a mouse click.

Validation states with no JavaScript

<form novalidate class="space-y-4">
  <div>
    <label for="email" class="block text-sm font-medium">Email</label>
    <input id="email" name="email" type="email" required
           aria-describedby="email-error"
           class="peer block w-full rounded-md border px-3 py-2
                  border-slate-300
                  required:border-slate-300
                  user-invalid:border-red-500 user-invalid:bg-red-50/50
                  user-invalid:focus:outline-red-500
                  valid:border-emerald-500">
    <p id="email-error" class="mt-1 hidden text-sm text-red-600
                               peer-user-invalid:block">
      Enter an email address we can reach you on.
    </p>
  </div>

  <div class="flex items-start gap-2">
    <input id="terms" type="checkbox" required
           class="mt-0.5 size-4 rounded border-slate-300 required:shadow-none
                  user-invalid:outline-2 user-invalid:outline-red-500">
    <label for="terms" class="text-sm">I accept the terms</label>
  </div>
</form>
VariantMatches when
required:The control has the required attribute
optional:It does not
valid: / invalid:The constraint validation state, from the first interaction
user-invalid:Invalid after the user has interacted and blurred
user-valid:Valid after interaction and blur
in-range: / out-of-range:Numeric range constraints
placeholder-shown:The value is empty and a placeholder is visible
autofill:The browser filled the field
<!-- group and peer variants reach siblings and ancestors -->
<div class="group">
  <input type="checkbox" class="peer sr-only">
  <span class="block rounded border p-3
               peer-checked:border-sky-500 peer-checked:bg-sky-50
               peer-focus-visible:outline-2 peer-focus-visible:outline-sky-500">
    Standard delivery
  </span>
</div>

<!-- has-[] styles a parent based on its children -->
<fieldset class="rounded-md border border-slate-300 p-4 has-[:user-invalid]:border-red-500">
  <legend>Address</legend>
</fieldset>

invalid: fires as soon as the field is invalid, which for a required empty input is immediately on load - so every empty field appears red before the user has typed anything. user-invalid: waits until the user has interacted and left the field, which is the behaviour that does not make a form look like an accusation.

Styling that carries the semantics

<!-- a switch built from a checkbox, styled and announced correctly -->
<label class="flex cursor-pointer items-center gap-3">
  <input type="checkbox" role="switch" class="peer sr-only" checked>
  <span class="relative h-6 w-11 rounded-full bg-slate-300 transition
               peer-checked:bg-sky-600
               peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2
               peer-focus-visible:outline-sky-600
               after:absolute after:top-0.5 after:left-0.5 after:size-5
               after:rounded-full after:bg-white after:transition
               peer-checked:after:translate-x-5">
  </span>
  <span class="text-sm">Email me about updates</span>
</label>

<!-- a visually hidden label that is still read out -->
<label for="search" class="sr-only">Search invoices</label>
<input id="search" type="search" placeholder="Search"
       class="rounded-md border px-3 py-2">
  • sr-only hides content visually while keeping it in the accessibility tree - the correct way to label an icon-only control.
  • peer plus peer-* lets a visually hidden input drive the styling of a visible sibling, which is how a custom checkbox keeps its native keyboard behaviour.
  • aria-invalid:border-red-500 styles the state a screen reader announces, so the visual and the semantic state come from the same source.
  • motion-reduce:transition-none respects the user's reduced-motion preference on any animated control.
  • Size targets at least 44 by 44 pixels on touch: min-h-11 min-w-11.
<button class="min-h-11 min-w-11 rounded-md border transition
               motion-reduce:transition-none"
        aria-label="Close dialog">
  <svg class="mx-auto size-5" aria-hidden="true">...</svg>
</button>
/* a ring that is visible against both light and dark backgrounds */
@theme {
  --color-focus: oklch(62% 0.19 250);
}

/* focus-visible is the modern default: no configuration needed */

FAQ

Why do my inputs look different in Safari?
Safari applies its own appearance and border radius to form controls. Normalising with the forms plugin, or explicitly setting appearance-none alongside a border and a background, is the fix. Test in more than one browser before assuming the class list is at fault.
Should validation styling come from the browser or from JavaScript?
Prefer the browser: required, type, pattern and user-invalid: give you correct states with no script at all. Add JavaScript when you need a message the browser cannot express, and keep the two in sync through setCustomValidity rather than a separate error state.

Responsive and state variants Component patterns without a component library

Last refreshed 2026-09-18.