On this page

Forms and Accessibility

12 min read TextCh. 4 — Components and animation

Forms and Accessibility

Forms are the primary way users interact with applications — they are also one of the most accessibility-sensitive areas of web development. This lesson covers practical Tailwind patterns for styling forms beautifully while meeting WCAG AA accessibility requirements.

Installing the forms plugin

The @tailwindcss/forms plugin provides a consistent, opinionated reset for native form elements. It removes browser-specific styling (WebKit input shadows, Firefox focus rings, platform-specific checkboxes) and gives you a clean starting point.

npm install -D @tailwindcss/forms
/* In your tailwind.css */
@import "tailwindcss";
@plugin "@tailwindcss/forms";

Two strategies are available:

/* Default: applies resets to native element selectors (input, select, textarea) */
@plugin "@tailwindcss/forms";

/* Class strategy: only applies resets when you add the form-input, form-select etc. classes */
@plugin "@tailwindcss/forms" { strategy: class; }

The class strategy is less intrusive and is better for projects where you mix Tailwind forms with third-party UI libraries.

Input field patterns

<!-- Standard text input -->
<div class="space-y-1">
  <label for="username" class="block text-sm font-medium text-gray-700">Username</label>
  <input
    id="username"
    type="text"
    autocomplete="username"
    class="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-150 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20"
    placeholder="your_username"
  />
</div>

<!-- Input with leading icon -->
<div class="relative">
  <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
    <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
    </svg>
  </div>
  <input
    type="search"
    class="w-full border border-gray-200 rounded-lg pl-9 pr-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20"
    placeholder="Search..."
    aria-label="Search"
  />
</div>

<!-- Input with trailing button -->
<div class="flex rounded-lg overflow-hidden border border-gray-200 focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/20 transition-all">
  <input
    type="email"
    class="flex-1 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 outline-none bg-white"
    placeholder="Enter your email"
    aria-label="Email for newsletter"
  />
  <button
    type="submit"
    class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 transition-colors"
  >
    Subscribe
  </button>
</div>

Select and textarea

<!-- Styled select -->
<div class="relative">
  <select
    class="w-full appearance-none border border-gray-200 rounded-lg px-3 py-2.5 pr-8 text-sm text-gray-900 bg-white outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20"
    aria-label="Select country"
  >
    <option value="">Select country</option>
    <option value="us">United States</option>
    <option value="ca">Canada</option>
    <option value="uk">United Kingdom</option>
  </select>
  <!-- Custom chevron icon -->
  <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
    <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
    </svg>
  </div>
</div>

<!-- Textarea -->
<textarea
  rows="4"
  class="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 outline-none resize-none transition-all focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20"
  placeholder="Your message..."
  aria-label="Message"
></textarea>

Checkboxes and radio buttons

<!-- Custom checkbox -->
<label class="flex items-center gap-3 cursor-pointer group">
  <input
    type="checkbox"
    class="w-4 h-4 rounded border-gray-300 text-indigo-600 cursor-pointer focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 transition-colors"
  />
  <span class="text-sm text-gray-700 group-hover:text-gray-900 transition-colors">
    I agree to the Terms of Service
  </span>
</label>

<!-- Radio group -->
<fieldset>
  <legend class="text-sm font-semibold text-gray-900 mb-3">Notification preference</legend>
  <div class="space-y-2">
    <label class="flex items-center gap-3 cursor-pointer">
      <input type="radio" name="notif" value="email" class="w-4 h-4 text-indigo-600 border-gray-300 focus-visible:ring-2 focus-visible:ring-indigo-500" />
      <span class="text-sm text-gray-700">Email only</span>
    </label>
    <label class="flex items-center gap-3 cursor-pointer">
      <input type="radio" name="notif" value="push" class="w-4 h-4 text-indigo-600 border-gray-300 focus-visible:ring-2 focus-visible:ring-indigo-500" />
      <span class="text-sm text-gray-700">Push notifications</span>
    </label>
    <label class="flex items-center gap-3 cursor-pointer">
      <input type="radio" name="notif" value="both" class="w-4 h-4 text-indigo-600 border-gray-300 focus-visible:ring-2 focus-visible:ring-indigo-500" />
      <span class="text-sm text-gray-700">Email and push</span>
    </label>
  </div>
</fieldset>

Focus visible and keyboard accessibility

The focus-visible: variant applies styles only when focus is triggered by keyboard navigation, not mouse clicks. This gives keyboard users a clear focus indicator without the unwanted focus ring on every mouse click.

<!-- Good: keyboard focus visible, mouse click focus invisible -->
<button class="outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 bg-indigo-600 text-white px-4 py-2 rounded-lg">
  Save
</button>

<!-- Links too -->
<a href="/about" class="text-indigo-600 hover:text-indigo-800 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded">
  About us
</a>

Ring utilities for focus indicators

The ring-* utilities create inset box-shadows that serve as accessible focus indicators:

<!-- Basic ring -->
<input class="focus:ring-2 focus:ring-indigo-500" />

<!-- Ring with offset (gap between element edge and ring) -->
<button class="focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2">...</button>

<!-- Custom ring color and width -->
<button class="focus-visible:ring-4 focus-visible:ring-indigo-500/50">...</button>

<!-- Ring that adapts in dark mode -->
<button class="focus-visible:ring-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400">...</button>

Disabled states

<!-- Input disabled -->
<input
  type="text"
  disabled
  class="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-400 bg-gray-50 cursor-not-allowed outline-none"
  value="Read-only value"
