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-400uses theplaceholdervariant; a baretext-*class will not colour the placeholder.read-only:anddisabled: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>| Variant | Matches 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-onlyhides content visually while keeping it in the accessibility tree - the correct way to label an icon-only control.peerpluspeer-*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-500styles the state a screen reader announces, so the visual and the semantic state come from the same source.motion-reduce:transition-nonerespects 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.Related
Responsive and state variants Component patterns without a component library
Last refreshed 2026-09-18.