Inputs

Special input types a plain <input> alone usually looks wrong for.

Baseline

The baseline every other input on this page starts from: an explicit border color (not an opacity-based one like border-black/15, which reads as invisible against most backgrounds), a visible focus ring, and an explicit background so dark mode doesn't leave the field blending into the page behind it.

Swap brand-500/brand-600 for whatever primary color the site actually defines; every example below assumes those two utilities exist.

<div class="flex flex-col gap-1">
  <label for="name" class="text-sm font-medium">Your name</label>
  <input id="name" name="name" type="text" required
         class="rounded-md border border-gray-300 bg-white px-3 py-2 outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25 dark:border-gray-600 dark:bg-white/5">
</div>

Date

A native <input type="date"> picks up this exact same styling; no JavaScript date-picker library needed unless the design calls for a range picker or a non-native calendar UI:

dark:[color-scheme:dark] fixes the native calendar icon and popup rendering as black-on-black in dark mode; without it, the picker itself still uses light-mode colors even though the field around it doesn't.

<div class="flex flex-col gap-1">
  <label for="move-in" class="text-sm font-medium">Preferred date</label>
  <input id="move-in" name="move_in_date" type="date" required
         class="rounded-md border border-gray-300 bg-white px-3 py-2 outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25 dark:border-gray-600 dark:bg-white/5 dark:[color-scheme:dark]">
</div>

Time

A native <input type="time"> picks up the same styling, so booking and appointment forms don't need a JavaScript time-picker library.

The browser draws the time picker itself, so by default it follows your browser or OS theme, not the toggle in the nav. [color-scheme:light] and dark:[color-scheme:dark] tell it which palette to use, the same fix the date and select examples need.

<div class="flex flex-col gap-1">
  <label for="appointment-time" class="text-sm font-medium">Appointment time</label>
  <input id="appointment-time" name="appointment_time" type="time" required
         class="rounded-md border border-gray-300 bg-white px-3 py-2 outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25 [color-scheme:light] dark:border-gray-600 dark:bg-white/5 dark:[color-scheme:dark]">
</div>

Number

A quantity or count field. Same base styling, plus min/max/step for real validation instead of just a visual hint:

w-24 instead of a full-width field: a number this short doesn't need the same width as an email field, and a full-width number input reads oddly.

<div class="flex flex-col gap-1">
  <label for="guests" class="text-sm font-medium">Number of guests</label>
  <input id="guests" name="guests" type="number" min="1" max="20" step="1" required
         class="w-24 rounded-md border border-gray-300 bg-white px-3 py-2 outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25 dark:border-gray-600 dark:bg-white/5">
</div>

Phone

A tel input, not text, gets phone-appropriate mobile keyboards for free and lets pattern validate a rough shape without a JavaScript library:

A separate country-code <select> next to this field is a common upgrade; skip it unless the site actually serves multiple countries; it's one more required decision for most visitors.

<div class="flex flex-col gap-1">
  <label for="phone-input" class="text-sm font-medium">Phone number</label>
  <input id="phone-input" name="phone" type="tel" autocomplete="tel"
         pattern="[0-9+()\-\s]{7,}" placeholder="+1 (555) 555-0123"
         class="rounded-md border border-gray-300 bg-white px-3 py-2 outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25 dark:border-gray-600 dark:bg-white/5">
</div>

File upload

Native <input type="file"> is nearly impossible to style directly across browsers; the reliable pattern is hiding it and styling a <label> that triggers it instead:

sr-only (a standard Tailwind utility: visually hidden, still in the accessibility tree and still focusable/keyboard-operable) rather than hidden, which would also remove it from tab order and break keyboard users entirely.

Resume
<div class="flex flex-col gap-1">
  <span class="text-sm font-medium">Resume</span>
  <label for="resume" class="flex w-fit cursor-pointer items-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:bg-white/5 dark:hover:bg-white/10">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4"><path d="M9.25 13.25a.75.75 0 0 0 1.5 0V4.66l2.1 2.1a.75.75 0 1 0 1.06-1.06l-3.38-3.38a.75.75 0 0 0-1.06 0L6.09 5.7a.75.75 0 0 0 1.06 1.06l2.1-2.1v8.59Z"/><path d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"/></svg>
    <span id="resume-filename">Choose a file</span>
  </label>
  <input id="resume" name="resume" type="file" required class="sr-only">
</div>

<script>
  document.getElementById('resume').addEventListener('change', (event) => {
    document.getElementById('resume-filename').textContent = event.target.files[0]?.name ?? 'Choose a file'
  })
</script>

Select