/>

<!-- Button disabled -->
<button
  type="button"
  disabled
  class="bg-indigo-600 text-white font-semibold px-5 py-2.5 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
  Submit
</button>

Visually hidden (screen reader only)

The sr-only class hides content visually while keeping it accessible to screen readers:

<!-- Icon button with accessible label -->
<button type="button" class="p-2 rounded-lg hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-indigo-500">
  <svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
  </svg>
  <span class="sr-only">Close dialog</span>
</button>

<!-- Skip navigation link (visible only on focus) -->
<a
  href="#main-content"
  class="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:top-4 focus-visible:left-4 focus-visible:z-50 focus-visible:bg-white focus-visible:px-4 focus-visible:py-2 focus-visible:rounded-lg focus-visible:ring-2 focus-visible:ring-indigo-500 font-medium text-indigo-700"
>
  Skip to main content
</a>

Validation states with peer

Using the peer variant, you can show or hide validation messages based on input validity without JavaScript:

<div class="space-y-1">
  <label for="user-email" class="block text-sm font-medium text-gray-700">Email</label>
  <input
    id="user-email"
    type="email"
    required
    class="peer w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20 invalid:border-red-400 invalid:focus:ring-red-500/20"
    placeholder="[email protected]"
  />
  <!-- Error message: visible only when input is invalid AND was interacted with -->
  <p class="text-xs text-red-600 hidden peer-invalid:peer-not-placeholder-shown:block">
    Please enter a valid email address.
  </p>
</div>

Practice

  1. Build a complete sign-up form with fields for name, email, password, and a terms checkbox. Every field must have a visible <label>, correct aria-required, and a focus-visible:ring-2 focus indicator.
  2. Add a "Skip to main content" skip link at the top of the page using sr-only and focus-visible:not-sr-only.
  3. Create a custom toggle switch using a checkbox with the peer variant to change the toggle track color when checked.

Never style focus out of existence
Do not use outline-none or outline-0 unless you immediately add a replacement focus indicator (like focus-visible:ring-2). Removing focus outlines without a replacement makes your UI completely inaccessible for keyboard users. Always use focus-visible: to provide visible keyboard focus that does not show for mouse clicks.
sr-only makes content visible to screen readers only
The sr-only class applies position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0). Use it for additional context that screen reader users need but that would clutter the visual layout: form error summaries, icon button labels, step numbers, etc.
The @tailwindcss/forms plugin resets native form elements
Native form elements (input, select, textarea, checkbox) have browser-dependent default styles that vary wildly across platforms. Install @tailwindcss/forms to get a clean, consistent baseline that Tailwind utilities can build on top of. Without it, styling native elements requires more workarounds.
html
<!-- Fully accessible form with Tailwind styling -->
<form class="max-w-lg mx-auto bg-white rounded-2xl shadow-md border border-gray-100 p-8 space-y-6" novalidate>

  <div>
    <h2 class="text-xl font-bold text-gray-900">Contact us</h2>
    <p class="text-sm text-gray-500 mt-1">We'll respond within 24 hours.</p>
  </div>

  <!-- Name field -->
  <div class="space-y-1">
    <label for="full-name" class="block text-sm font-medium text-gray-700">
      Full name <span class="text-red-500" aria-hidden="true">*</span>
    </label>
    <input
      id="full-name"
      name="full_name"
      type="text"
      required
      autocomplete="name"
      aria-required="true"
      class="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-150 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20 invalid:border-red-400 invalid:focus:ring-red-500/20"
      placeholder="Alice Johnson"
    />
  </div>

  <!-- Email field -->
  <div class="space-y-1">
    <label for="email-address" class="block text-sm font-medium text-gray-700">
      Email address <span class="text-red-500" aria-hidden="true">*</span>
    </label>
    <input
      id="email-address"
      name="email"
      type="email"
      required
      autocomplete="email"
      aria-required="true"
      aria-describedby="email-hint"
      class="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-150 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20 invalid:border-red-400"
      placeholder="[email protected]"
    />
    <p id="email-hint" class="text-xs text-gray-400">We will never share your email.</p>
  </div>

  <!-- Subject select -->
  <div class="space-y-1">
    <label for="subject" class="block text-sm font-medium text-gray-700">Subject</label>
    <select
      id="subject"
      name="subject"
      class="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-900 bg-white outline-none transition-all duration-150 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20"
    >
      <option value="">Select a subject</option>
      <option value="general">General inquiry</option>
      <option value="support">Technical support</option>
      <option value="billing">Billing question</option>
    </select>
  </div>

  <!-- Message textarea -->
  <div class="space-y-1">
    <label for="message" class="block text-sm font-medium text-gray-700">
      Message <span class="text-red-500" aria-hidden="true">*</span>
    </label>
    <textarea
      id="message"
      name="message"
      rows="4"
      required
      aria-required="true"
      class="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 outline-none resize-none transition-all duration-150 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20"
      placeholder="Tell us how we can help..."
    ></textarea>
  </div>

  <!-- Checkbox -->
  <div class="flex items-start gap-3">
    <input
      id="newsletter"
      name="newsletter"
      type="checkbox"
      class="mt-0.5 w-4 h-4 rounded border-gray-300 text-indigo-600 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
    />
    <label for="newsletter" class="text-sm text-gray-700 cursor-pointer">
      Subscribe to our newsletter for updates and tips.
    </label>
  </div>

  <!-- Submit button -->
  <button
    type="submit"
    class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
  >
    Send message
  </button>

</form>