Input Components in Tailwind: Text, Select, Checkbox, Radio
Build polished text inputs, selects, checkboxes, and radio buttons entirely in Tailwind CSS — with real patterns, state styling, and accessibility baked in.
Why Form Inputs Are Still Hard in Tailwind
You'd think by 2026 this would be a solved problem. You've got a utility-first CSS framework, a component library, maybe a design system — and yet every new project has you rewriting the same <input> styles from scratch. The default browser inputs are ugly. Tailwind resets almost nothing by default. And the community keeps shipping opinionated solutions that don't compose well together.
Tailwind v3.3 introduced the @tailwindcss/forms plugin, which gives you a clean baseline. But that's just the starting point. Building inputs that handle focus, disabled, error, and read-only states — all while looking good across browsers — requires real thought. Not magic, just patterns you can copy and own.
In practice, the mistake most teams make is styling only the happy path. You get a beautiful focused border, then ship it, and the disabled state looks broken on Chrome because you forgot disabled:opacity-50 disabled:cursor-not-allowed. Quick aside: these aren't edge cases. Real users hit disabled and error states constantly.
This guide covers the four input primitives you'll actually use: text inputs, selects, checkboxes, and radio buttons. Each one has real gotchas, real solutions, and code you can drop straight into your project. We'll also link to the glassmorphism form design article if you want to go beyond plain white inputs.
Text Inputs: The Foundation
A text input in Tailwind isn't complicated. But the difference between a passable input and a great one is about 8 utility classes that most devs skip.
Start with this base and build from it:
``html
<input
type="text"
placeholder="Your name"
class="w-full rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 shadow-sm transition-colors focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
/>
`
That's your solid baseline. focus:ring-2 focus:ring-indigo-500/20 gives you that soft glow without the harsh default browser outline. The transition-colors` on the border means the blue doesn't just snap in — it slides.
One more thing — don't forget the error state. This is what most UI libraries actually skip or make ugly:
``html
<div class="space-y-1">
<input
type="text"
aria-invalid="true"
aria-describedby="email-error"
class="w-full rounded-lg border border-red-400 bg-white px-4 py-2.5 text-sm text-gray-900 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-400/20"
/>
<p id="email-error" class="text-xs text-red-500">Please enter a valid email.</p>
</div>
`
The aria-invalid and aria-describedby` aren't optional. Screen readers need them to make sense of the validation state.
Worth noting: if you're using Tailwind v4, the new field-sizing utilities let inputs grow with their content automatically. Check the tailwind-v4-features article for the full picture — it changes how you'd write some of this.
Honestly, the 4px rounded-lg (rounded-lg resolves to border-radius: 0.5rem = 8px) is better than sharp rounded-none for most UIs. Looks less governmental, more intentional. That said, if you're building a neobrutalism theme, you'd go rounded-none border-2 border-black — and that's a completely different vibe. We've got a deep-dive on neobrutalism if that's where you're headed.
Select Dropdowns: Fixing the Ugly Default
Native <select> elements are the worst-looking form primitive in existence. The browser chrome on selects is wildly inconsistent across Windows, macOS, and Android — so you need to both reset and re-style them properly.
Install the official plugin first if you haven't: npm install -D @tailwindcss/forms. Then add it to your config:
``js
// tailwind.config.js
module.exports = {
plugins: [
require('@tailwindcss/forms'),
],
}
`
With the plugin active, you get a normalized base to build on. Here's a select that actually looks good:
`html
<select class="w-full appearance-none rounded-lg border border-gray-300 bg-white bg-[url('data:image/svg+xml,...')] bg-[right_0.75rem_center] bg-no-repeat px-4 py-2.5 pr-10 text-sm text-gray-900 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20">
<option value="">Choose a plan</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
`
The appearance-none` strips the browser arrow. You then inject your own SVG arrow as a background image. It's ugly in the source, but it works everywhere.
Look, if you find the SVG-in-bg-image pattern annoying (you should), just wrap the select:
``html
<div class="relative">
<select class="w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/20">
<option>Option 1</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
``
Pointer-events none on the icon wrapper means click-through still triggers the native select. Clean, accessible, no JavaScript.
For multi-select with search, you're eventually going to want a headless library. Check out combobox-react and multi-select-react for the full treatment — native selects cap out fast when you need filtering.
Checkboxes: Custom Styling Without Losing Accessibility
Checkboxes are where Tailwind gets genuinely satisfying. You can build a fully custom checkbox that looks nothing like the browser default while keeping full keyboard support and screen reader compatibility. No JavaScript. No extra dependencies. Just CSS.
The @tailwindcss/forms plugin makes the base checkbox actually styleable. Here's a pattern that works:
``html
<label class="flex cursor-pointer items-start gap-3">
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-2 focus:ring-indigo-500/20 focus:ring-offset-1"
/>
</div>
<span class="text-sm text-gray-700">
I agree to the <a href="/terms" class="text-indigo-600 underline">terms of service</a>
</span>
</label>
`
The text-indigo-600 on the checkbox itself is the trick from the forms plugin — it sets the checked fill color via accent-color` under the hood.
Want a fully custom checkbox that you control pixel-by-pixel? Hide the real input, style a div, use :checked sibling state:
``html
<label class="group flex cursor-pointer items-center gap-3">
<input type="checkbox" class="peer sr-only" />
<div class="flex h-5 w-5 items-center justify-center rounded border-2 border-gray-300 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-500 peer-focus:ring-2 peer-focus:ring-indigo-500/30">
<svg class="hidden h-3 w-3 text-white peer-checked:block" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-sm text-gray-700">Remember me</span>
</label>
`
That peer pattern is genuinely clever. The sr-only input stays in the DOM for accessibility. The visual div responds to the hidden input's state. Zero JS. The checkmark SVG shows via peer-checked:block — hidden` by default, visible when checked.
One pitfall: sr-only removes the element from the visual flow but it still needs to be *before* the peer element in the DOM for Tailwind's peer modifier to work. Get the order wrong and the peer relationship breaks silently. Don't ask me how I know.
Radio Buttons: Groups and State Management
Radio buttons follow the same peer pattern as checkboxes, but they come with a group dynamic — exactly one in the group can be selected at a time. That's not a CSS concern, that's HTML name attributes doing their job. Still, the styling challenge is real.
Basic styled radio group:
``html
<fieldset class="space-y-3">
<legend class="text-sm font-medium text-gray-900">Notification preference</legend>
<label class="flex cursor-pointer items-center gap-3">
<input type="radio" name="notification" value="email" class="peer sr-only" />
<div class="flex h-5 w-5 items-center justify-center rounded-full border-2 border-gray-300 transition-colors peer-checked:border-indigo-500">
<div class="h-2.5 w-2.5 scale-0 rounded-full bg-indigo-500 transition-transform peer-checked:scale-100"></div>
</div>
<span class="text-sm text-gray-700">Email</span>
</label>
<label class="flex cursor-pointer items-center gap-3">
<input type="radio" name="notification" value="sms" class="peer sr-only" />
<div class="flex h-5 w-5 items-center justify-center rounded-full border-2 border-gray-300 transition-colors peer-checked:border-indigo-500">
<div class="h-2.5 w-2.5 scale-0 rounded-full bg-indigo-500 transition-transform peer-checked:scale-100"></div>
</div>
<span class="text-sm text-gray-700">SMS</span>
</label>
</fieldset>
`
The scale-0 to scale-100` transition on the inner dot gives you that satisfying pop animation with zero JS.
For card-style radio buttons — the kind where you pick a plan and the whole card highlights — you need a slightly different approach:
``html
<label class="relative cursor-pointer">
<input type="radio" name="plan" value="pro" class="peer sr-only" />
<div class="rounded-xl border-2 border-gray-200 p-4 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50">
<p class="font-semibold text-gray-900">Pro Plan</p>
<p class="text-sm text-gray-500">$12/month</p>
</div>
<div class="pointer-events-none absolute right-3 top-3 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-500 peer-checked:flex">
<svg class="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</label>
``
This is the exact pattern you'd see on a pricing page. The whole card becomes the click target. The checkmark badge appears in the corner when selected.
Honestly, radio groups are where I'd reach for a component library the fastest. If you're building a complex multi-step form with conditional logic, the manual peer approach gets messy. Take a look at what Empire UI has for form primitives — it handles the a11y scaffolding so you're not re-doing aria-checked and role="radiogroup" from scratch every project.
Worth noting: always use a <fieldset> and <legend> for radio groups. Not optional. That's how assistive tech announces the group context before reading each option.
Combining It All: A Real Form Layout
Scattered inputs don't make a form. Layout, spacing, and label consistency matter as much as the individual components. Here's a login/signup form that pulls everything together:
``html
<form class="mx-auto w-full max-w-md space-y-5 rounded-2xl bg-white p-8 shadow-lg">
<h2 class="text-xl font-semibold text-gray-900">Create account</h2>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-gray-700" for="name">Full name</label>
<input id="name" type="text" placeholder="Jane Smith"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-gray-700" for="role">Role</label>
<div class="relative">
<select id="role"
class="w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/20">
<option>Designer</option>
<option>Developer</option>
<option>Manager</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-3 flex items-center">
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<fieldset class="space-y-2">
<legend class="text-sm font-medium text-gray-700">Notifications</legend>
<label class="flex cursor-pointer items-center gap-3">
<input type="radio" name="notif" value="all" class="peer sr-only" />
<div class="flex h-5 w-5 items-center justify-center rounded-full border-2 border-gray-300 peer-checked:border-indigo-500">
<div class="h-2.5 w-2.5 scale-0 rounded-full bg-indigo-500 transition-transform peer-checked:scale-100"></div>
</div>
<span class="text-sm text-gray-700">All activity</span>
</label>
<label class="flex cursor-pointer items-center gap-3">
<input type="radio" name="notif" value="mentions" class="peer sr-only" />
<div class="flex h-5 w-5 items-center justify-center rounded-full border-2 border-gray-300 peer-checked:border-indigo-500">
<div class="h-2.5 w-2.5 scale-0 rounded-full bg-indigo-500 transition-transform peer-checked:scale-100"></div>
</div>
<span class="text-sm text-gray-700">Mentions only</span>
</label>
</fieldset>
<label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" class="peer sr-only" />
<div class="flex h-5 w-5 items-center justify-center rounded border-2 border-gray-300 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-500">
<svg class="hidden h-3 w-3 text-white peer-checked:block" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-sm text-gray-700">I agree to the terms</span>
</label>
<button type="submit"
class="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
Create account
</button>
</form>
``
The space-y-1.5 between label and input keeps them visually grouped without cramming. The max-w-md container prevents the form from stretching to 100% on wide viewports — this is a common oversight that makes forms look weird on desktop.
If you want to push the visual styling further — frosted glass card, backdrop blur, subtle gradients — the glassmorphism generator will give you the exact CSS values to drop in. The frosted look works particularly well on form containers sitting over an image or gradient background.
Accessibility Checklist Before You Ship
Can you tab through every field in logical order? That's the minimum bar. But there's more. Every input needs a visible label — not just a placeholder. Placeholders disappear the moment someone starts typing, which is not a label, no matter how many codepens do it wrong.
Key things to audit before you call a form done:
``html
<!-- Bad: placeholder as label -->
<input type="email" placeholder="Email address" />
<!-- Good: real label, placeholder as hint -->
<label for="email" class="block text-sm font-medium text-gray-700">Email address</label>
<input id="email" type="email" placeholder="you@example.com"
aria-describedby="email-hint" />
<p id="email-hint" class="text-xs text-gray-500">We'll never share your email.</p>
``
Focus rings are not decorative. The focus:outline-none you see everywhere is only acceptable when you're replacing the outline with something equally visible — the focus:ring-2 approach we've been using throughout this article. Removing focus indicators with nothing in return is a WCAG 2.1 failure. The react-accessibility-guide has the full WCAG breakdown if you want the spec references.
Run your form through a screen reader before shipping. VoiceOver on macOS, NVDA on Windows. Tab into each field and confirm it announces label, type, and any hints. Radio groups especially — the <fieldset>/<legend> combo is what ties the group name to each individual option. Skip it and screen reader users hear a decontextualized list of options with no idea what they're choosing.
In practice, the 20 minutes you spend testing with a screen reader will catch more real bugs than any automated accessibility tool. Automated tools catch maybe 30% of issues. The rest requires human judgment.
FAQ
Not strictly, but it saves you hours of cross-browser pain. It normalizes the base styles so you're building on a consistent foundation instead of fighting browser defaults. Worth the 30-second install.
The peer input must come before the sibling element in the DOM — Tailwind's peer modifier only targets subsequent siblings via the CSS ~ combinator. Flip the order and it breaks silently.
You can use CSS :invalid pseudo-class with Tailwind's invalid: modifier on inputs that use HTML5 validation attributes like required, type="email", and minlength. It's limited but covers basic cases without any JS.
Yes — the Tailwind classes sit on the HTML elements, so they're framework-agnostic. Just spread your register() or field props onto the input alongside the className. The react-form-react-hook-form article has working examples.