A native <select>'s dropdown popup follows the browser/OS's own light-or-dark preference by default, not the page's, which can leave its options rendering as invisible white-on-white (or black-on-black) until you hover one. [color-scheme:light] / dark:[color-scheme:dark] tells the browser explicitly which native palette to use for the parts of the control it renders itself, the same fix the date input above needs for its calendar popup. color-scheme alone wasn't reliable enough on its own in testing (some Chrome versions still rendered the popup with default colors regardless), so each <option> also sets its own background-color/color directly, which Chrome does honor for the popup list even though it ignores most other CSS on <option>.

<div class="flex flex-col gap-1">
  <label for="budget" class="text-sm font-medium">Budget range</label>
  <select id="budget" name="budget" required
          class="rounded-md border border-gray-300 bg-white px-3 py-2 outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25 [color-scheme:light] dark:border-gray-600 dark:bg-white/5 dark:[color-scheme:dark]">
    <option value="" disabled selected class="bg-white text-ink dark:bg-ink dark:text-paper">Choose one</option>
    <option value="under-1k" class="bg-white text-ink dark:bg-ink dark:text-paper">Under $1,000</option>
    <option value="1k-5k" class="bg-white text-ink dark:bg-ink dark:text-paper">$1,000 - $5,000</option>
    <option value="5k-plus" class="bg-white text-ink dark:bg-ink dark:text-paper">$5,000+</option>
  </select>
</div>

Textarea with character counter

Set a real character limit with maxlength and connect the counter to the textarea with aria-describedby. A small input listener updates the count as you type.

0 / 500 characters

<div class="flex w-full max-w-md flex-col gap-1">
  <label for="message-textarea" class="text-sm font-medium">Message</label>
  <textarea id="message-textarea" name="message" rows="4" maxlength="500"
            aria-describedby="message-character-count" placeholder="Write your message..."
            class="w-full resize-y rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25 dark:border-gray-600 dark:bg-white/5 dark:text-paper dark:placeholder:text-white/40"></textarea>
  <p id="message-character-count" class="text-right text-xs text-ink/60 dark:text-white/60">0 / 500 characters</p>
</div>
<script>
  const messageField = document.getElementById('message-textarea')
  const messageCount = document.getElementById('message-character-count')
  const updateMessageCount = () => {
    messageCount.textContent = messageField.value.length + ' / ' + messageField.maxLength + ' characters'
  }
  messageField.addEventListener('input', updateMessageCount)
  updateMessageCount()
</script>

Range slider

A native range input works with a mouse, touch, or the arrow keys. Style its track and thumb with Tailwind CSS, then update the output as the value changes. Try the site's theme toggle to compare light and dark states.

5 / 10

Use the arrow keys to adjust the rating.

<div class="flex w-full max-w-md flex-col gap-3">
  <div class="flex items-center justify-between gap-4">
    <label for="rating-slider" class="text-sm font-medium">Satisfaction rating</label>
    <output id="rating-slider-value" for="rating-slider" class="rounded-md bg-brand-50 px-2 py-1 text-sm font-semibold text-brand-600 dark:bg-brand-500/20 dark:text-paper">5 / 10</output>
  </div>
  <input id="rating-slider" name="rating" type="range" min="1" max="10" step="1" value="5" aria-describedby="rating-hint"
         class="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 outline-none focus-visible:ring-2 focus-visible:ring-brand-500/50 focus-visible:ring-offset-4 dark:bg-gray-600 dark:focus-visible:ring-offset-ink [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-brand-600 [&::-webkit-slider-thumb]:shadow-md dark:[&::-webkit-slider-thumb]:border-ink dark:[&::-webkit-slider-thumb]:bg-brand-500 [&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-gray-200 dark:[&::-moz-range-track]:bg-gray-600 [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-brand-600 [&::-moz-range-thumb]:shadow-md dark:[&::-moz-range-thumb]:border-ink dark:[&::-moz-range-thumb]:bg-brand-500">
  <p id="rating-hint" class="text-xs text-ink/60 dark:text-white/60">Use the arrow keys to adjust the rating.</p>
</div>
<script>
  const ratingSlider = document.getElementById('rating-slider')
  const ratingValue = document.getElementById('rating-slider-value')
  const updateRatingValue = () => {
    ratingValue.textContent = ratingSlider.value + ' / ' + ratingSlider.max
  }
  ratingSlider.addEventListener('input', updateRatingValue)
  updateRatingValue()
</script>

Radio group (segmented control look)

A styled radio group reads as a set of buttons instead of the browser's default small circles, using peer and :has() so no JavaScript is needed to toggle the selected look:

has-[:checked]: needs Tailwind v3.4+ (the :has() variant); on an older Tailwind, style the sibling <input>'s checked state with peer-checked: on an adjacent element instead.

Project type
<fieldset class="flex flex-col gap-1">
  <legend class="text-sm font-medium">Project type</legend>
  <div class="flex gap-2">
    <label class="has-[:checked]:border-brand-500 has-[:checked]:bg-brand-50 has-[:checked]:text-brand-600 dark:has-[:checked]:bg-brand-500/20 dark:has-[:checked]:text-brand-500 flex-1 cursor-pointer rounded-md border border-gray-300 px-4 py-2 text-center text-sm font-medium dark:border-gray-600">
      <input type="radio" name="project_type" value="new" class="sr-only" checked>
      New site
    </label>
    <label class="has-[:checked]:border-brand-500 has-[:checked]:bg-brand-50 has-[:checked]:text-brand-600 dark:has-[:checked]:bg-brand-500/20 dark:has-[:checked]:text-brand-500 flex-1 cursor-pointer rounded-md border border-gray-300 px-4 py-2 text-center text-sm font-medium dark:border-gray-600">
      <input type="radio" name="project_type" value="redesign" class="sr-only">
      Redesign
    </label>
  </div>
</fieldset>

Toggle switch

Use a native checkbox with role="switch" for an on/off choice. Keep the checkbox focusable and give it a visible label. The peer-checked: classes move the thumb and change the track color without JavaScript.

<label class="relative flex w-fit cursor-pointer items-center gap-3 text-sm font-medium">
  <input type="checkbox" name="email_copy" role="switch" class="peer sr-only">
  <span aria-hidden="true" class="h-6 w-11 shrink-0 rounded-full bg-gray-300 transition-colors peer-checked:bg-brand-500 peer-focus-visible:ring-2 peer-focus-visible:ring-brand-500 peer-focus-visible:ring-offset-2 dark:bg-gray-600 dark:peer-checked:bg-brand-500 dark:peer-focus-visible:ring-offset-ink motion-reduce:transition-none"></span>
  <span aria-hidden="true" class="pointer-events-none absolute left-0.5 top-0.5 size-5 rounded-full bg-white shadow-sm transition-transform peer-checked:translate-x-5 motion-reduce:transition-none"></span>
  <span>Email me a copy</span>
</label>

Checkbox group

text-brand-500 on a checkbox/radio input sets its own check/dot color, since browsers render native checkbox and radio marks using currentColor/accent-color under the hood; Tailwind's text-* utilities are what set that.

What do you need?
<fieldset class="flex flex-col gap-2">
  <legend class="text-sm font-medium">What do you need?</legend>
  <label class="flex items-center gap-2 text-sm">
    <input type="checkbox" name="needs[]" value="design" class="size-4 rounded border-gray-300 text-brand-500 focus:ring-brand-500/25 dark:border-gray-600">
    Design
  </label>
  <label class="flex items-center gap-2 text-sm">
    <input type="checkbox" name="needs[]" value="development" class="size-4 rounded border-gray-300 text-brand-500 focus:ring-brand-500/25 dark:border-gray-600">
    Development
  </label>
</fieldset>

Password

A password input. Use autocomplete="current-password" for login forms and autocomplete="new-password" for sign up forms. It's common to add a minlength attribute to enforce password policy.

<div class="flex flex-col gap-1">
  <label for="password-input" class="text-sm font-medium">Password</label>
  <div class="relative">
    <input id="password-input" name="password" type="password" autocomplete="current-password" class="rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25 dark:border-gray-600 dark:bg-white/5" minlength="8" required>
    <button id="passwordToggle" type="button" class="group absolute inset-y-0 right-0 flex cursor-pointer items-center rounded-md px-3 outline-none focus-visible:ring-2 focus-visible:ring-brand-500/25" aria-label="Show password" aria-pressed="false">
      <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path class="group-aria-pressed:hidden" d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
        <path class="group-aria-pressed:hidden" d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
        <path class="group-aria-pressed:hidden" d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
        <line class="group-aria-pressed:hidden" x1="2" x2="22" y1="2" y2="22"/>
        <path class="hidden group-aria-pressed:block" d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
        <circle class="hidden group-aria-pressed:block" cx="12" cy="12" r="3"/>
      </svg>
    </button>
  </div>
</div>
<script>
  const passwordInput = document.getElementById("password-input");
  const passwordToggle = document.getElementById("passwordToggle");
  passwordToggle.addEventListener('click', () => {
    if (passwordToggle.ariaPressed === "false") {
      passwordInput.type = "text";
      passwordToggle.ariaLabel = "Hide password";
      passwordToggle.ariaPressed = "true";
    } else {
      passwordInput.type = "password";
      passwordToggle.ariaLabel = "Show password";
      passwordToggle.ariaPressed = "false";
    }
  });
</script>