Tailwind Select: 16 Examples, From Native to Combobox

Tailwind CSS ships no select component. You style the native select element with utilities, and variants handle focus, invalid and dark mode states. They reach the closed control only, until appearance: base-select in Chrome 135 and Safari 27 opens the browser-drawn list to CSS.

Below are 16 Tailwind select examples you can copy, followed by a guide to chevrons, states and base-select.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Native selects styled with utilities

The real select element with appearance-none, an inline SVG chevron laid over it in a one-cell grid, and state variants for focus, errors and disabled fields. The open list stays the native one, so keyboard and mobile pickers keep working.

Chevron overlay in three sizes and three shapes

  • appearance-none
  • col-start-1 row-start-1
  • pointer-events-none
  • forced-colors:appearance-auto
  • forced-colors:hidden

Each select and its SVG share one grid cell through col-start-1 row-start-1, and pointer-events-none lets clicks fall through the icon to the select. The right padding grows with the chevron (pr-7, pr-8, pr-10) so long labels never run under it. forced-colors:appearance-auto brings back the native arrow in Windows contrast themes while forced-colors:hidden removes the overlay.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-900/10 sm:p-8">
  <h2 class="text-lg font-semibold text-zinc-900">Export artboards</h2>
  <p class="mt-1 text-sm text-zinc-600">One native select at three sizes, then as a pill, with a leading icon and as an underline field.</p>
  <div class="mt-6 grid gap-5 md:grid-cols-3 md:items-end">
    <div>
      <label for="tsl-cs-scale" class="block text-xs font-medium text-zinc-700">Scale (small)</label>
      <div class="mt-1.5 grid grid-cols-1">
        <select id="tsl-cs-scale" class="col-start-1 row-start-1 w-full appearance-none rounded-md border border-zinc-500 bg-white py-1 pr-7 pl-2 text-xs text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 forced-colors:appearance-auto">
          <option>0.5x</option>
          <option>1x</option>
          <option selected>2x</option>
          <option>3x</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-3 self-center justify-self-end text-zinc-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tsl-cs-format" class="block text-sm font-medium text-zinc-700">Format (medium)</label>
      <div class="mt-1.5 grid grid-cols-1">
        <select id="tsl-cs-format" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-zinc-500 bg-white py-2 pr-8 pl-3 text-sm text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 forced-colors:appearance-auto">
          <option>PNG</option>
          <option>JPG</option>
          <option selected>WebP</option>
          <option>AVIF</option>
          <option>SVG</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-zinc-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tsl-cs-profile" class="block text-base font-medium text-zinc-700">Color profile (large)</label>
      <div class="mt-1.5 grid grid-cols-1">
        <select id="tsl-cs-profile" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-zinc-500 bg-white py-3 pr-10 pl-4 text-base text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 forced-colors:appearance-auto">
          <option>sRGB</option>
          <option>Display P3</option>
          <option>Adobe RGB (1998)</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3.5 size-5 self-center justify-self-end text-zinc-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
  </div>
  <div class="mt-6 grid gap-5 border-t border-zinc-200 pt-6 md:grid-cols-3 md:items-end">
    <div>
      <label for="tsl-cs-bg" class="block text-sm font-medium text-zinc-700">Background</label>
      <div class="mt-1.5 grid grid-cols-1">
        <select id="tsl-cs-bg" class="col-start-1 row-start-1 w-full appearance-none rounded-full border border-violet-700 bg-violet-50 py-2.5 pr-10 pl-4 text-sm font-medium text-violet-950 hover:bg-violet-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 forced-colors:appearance-auto">
          <option>Transparent</option>
          <option>White</option>
          <option>Artboard color</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-4 size-4 self-center justify-self-end text-violet-700 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tsl-cs-folder" class="block text-sm font-medium text-zinc-700">Save to</label>
      <div class="mt-1.5 grid grid-cols-1">
        <select id="tsl-cs-folder" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-zinc-500 bg-white py-2 pr-8 pl-9 text-sm text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 forced-colors:appearance-auto">
          <option>Exports</option>
          <option>Brand / Social</option>
          <option>Client handoff</option>
        </select>
        <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 ml-3 size-4 self-center justify-self-start text-zinc-600"><path d="M2.5 5.5A1.5 1.5 0 0 1 4 4h3.6l1.8 2H16a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 16 16H4a1.5 1.5 0 0 1-1.5-1.5z"/></svg>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-zinc-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tsl-cs-naming" class="block text-sm font-medium text-zinc-700">File naming</label>
      <div class="mt-1.5 grid grid-cols-1">
        <select id="tsl-cs-naming" class="col-start-1 row-start-1 w-full appearance-none rounded-none border-0 border-b-2 border-zinc-500 bg-transparent py-2 pr-7 pl-0.5 text-sm text-zinc-900 focus-visible:border-violet-700 focus-visible:bg-violet-50 focus-visible:outline-none forced-colors:appearance-auto">
          <option>artboard-name@2x</option>
          <option>page / artboard-name</option>
          <option>date-artboard-name</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-1 size-4 self-center justify-self-end text-zinc-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
  </div>
</div>

Label, help text, error and disabled states

  • aria-[invalid=true]:
  • peer-aria-[invalid=true]:
  • invalid:text-slate-600
  • *:text-slate-900
  • disabled:

Press Continue without picking a country.

The help text is linked with aria-describedby, and the empty placeholder option makes the required select match :invalid, which invalid:text-slate-600 uses to gray the prompt while *:text-slate-900 keeps the open list dark. On submit the script sets aria-invalid and points aria-describedby at the error, so aria-[invalid=true]: paints the field red and peer-aria-[invalid=true]: recolors the chevron. The disabled select keeps its value in a hidden input because disabled fields are not submitted.

View code
<form id="tsl-fs-form" novalidate class="w-full max-w-md space-y-6 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8">
  <div>
    <h2 class="text-lg font-semibold text-slate-900">Workspace billing</h2>
    <p class="mt-1 text-sm text-slate-600">Fields marked with an asterisk are required.</p>
  </div>
  <div>
    <label for="tsl-fs-plan" class="block text-sm font-medium text-slate-800">Plan</label>
    <div class="mt-2 grid grid-cols-1">
      <select id="tsl-fs-plan" name="plan" aria-describedby="tsl-fs-plan-help" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-9 pl-3 text-base text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 sm:text-sm forced-colors:appearance-auto">
        <option value="starter">Starter, $0 per seat</option>
        <option value="team" selected>Team, $12 per seat</option>
        <option value="business">Business, $24 per seat</option>
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-slate-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
    </div>
    <p id="tsl-fs-plan-help" class="mt-2 text-sm text-slate-600">Switch plans at any time. Changes apply from the next invoice.</p>
  </div>
  <div>
    <label for="tsl-fs-country" class="block text-sm font-medium text-slate-800">Billing country <span class="text-red-700" aria-hidden="true">*</span></label>
    <div class="mt-2 grid grid-cols-1">
      <select id="tsl-fs-country" name="country" required class="peer col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-9 pl-3 text-base text-slate-900 invalid:text-slate-600 *:text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[invalid=true]:border-red-700 aria-[invalid=true]:bg-red-50 aria-[invalid=true]:focus-visible:outline-red-700 sm:text-sm forced-colors:appearance-auto">
        <option value="">Select a country</option>
        <option value="CA">Canada</option>
        <option value="DE">Germany</option>
        <option value="IE">Ireland</option>
        <option value="PT">Portugal</option>
        <option value="US">United States</option>
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-slate-600 peer-aria-[invalid=true]:text-red-700 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
    </div>
    <p id="tsl-fs-country-err" hidden class="mt-2 flex items-center gap-1.5 text-sm font-medium text-red-700">
      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4 shrink-0"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm-.75-11.5a.75.75 0 0 1 1.5 0v4a.75.75 0 0 1-1.5 0zM10 14.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd"/></svg>
      Choose the country printed on your invoices.
    </p>
  </div>
  <div>
    <label for="tsl-fs-region" class="block text-sm font-medium text-slate-800">Data region</label>
    <div class="mt-2 grid grid-cols-1">
      <select id="tsl-fs-region" disabled aria-describedby="tsl-fs-region-help" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-9 pl-3 text-base text-slate-900 disabled:cursor-not-allowed disabled:border-slate-300 disabled:bg-slate-100 disabled:text-slate-600 sm:text-sm forced-colors:appearance-auto">
        <option>EU Central (Frankfurt)</option>
      </select>
      <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-slate-500 forced-colors:hidden"><rect x="4.5" y="9" width="11" height="8" rx="1.5"/><path d="M7 9V6.5a3 3 0 0 1 6 0V9"/></svg>
    </div>
    <input type="hidden" name="region" value="eu-central">
    <p id="tsl-fs-region-help" class="mt-2 text-sm text-slate-600">Set when the workspace was created. Contact support to move your data.</p>
  </div>
  <button type="submit" class="min-h-11 w-full rounded-lg bg-sky-800 px-4 py-2.5 text-sm font-semibold text-white hover:bg-sky-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-800">Continue to payment</button>
  <p id="tsl-fs-status" role="status" class="text-sm font-medium text-emerald-800 empty:hidden"></p>
</form>

Optgroup select and a styled multiple select

  • <optgroup>
  • multiple
  • [&_option]:
  • [&_option:checked]:
  • size

Ctrl or Cmd click to pick several channels.

The routing select groups its options with optgroup, which the native list renders as bold, non-selectable headings. A multiple select draws as an always-open list box, so it needs no chevron: the [&_option]: and [&_option:checked]: arbitrary variants pad each row and color the picked ones. The script only counts the selected options for the status line.

View code
<div class="grid w-full max-w-2xl gap-6 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-900/10 sm:p-8 md:grid-cols-2">
  <div>
    <h2 class="text-lg font-semibold text-slate-900">Ticket routing</h2>
    <label for="tsl-om-team" class="mt-5 block text-sm font-medium text-slate-800">Send new tickets to</label>
    <div class="mt-2 grid grid-cols-1">
      <select id="tsl-om-team" aria-describedby="tsl-om-team-help" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-9 pl-3 text-sm text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 forced-colors:appearance-auto">
        <optgroup label="Support">
          <option>Tier 1 inbox</option>
          <option>Billing questions</option>
          <option>Account recovery</option>
        </optgroup>
        <optgroup label="Engineering">
          <option selected>Frontend on-call</option>
          <option>API and integrations</option>
          <option>Infrastructure</option>
        </optgroup>
        <optgroup label="Customer success">
          <option>Enterprise accounts</option>
          <option>Onboarding</option>
        </optgroup>
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-slate-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
    </div>
    <p id="tsl-om-team-help" class="mt-2 text-sm text-slate-600">Departments appear as headings in the open list.</p>
    <div class="mt-5 rounded-xl bg-teal-50 p-4 text-sm text-teal-900">
      <p class="font-medium">Auto-assign is on</p>
      <p class="mt-1 text-teal-800">Tickets rotate between the five people in the chosen queue.</p>
    </div>
  </div>
  <div>
    <label for="tsl-om-channels" class="block text-sm font-medium text-slate-800">Also notify</label>
    <p id="tsl-om-hint" class="mt-1 text-sm text-slate-600">Hold Ctrl or Cmd to pick several, Shift for a range.</p>
    <select id="tsl-om-channels" multiple size="9" aria-describedby="tsl-om-hint" class="mt-2 block w-full rounded-lg border border-slate-500 bg-white p-1.5 text-sm text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-700 [&_option]:rounded-md [&_option]:px-2.5 [&_option]:py-1.5 [&_option:checked]:bg-teal-700 [&_option:checked]:text-white">
      <optgroup label="Chat channels">
        <option value="frontend" selected>#frontend-alerts</option>
        <option value="design">#design-crit</option>
        <option value="releases">#releases</option>
      </optgroup>
      <optgroup label="Email lists">
        <option value="leads">Team leads</option>
        <option value="support">Support managers</option>
        <option value="exec">Weekly digest</option>
      </optgroup>
    </select>
    <p id="tsl-om-count" role="status" class="mt-2 text-sm font-medium text-teal-800">1 channel selected</p>
  </div>
</div>

Selects inside input groups

  • flex
  • min-w-0 flex-1
  • focus-visible:-outline-offset-2
  • rounded-l-lg
  • autocomplete

Change the currency and watch the symbol.

A bordered flex row holds a select and an input that each keep their own inset focus outline (focus-visible:-outline-offset-2), so keyboard users can always tell which half is active. min-w-0 flex-1 lets the input shrink on narrow screens while the select keeps its width. The script only swaps the currency symbol when the currency changes.

View code
<div class="w-full max-w-md space-y-6 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-neutral-900/10 sm:p-8">
  <h2 class="text-lg font-semibold text-neutral-900">Payout details</h2>
  <div>
    <label for="tsl-ig-phone" class="block text-sm font-medium text-neutral-800">Phone number</label>
    <div class="mt-2 flex rounded-lg border border-neutral-500 bg-white">
      <div class="grid shrink-0 grid-cols-1">
        <select id="tsl-ig-code" aria-label="Country code" autocomplete="tel-country-code" class="col-start-1 row-start-1 appearance-none rounded-l-lg bg-transparent py-2.5 pr-8 pl-3 text-base text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-700 sm:text-sm forced-colors:appearance-auto">
          <option value="+1">US +1</option>
          <option value="+44">UK +44</option>
          <option value="+49">DE +49</option>
          <option value="+61">AU +61</option>
          <option value="+81">JP +81</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-neutral-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
      <span class="my-2 w-px bg-neutral-300" aria-hidden="true"></span>
      <input id="tsl-ig-phone" type="tel" autocomplete="tel-national" placeholder="555 0134" class="min-w-0 flex-1 rounded-r-lg bg-transparent px-3 py-2.5 text-base text-neutral-900 placeholder:text-neutral-500 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-700 sm:text-sm">
    </div>
  </div>
  <div>
    <label for="tsl-ig-amount" class="block text-sm font-medium text-neutral-800">Minimum payout</label>
    <div class="mt-2 flex rounded-lg border border-neutral-500 bg-white">
      <span id="tsl-ig-sym" aria-hidden="true" class="grid min-w-9 place-items-center pl-3 text-base text-neutral-600 sm:text-sm">$</span>
      <input id="tsl-ig-amount" type="text" inputmode="decimal" placeholder="0.00" value="250.00" class="min-w-0 flex-1 bg-transparent py-2.5 pr-3 pl-1.5 text-base text-neutral-900 placeholder:text-neutral-500 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-700 sm:text-sm">
      <div class="grid shrink-0 grid-cols-1 border-l border-neutral-300">
        <select id="tsl-ig-cur" aria-label="Currency" class="col-start-1 row-start-1 appearance-none rounded-r-lg bg-neutral-50 py-2.5 pr-8 pl-3 text-base font-medium text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-700 sm:text-sm forced-colors:appearance-auto">
          <option>USD</option>
          <option>EUR</option>
          <option>GBP</option>
          <option>JPY</option>
          <option>CHF</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-neutral-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
  </div>
  <div>
    <label for="tsl-ig-every" class="block text-sm font-medium text-neutral-800">Pay out every</label>
    <div class="mt-2 flex rounded-lg border border-neutral-500 bg-white">
      <input id="tsl-ig-every" type="number" min="1" max="12" value="2" class="w-20 min-w-0 rounded-l-lg bg-transparent px-3 py-2.5 text-base text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-700 sm:text-sm">
      <div class="grid flex-1 grid-cols-1 border-l border-neutral-300">
        <select id="tsl-ig-unit" aria-label="Interval unit" class="col-start-1 row-start-1 w-full appearance-none rounded-r-lg bg-transparent py-2.5 pr-8 pl-3 text-base text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-700 sm:text-sm forced-colors:appearance-auto">
          <option>days</option>
          <option selected>weeks</option>
          <option>months</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-neutral-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
  </div>
</div>

Customizable select with appearance: base-select

Chrome 135+ and Safari 27 let CSS style the open list itself: rich options with icons and descriptions, the picker, the arrow and the checkmark. Browsers without support, Firefox included, keep showing a normal native select with the same text.

Status picker with icons, built from arbitrary variants

  • [appearance:base-select]
  • [&::picker(select)]:
  • [&::picker-icon]:
  • open:
  • checked:
  • [&::checkmark]:

Open the select in Chrome, Edge or Safari 27 to see the styled picker.

Tailwind has no base-select utility, so the arbitrary property [appearance:base-select] opts the select in, and [&::picker(select)]:[appearance:base-select] does the same for its popup, which the other [&::picker(select)]: classes then round, pad and shadow. A button with selectedcontent mirrors the chosen option, icon included, and open:[&::picker-icon]:rotate-180 flips the arrow. Browsers without support drop the invalid value, ignore the SVG inside the options and show a normal select with the same labels.

View code
<div class="w-full max-w-sm rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/10 sm:p-6">
  <h2 class="text-base font-semibold text-slate-900">Search field loses its focus ring</h2>
  <p class="mt-1 text-sm text-slate-600">WEB-482, opened by Priya Raman</p>
  <label for="tsl-bs-status" class="mt-5 block text-sm font-medium text-slate-800">Status</label>
  <select id="tsl-bs-status" class="mt-2 w-full rounded-lg border border-slate-500 bg-white px-3 py-2.5 text-sm text-slate-900 [appearance:base-select] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 [&::picker-icon]:text-slate-500 motion-safe:[&::picker-icon]:transition-transform open:[&::picker-icon]:rotate-180 [&::picker(select)]:[appearance:base-select] [&::picker(select)]:mt-1.5 [&::picker(select)]:rounded-xl [&::picker(select)]:border-slate-200 [&::picker(select)]:bg-white [&::picker(select)]:p-1.5 [&::picker(select)]:shadow-lg">
    <button class="flex w-full items-center"><selectedcontent class="flex items-center gap-2.5"></selectedcontent></button>
    <option value="backlog" class="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm text-slate-800 hover:bg-slate-100 checked:bg-blue-50 checked:font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 [&::checkmark]:order-last [&::checkmark]:ml-auto [&::checkmark]:text-blue-700"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true" class="size-4 shrink-0 text-slate-500"><circle cx="8" cy="8" r="6" stroke-dasharray="2.2 2.2"/></svg>Backlog</option>
    <option value="todo" class="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm text-slate-800 hover:bg-slate-100 checked:bg-blue-50 checked:font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 [&::checkmark]:order-last [&::checkmark]:ml-auto [&::checkmark]:text-blue-700"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true" class="size-4 shrink-0 text-slate-600"><circle cx="8" cy="8" r="6"/></svg>Todo</option>
    <option value="progress" selected class="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm text-slate-800 hover:bg-slate-100 checked:bg-blue-50 checked:font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 [&::checkmark]:order-last [&::checkmark]:ml-auto [&::checkmark]:text-blue-700"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true" class="size-4 shrink-0 text-amber-600"><circle cx="8" cy="8" r="6"/><path d="M8 4a4 4 0 0 1 0 8z" fill="currentColor" stroke="none"/></svg>In progress</option>
    <option value="review" class="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm text-slate-800 hover:bg-slate-100 checked:bg-blue-50 checked:font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 [&::checkmark]:order-last [&::checkmark]:ml-auto [&::checkmark]:text-blue-700"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true" class="size-4 shrink-0 text-violet-600"><circle cx="8" cy="8" r="6"/><path d="M8 4a4 4 0 1 1-4 4h4z" fill="currentColor" stroke="none"/></svg>In review</option>
    <option value="done" class="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm text-slate-800 hover:bg-slate-100 checked:bg-blue-50 checked:font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 [&::checkmark]:order-last [&::checkmark]:ml-auto [&::checkmark]:text-blue-700"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4 shrink-0 text-emerald-600"><circle cx="8" cy="8" r="7" fill="currentColor" stroke="none"/><path d="m5 8.2 2 2 4-4.2" stroke="white"/></svg>Done</option>
    <option value="canceled" class="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm text-slate-800 hover:bg-slate-100 checked:bg-blue-50 checked:font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 [&::checkmark]:order-last [&::checkmark]:ml-auto [&::checkmark]:text-blue-700"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true" class="size-4 shrink-0 text-slate-500"><circle cx="8" cy="8" r="6"/><path d="m6 6 4 4m0-4-4 4"/></svg>Canceled</option>
  </select>
  <p id="tsl-bs-log" role="status" class="mt-4 text-sm text-slate-600">Jonas Berg set the status to In progress 2 hours ago.</p>
</div>

Typeface picker styled from the stylesheet

  • @supports (appearance: base-select)
  • ::picker(select)
  • @apply
  • @theme
  • selectedcontent

Open the select in Chrome, Edge or Safari 27 to see the styled picker.

This version keeps the markup clean: the opt-in, the picker and the option rows live in the stylesheet inside @supports (appearance: base-select), with @apply reusing the same utilities. Prefixed @theme tokens add the paper and ink colors and five system font stacks, so each option previews its own typeface and font-tsl-* classes restyle the sample article. Small print in each option is hidden inside selectedcontent, and older browsers show a plain select with the same names.

View code
<div class="grid w-full max-w-3xl gap-6 rounded-2xl bg-tsl-paper p-6 text-tsl-ink ring-1 ring-tsl-rule/40 sm:p-8 md:grid-cols-[15rem_1fr]">
  <div class="space-y-5">
    <h2 class="text-lg font-semibold">Article typography</h2>
    <div>
      <label for="tsl-tf-face" class="block text-sm font-medium">Body typeface</label>
      <select id="tsl-tf-face" class="tsl-font mt-2 w-full rounded-lg border border-tsl-rule bg-white px-3 py-2.5 text-base text-tsl-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tsl-ink">
        <button><selectedcontent></selectedcontent></button>
        <option value="transitional" selected><span class="font-tsl-transitional">Transitional serif</span> <small>Charter, Cambria</small></option>
        <option value="oldstyle"><span class="font-tsl-oldstyle">Old style serif</span> <small>Iowan, Palatino</small></option>
        <option value="humanist"><span class="font-tsl-humanist">Humanist sans</span> <small>Seravek, Calibri</small></option>
        <option value="rounded"><span class="font-tsl-rounded">Rounded sans</span> <small>SF Rounded, Quicksand</small></option>
        <option value="mono"><span class="font-tsl-mono">Monospace</span> <small>Cascadia, Menlo</small></option>
      </select>
    </div>
    <div>
      <label for="tsl-tf-size" class="block text-sm font-medium">Body size</label>
      <select id="tsl-tf-size" class="tsl-font mt-2 w-full rounded-lg border border-tsl-rule bg-white px-3 py-2.5 text-base text-tsl-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tsl-ink">
        <button><selectedcontent></selectedcontent></button>
        <option value="16">16 px <small>Dense layouts</small></option>
        <option value="18" selected>18 px <small>Long reads</small></option>
        <option value="20">20 px <small>Large screens</small></option>
      </select>
    </div>
    <p class="text-sm text-tsl-muted">System font stacks only, so nothing extra downloads.</p>
  </div>
  <article id="tsl-tf-preview" class="rounded-xl bg-white p-6 ring-1 ring-tsl-rule/30 font-tsl-transitional text-lg">
    <h3 class="text-2xl leading-tight font-semibold">Notes from a slow week on the coast</h3>
    <p class="mt-3 leading-relaxed">The ferry only runs twice a day in October, which turns out to be the right number. Mornings go to the harbor wall and a thermos, afternoons to the long road that follows the cliffs south.</p>
    <p class="mt-3 leading-relaxed text-tsl-muted">Filed under travel, 6 min read</p>
  </article>
</div>

Share dialog with role descriptions in dark mode

  • [appearance:base-select]
  • dark:
  • [&_selectedcontent_small]:hidden
  • disabled:
  • scheme-dark

Enter an email, pick a role and press Invite.

Every color has a dark: twin, so the same card works in both themes, and scheme-light dark:scheme-dark keeps the native fallback list matching. The role options hold an icon, a name and a description, and the arbitrary variant [&_selectedcontent_small]:hidden shows only the icon and name in the closed button. A disabled Owner option explains itself instead of disappearing, and the invite form adds a member row built with createElement.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 text-slate-900 shadow-xl ring-1 ring-slate-900/10 scheme-light sm:p-7 dark:bg-slate-900 dark:text-slate-100 dark:ring-white/10 dark:scheme-dark">
  <h2 class="text-lg font-semibold">Share Q4 launch plan</h2>
  <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">People you invite get an email with a link to this document.</p>
  <form id="tsl-pm-form" class="mt-5 grid gap-3 sm:grid-cols-[1fr_11rem_auto]">
    <label for="tsl-pm-email" class="sr-only">Email address</label>
    <input id="tsl-pm-email" type="email" required autocomplete="off" placeholder="name@example.com" class="min-h-11 w-full rounded-lg border border-slate-500 bg-white px-3 text-sm text-slate-900 placeholder:text-slate-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-600 dark:border-slate-500 dark:bg-slate-800 dark:text-white dark:placeholder:text-slate-400 dark:focus-visible:outline-lime-400">
    <label for="tsl-pm-role" class="sr-only">Role</label>
    <select id="tsl-pm-role" class="min-h-11 w-full rounded-lg border border-slate-500 bg-white px-3 text-sm text-slate-900 [appearance:base-select] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-600 dark:bg-slate-800 dark:text-white dark:focus-visible:outline-lime-400 [&_selectedcontent_small]:hidden [&::picker-icon]:text-slate-500 dark:[&::picker-icon]:text-slate-400 [&::picker(select)]:[appearance:base-select] [&::picker(select)]:mt-1.5 [&::picker(select)]:w-72 [&::picker(select)]:max-w-[calc(100vw-2rem)] [&::picker(select)]:rounded-xl [&::picker(select)]:border-slate-200 [&::picker(select)]:bg-white [&::picker(select)]:p-1.5 [&::picker(select)]:shadow-2xl dark:[&::picker(select)]:border-white/10 dark:[&::picker(select)]:bg-slate-800">
      <button class="flex w-full items-center"><selectedcontent class="flex items-center gap-2"></selectedcontent></button>
      <option value="viewer" data-label="Viewer" class="flex items-start gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-900 hover:bg-slate-100 checked:bg-lime-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-lime-600 dark:text-slate-100 dark:hover:bg-white/10 dark:checked:bg-lime-400/15 dark:focus-visible:outline-lime-400 [&::checkmark]:hidden"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" class="mt-0.5 size-4 shrink-0"><path d="M1.8 10S5 4.5 10 4.5 18.2 10 18.2 10 15 15.5 10 15.5 1.8 10 1.8 10Z"/><circle cx="10" cy="10" r="2.5"/></svg><span class="flex min-w-0 flex-col whitespace-normal"><span class="font-medium">Viewer</span><small class="text-xs text-slate-600 dark:text-slate-400">Can view and download</small></span></option>
      <option value="commenter" data-label="Commenter" class="flex items-start gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-900 hover:bg-slate-100 checked:bg-lime-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-lime-600 dark:text-slate-100 dark:hover:bg-white/10 dark:checked:bg-lime-400/15 dark:focus-visible:outline-lime-400 [&::checkmark]:hidden"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true" class="mt-0.5 size-4 shrink-0"><path d="M3 4.5h14v9H8l-4 3v-3H3z"/></svg><span class="flex min-w-0 flex-col whitespace-normal"><span class="font-medium">Commenter</span><small class="text-xs text-slate-600 dark:text-slate-400">Can view and leave comments</small></span></option>
      <option value="editor" data-label="Editor" selected class="flex items-start gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-900 hover:bg-slate-100 checked:bg-lime-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-lime-600 dark:text-slate-100 dark:hover:bg-white/10 dark:checked:bg-lime-400/15 dark:focus-visible:outline-lime-400 [&::checkmark]:hidden"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true" class="mt-0.5 size-4 shrink-0"><path d="m13.5 3.5 3 3L7 16H4v-3z"/></svg><span class="flex min-w-0 flex-col whitespace-normal"><span class="font-medium">Editor</span><small class="text-xs text-slate-600 dark:text-slate-400">Can edit, comment and share</small></span></option>
      <option value="owner" data-label="Owner" disabled class="flex items-start gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-900 disabled:opacity-60 dark:text-slate-100 [&::checkmark]:hidden"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true" class="mt-0.5 size-4 shrink-0"><path d="M10 2.5 16 5v4.5c0 3.8-2.6 6.6-6 8-3.4-1.4-6-4.2-6-8V5z"/></svg><span class="flex min-w-0 flex-col whitespace-normal"><span class="font-medium">Owner</span><small class="text-xs text-slate-600 dark:text-slate-400">Only workspace admins can transfer ownership</small></span></option>
    </select>
    <button type="submit" class="min-h-11 rounded-lg bg-lime-400 px-5 text-sm font-semibold text-slate-950 hover:bg-lime-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-600 dark:focus-visible:outline-lime-300">Invite</button>
  </form>
  <p id="tsl-pm-live" role="status" class="mt-2 text-sm text-slate-600 empty:hidden dark:text-slate-400"></p>
  <h3 class="mt-6 text-sm font-medium text-slate-700 dark:text-slate-300">People with access</h3>
  <ul id="tsl-pm-list" class="mt-3 divide-y divide-slate-200 dark:divide-white/10">
    <li class="flex items-center gap-3 py-2.5"><span class="grid size-9 shrink-0 place-items-center rounded-full bg-amber-200 text-xs font-semibold text-amber-950" aria-hidden="true">MO</span><span class="min-w-0 flex-1"><span class="block truncate text-sm font-medium">Maya Okafor</span><span class="block truncate text-xs text-slate-600 dark:text-slate-400">maya@example.com</span></span><span class="text-sm text-slate-600 dark:text-slate-400">Owner</span></li>
    <li class="flex items-center gap-3 py-2.5"><span class="grid size-9 shrink-0 place-items-center rounded-full bg-sky-200 text-xs font-semibold text-sky-950" aria-hidden="true">JB</span><span class="min-w-0 flex-1"><span class="block truncate text-sm font-medium">Jonas Berg</span><span class="block truncate text-xs text-slate-600 dark:text-slate-400">jonas@example.com</span></span><span class="text-sm text-slate-600 dark:text-slate-400">Editor</span></li>
    <li class="flex items-center gap-3 py-2.5"><span class="grid size-9 shrink-0 place-items-center rounded-full bg-rose-200 text-xs font-semibold text-rose-950" aria-hidden="true">PR</span><span class="min-w-0 flex-1"><span class="block truncate text-sm font-medium">Priya Raman</span><span class="block truncate text-xs text-slate-600 dark:text-slate-400">priya@example.com</span></span><span class="text-sm text-slate-600 dark:text-slate-400">Viewer</span></li>
  </ul>
</div>

Custom listbox, combobox and multi-select

When options need avatars, search or chips, the widget is rebuilt with ARIA roles from the W3C APG combobox patterns: aria-expanded, aria-activedescendant, listbox and option roles, arrow keys and typeahead. Tailwind styles every state through aria-* and data-* variants.

Select-only listbox with avatars and typeahead

  • role="combobox"
  • aria-activedescendant
  • data-active:
  • group-aria-selected:
  • aria-expanded:

Focus the field, press Down, then type a name.

This follows the W3C APG select-only combobox: focus stays on the combobox while aria-activedescendant points at the highlighted option, so the script only moves a data-active attribute that data-active: and group-data-active: utilities paint. Arrow keys, Home, End, Enter, Space, Escape, Tab and typeahead all work, and aria-selected drives the checkmark through group-aria-selected:visible. A hidden input carries the value for a normal form post.

View code
<div class="w-full max-w-sm rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-900/10 sm:p-6">
  <h2 class="text-base font-semibold text-slate-900">Hero image for the spring campaign</h2>
  <p class="mt-1 text-sm text-slate-600">Due Friday, design review on Thursday.</p>
  <span id="tsl-la-label" class="mt-5 block text-sm font-medium text-slate-800">Assignee</span>
  <div class="relative mt-2">
    <div id="tsl-la-combo" role="combobox" tabindex="0" aria-haspopup="listbox" aria-expanded="false" aria-controls="tsl-la-list" aria-labelledby="tsl-la-label" class="flex min-h-12 w-full cursor-default items-center gap-3 rounded-lg border border-slate-500 bg-white py-2 pr-10 pl-2.5 text-sm text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 aria-expanded:border-indigo-600">
      <span id="tsl-la-avatar" class="contents"></span>
      <span id="tsl-la-value" class="truncate font-medium"></span>
    </div>
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-slate-600"><path d="m4 6 4 4 4-4"/></svg>
    <ul id="tsl-la-list" role="listbox" aria-labelledby="tsl-la-label" tabindex="-1" hidden class="absolute z-10 mt-2 max-h-72 w-full overflow-auto rounded-xl bg-white p-1.5 shadow-lg ring-1 ring-slate-900/10">
      <li id="tsl-la-o0" role="option" aria-selected="false" data-value="" class="group flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-slate-800 data-active:bg-indigo-600 data-active:text-white"><span class="tsl-la-av grid size-8 shrink-0 place-items-center rounded-full border border-dashed border-slate-500 text-slate-600 group-data-active:border-white group-data-active:text-white" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" class="size-4"><circle cx="10" cy="7" r="3"/><path d="M4 17a6 6 0 0 1 12 0"/></svg></span><span class="min-w-0 flex-1"><span class="tsl-la-name block truncate font-medium">Unassigned</span></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="invisible size-4 text-indigo-600 group-aria-selected:visible group-data-active:text-white"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      <li id="tsl-la-o1" role="option" aria-selected="false" data-value="maya" class="group flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-slate-800 data-active:bg-indigo-600 data-active:text-white"><span class="tsl-la-av grid size-8 shrink-0 place-items-center rounded-full bg-amber-200 text-xs font-semibold text-amber-950" aria-hidden="true">MO</span><span class="min-w-0 flex-1"><span class="tsl-la-name block truncate font-medium">Maya Okafor</span><span class="block truncate text-xs text-slate-600 group-data-active:text-indigo-100">Design lead</span></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="invisible size-4 text-indigo-600 group-aria-selected:visible group-data-active:text-white"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      <li id="tsl-la-o2" role="option" aria-selected="false" data-value="jonas" class="group flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-slate-800 data-active:bg-indigo-600 data-active:text-white"><span class="tsl-la-av grid size-8 shrink-0 place-items-center rounded-full bg-sky-200 text-xs font-semibold text-sky-950" aria-hidden="true">JB</span><span class="min-w-0 flex-1"><span class="tsl-la-name block truncate font-medium">Jonas Berg</span><span class="block truncate text-xs text-slate-600 group-data-active:text-indigo-100">Frontend engineer</span></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="invisible size-4 text-indigo-600 group-aria-selected:visible group-data-active:text-white"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      <li id="tsl-la-o3" role="option" aria-selected="false" data-value="priya" class="group flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-slate-800 data-active:bg-indigo-600 data-active:text-white"><span class="tsl-la-av grid size-8 shrink-0 place-items-center rounded-full bg-rose-200 text-xs font-semibold text-rose-950" aria-hidden="true">PR</span><span class="min-w-0 flex-1"><span class="tsl-la-name block truncate font-medium">Priya Raman</span><span class="block truncate text-xs text-slate-600 group-data-active:text-indigo-100">Product manager</span></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="invisible size-4 text-indigo-600 group-aria-selected:visible group-data-active:text-white"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      <li id="tsl-la-o4" role="option" aria-selected="false" data-value="lucas" class="group flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-slate-800 data-active:bg-indigo-600 data-active:text-white"><span class="tsl-la-av grid size-8 shrink-0 place-items-center rounded-full bg-emerald-200 text-xs font-semibold text-emerald-950" aria-hidden="true">LF</span><span class="min-w-0 flex-1"><span class="tsl-la-name block truncate font-medium">Lucas Ferreira</span><span class="block truncate text-xs text-slate-600 group-data-active:text-indigo-100">Backend engineer</span></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="invisible size-4 text-indigo-600 group-aria-selected:visible group-data-active:text-white"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      <li id="tsl-la-o5" role="option" aria-selected="false" data-value="aiko" class="group flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-slate-800 data-active:bg-indigo-600 data-active:text-white"><span class="tsl-la-av grid size-8 shrink-0 place-items-center rounded-full bg-violet-200 text-xs font-semibold text-violet-950" aria-hidden="true">AT</span><span class="min-w-0 flex-1"><span class="tsl-la-name block truncate font-medium">Aiko Tanaka</span><span class="block truncate text-xs text-slate-600 group-data-active:text-indigo-100">QA lead</span></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="invisible size-4 text-indigo-600 group-aria-selected:visible group-data-active:text-white"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      <li id="tsl-la-o6" role="option" aria-selected="false" data-value="sofia" class="group flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-slate-800 data-active:bg-indigo-600 data-active:text-white"><span class="tsl-la-av grid size-8 shrink-0 place-items-center rounded-full bg-orange-200 text-xs font-semibold text-orange-950" aria-hidden="true">SL</span><span class="min-w-0 flex-1"><span class="tsl-la-name block truncate font-medium">Sofia Lindqvist</span><span class="block truncate text-xs text-slate-600 group-data-active:text-indigo-100">Content designer</span></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="invisible size-4 text-indigo-600 group-aria-selected:visible group-data-active:text-white"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
    </ul>
  </div>
  <input type="hidden" id="tsl-la-input" name="assignee" value="">
</div>

Time zone combobox with filtering

  • aria-autocomplete="list"
  • aria-activedescendant
  • data-active:
  • group-data-active:
  • <mark>

Type a city or region such as tok or europe.

An editable combobox with list autocomplete and manual selection, as in the W3C APG: typing filters the options, Down and Up move the highlight, Enter picks it and Escape closes the list or clears the field. The script rebuilds the options with createElement and wraps the matched letters in a mark element, while data-active: and group-data-active: utilities handle the highlight. A polite live region announces how many zones match.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-5 shadow-sm ring-1 ring-stone-900/10 sm:p-6">
  <h2 class="text-base font-semibold text-stone-900">Schedule the kickoff call</h2>
  <p class="mt-1 text-sm text-stone-600">Thursday at 15:00, 45 minutes</p>
  <label id="tsl-cf-label" for="tsl-cf-input" class="mt-5 block text-sm font-medium text-stone-800">Time zone</label>
  <div id="tsl-cf-wrap" class="relative mt-2">
    <input id="tsl-cf-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="tsl-cf-list" autocomplete="off" spellcheck="false" value="Lisbon" class="min-h-12 w-full rounded-lg border border-stone-500 bg-white py-2.5 pr-11 pl-3 text-base text-stone-900 placeholder:text-stone-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 sm:text-sm">
    <button id="tsl-cf-toggle" type="button" tabindex="-1" aria-label="Show time zones" aria-controls="tsl-cf-list" aria-expanded="false" class="absolute inset-y-0 right-0 grid w-11 place-items-center rounded-r-lg text-stone-600 hover:text-stone-900">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4"><path d="m4 6 4 4 4-4"/></svg>
    </button>
    <div id="tsl-cf-pop" hidden class="absolute z-10 mt-2 w-full rounded-xl bg-white p-1.5 shadow-lg ring-1 ring-stone-900/10">
      <ul id="tsl-cf-list" role="listbox" aria-labelledby="tsl-cf-label" class="max-h-64 overflow-auto"></ul>
      <p id="tsl-cf-empty" hidden class="px-3 py-6 text-center text-sm text-stone-600">No time zone matches that. Try a city or a region such as Europe.</p>
    </div>
  </div>
  <p id="tsl-cf-chosen" class="mt-3 text-sm text-stone-600">Invites go out in Lisbon time (Europe/Lisbon).</p>
  <p id="tsl-cf-live" class="sr-only" aria-live="polite"></p>
</div>

Multi-select with removable chips

  • aria-multiselectable
  • focus-within:
  • group-aria-selected:
  • data-active:
  • flex-wrap

Type to filter, press Enter to add, Backspace to remove.

The field is a flex-wrap row of chips plus a combobox input, outlined as one control with focus-within:. The listbox sets aria-multiselectable="true", so Enter or a click toggles aria-selected and the list stays open, and group-aria-selected: fills the checkbox square. Each chip is a list item with its own Remove button, Backspace in an empty input removes the last one, and a live region reports every change.

View code
<div class="w-full max-w-md rounded-2xl bg-white p-5 shadow-sm ring-1 ring-stone-900/10 sm:p-6">
  <h2 class="text-base font-semibold text-stone-900">Your designer profile</h2>
  <label id="tsl-mc-label" for="tsl-mc-input" class="mt-4 block text-sm font-medium text-stone-800">Skills</label>
  <p id="tsl-mc-help" class="mt-1 text-sm text-stone-600">Pick up to five. They show on your public profile.</p>
  <div id="tsl-mc-wrap" class="relative mt-2">
    <div id="tsl-mc-box" class="flex min-h-12 flex-wrap items-center gap-1.5 rounded-lg border border-stone-500 bg-white p-1.5 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-fuchsia-700">
      <ul id="tsl-mc-chips" aria-label="Selected skills" class="contents"></ul>
      <input id="tsl-mc-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="tsl-mc-list" aria-describedby="tsl-mc-help" autocomplete="off" placeholder="Add a skill" class="min-w-24 flex-1 bg-transparent px-1.5 py-1.5 text-base text-stone-900 placeholder:text-stone-500 focus:outline-none sm:text-sm">
    </div>
    <ul id="tsl-mc-list" role="listbox" aria-labelledby="tsl-mc-label" aria-multiselectable="true" hidden class="absolute z-10 mt-2 max-h-64 w-full overflow-auto rounded-xl bg-white p-1.5 shadow-lg ring-1 ring-stone-900/10"></ul>
  </div>
  <p id="tsl-mc-count" class="mt-2 text-sm text-stone-600"></p>
  <p id="tsl-mc-live" class="sr-only" aria-live="polite"></p>
</div>

Selects in real interfaces

Where selects actually live: dependent country and region fields, a date of birth split in three, product variants that change the price, a job board toolbar, a filter written as a sentence and a settings card with its own dark mode.

Dependent selects: country to region

  • disabled:
  • new Option()
  • sm:grid-cols-[1fr_9rem]
  • appearance-none
  • aria-describedby

Pick a country to load its regions.

The region select starts disabled, and disabled: utilities gray it out until a country is picked. On change the script refills it with new Option(), renames its label (State or territory, Province or territory, State, Region) and the postal code label to match the country, and updates the help text it is described by. All styling stays in utilities; the script never touches a class.

View code
<form id="tsl-dr-form" class="w-full max-w-md space-y-5 rounded-2xl bg-stone-50 p-6 ring-1 ring-stone-900/10 sm:p-8">
  <div>
    <h2 class="text-lg font-semibold text-stone-900">Shipping address</h2>
    <p class="mt-1 text-sm text-stone-600">We ship framed prints to four countries.</p>
  </div>
  <div>
    <label for="tsl-dr-country" class="block text-sm font-medium text-stone-800">Country</label>
    <div class="mt-2 grid grid-cols-1">
      <select id="tsl-dr-country" name="country" autocomplete="country" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-stone-500 bg-white py-2.5 pr-9 pl-3 text-base text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-700 sm:text-sm forced-colors:appearance-auto">
        <option value="">Select a country</option>
        <option value="AU">Australia</option>
        <option value="CA">Canada</option>
        <option value="DE">Germany</option>
        <option value="NZ">New Zealand</option>
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-stone-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
    </div>
  </div>
  <div>
    <label id="tsl-dr-region-label" for="tsl-dr-region" class="block text-sm font-medium text-stone-800">State or region</label>
    <div class="mt-2 grid grid-cols-1">
      <select id="tsl-dr-region" name="region" autocomplete="address-level1" disabled aria-describedby="tsl-dr-region-help" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-stone-500 bg-white py-2.5 pr-9 pl-3 text-base text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-700 disabled:cursor-not-allowed disabled:border-stone-300 disabled:bg-stone-100 disabled:text-stone-600 sm:text-sm forced-colors:appearance-auto">
        <option value="">Choose a country first</option>
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-stone-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
    </div>
    <p id="tsl-dr-region-help" class="mt-2 text-sm text-stone-600">The list loads once you pick a country.</p>
  </div>
  <div class="grid gap-4 sm:grid-cols-[1fr_9rem]">
    <div>
      <label for="tsl-dr-city" class="block text-sm font-medium text-stone-800">City</label>
      <input id="tsl-dr-city" type="text" autocomplete="address-level2" class="mt-2 block w-full rounded-lg border border-stone-500 bg-white px-3 py-2.5 text-base text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-700 sm:text-sm">
    </div>
    <div>
      <label id="tsl-dr-postal-label" for="tsl-dr-postal" class="block text-sm font-medium text-stone-800">Postal code</label>
      <input id="tsl-dr-postal" type="text" autocomplete="postal-code" class="mt-2 block w-full rounded-lg border border-stone-500 bg-white px-3 py-2.5 text-base text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-700 sm:text-sm">
    </div>
  </div>
</form>

Date of birth split into three selects

  • <fieldset>
  • col-span-2 sm:col-span-1
  • sm:grid-cols-[1fr_5.5rem_7rem]
  • autocomplete="bday-*"
  • empty:hidden

Pick February, then switch the year between 2024 and 2023.

A fieldset and legend name the group, and each select keeps its own visible label and bday-month, bday-day or bday-year autocomplete token. The grid puts the month on its own row on phones and uses an arbitrary three-column template from sm: up. The script fills the years, rebuilds the day list for the month length (29 in leap-year Februaries) and writes the result into a status line that empty:hidden hides until there is one.

View code
<form class="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-rose-900/10 sm:p-8">
  <fieldset>
    <legend class="text-lg font-semibold text-stone-900">Date of birth</legend>
    <p id="tsl-dp-help" class="mt-1 text-sm text-stone-600">We use this to confirm you are old enough to open an account.</p>
    <div class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-[1fr_5.5rem_7rem]">
      <div class="col-span-2 sm:col-span-1">
        <label for="tsl-dp-month" class="block text-sm font-medium text-stone-800">Month</label>
        <div class="mt-1.5 grid grid-cols-1">
          <select id="tsl-dp-month" autocomplete="bday-month" aria-describedby="tsl-dp-help" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-stone-500 bg-white py-2.5 pr-8 pl-3 text-base text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-700 sm:text-sm forced-colors:appearance-auto">
            <option value="">Month</option>
            <option value="1">January</option>
            <option value="2">February</option>
            <option value="3">March</option>
            <option value="4">April</option>
            <option value="5">May</option>
            <option value="6">June</option>
            <option value="7">July</option>
            <option value="8">August</option>
            <option value="9">September</option>
            <option value="10">October</option>
            <option value="11">November</option>
            <option value="12">December</option>
          </select>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-stone-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
        </div>
      </div>
      <div>
        <label for="tsl-dp-day" class="block text-sm font-medium text-stone-800">Day</label>
        <div class="mt-1.5 grid grid-cols-1">
          <select id="tsl-dp-day" autocomplete="bday-day" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-stone-500 bg-white py-2.5 pr-8 pl-3 text-base text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-700 sm:text-sm forced-colors:appearance-auto">
            <option value="">Day</option>
          </select>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-stone-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
        </div>
      </div>
      <div>
        <label for="tsl-dp-year" class="block text-sm font-medium text-stone-800">Year</label>
        <div class="mt-1.5 grid grid-cols-1">
          <select id="tsl-dp-year" autocomplete="bday-year" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-stone-500 bg-white py-2.5 pr-8 pl-3 text-base text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-700 sm:text-sm forced-colors:appearance-auto">
            <option value="">Year</option>
          </select>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-stone-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
        </div>
      </div>
    </div>
  </fieldset>
  <p id="tsl-dp-out" role="status" class="mt-5 rounded-lg bg-rose-50 px-3 py-2.5 text-sm text-rose-950 empty:hidden"></p>
</form>

Product variants that change price and frame

  • md:grid-cols-2
  • disabled option
  • bg-[#b98d58]
  • tabular-nums
  • aspect-[4/3]

Change the size and the frame.

Size, frame and quantity are three native selects; the sold-out size stays in the list as a disabled option so shoppers know it exists. On change the script swaps full class strings on the frame wrapper (arbitrary oak and ash colors, or no frame at all), recalculates the tabular-nums price and rewrites the stock line. The add-to-cart button is a real submit handled with preventDefault.

View code
<form id="tsl-pv-form" class="grid w-full max-w-3xl gap-6 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-neutral-900/10 sm:p-7 md:grid-cols-2 md:items-center">
  <div class="grid aspect-[4/3] place-items-center rounded-xl bg-neutral-100 p-6">
    <div id="tsl-pv-frame" class="w-full bg-[#b98d58] p-2.5 shadow-md ring-1 ring-[#8f6634]">
      <div id="tsl-pv-mat" class="bg-white p-3 sm:p-4">
        <img src="fjord.webp" width="800" height="600" alt="Deep blue fjord between steep grey cliffs, seen from a rocky ledge high above the water" class="block h-auto w-full">
      </div>
    </div>
  </div>
  <div>
    <h2 class="text-xl font-semibold text-neutral-900">Fjord at first light</h2>
    <p class="mt-1 text-sm text-neutral-600">Archival pigment print on matte cotton rag, signed on the back.</p>
    <p class="mt-4 text-3xl font-semibold text-neutral-900 tabular-nums" id="tsl-pv-price">$120</p>
    <div class="mt-5 space-y-4">
      <div>
        <label for="tsl-pv-size" class="block text-sm font-medium text-neutral-800">Size</label>
        <div class="mt-1.5 grid grid-cols-1">
          <select id="tsl-pv-size" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-neutral-500 bg-white py-2.5 pr-9 pl-3 text-base text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900 sm:text-sm forced-colors:appearance-auto">
            <option value="s">30 x 40 cm</option>
            <option value="m" selected>50 x 70 cm</option>
            <option value="l">70 x 100 cm</option>
            <option value="xl" disabled>100 x 140 cm (sold out)</option>
          </select>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-neutral-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
        </div>
      </div>
      <div class="grid grid-cols-[1fr_6rem] gap-3">
        <div>
          <label for="tsl-pv-frame-select" class="block text-sm font-medium text-neutral-800">Frame</label>
          <div class="mt-1.5 grid grid-cols-1">
            <select id="tsl-pv-frame-select" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-neutral-500 bg-white py-2.5 pr-9 pl-3 text-base text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900 sm:text-sm forced-colors:appearance-auto">
              <option value="oak" selected>Natural oak</option>
              <option value="ash">Black ash</option>
              <option value="white">White</option>
              <option value="none">No frame</option>
            </select>
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-neutral-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
          </div>
        </div>
        <div>
          <label for="tsl-pv-qty" class="block text-sm font-medium text-neutral-800">Quantity</label>
          <div class="mt-1.5 grid grid-cols-1">
            <select id="tsl-pv-qty" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-neutral-500 bg-white py-2.5 pr-8 pl-3 text-base text-neutral-900 tabular-nums focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900 sm:text-sm forced-colors:appearance-auto">
              <option>1</option>
              <option>2</option>
              <option>3</option>
              <option>4</option>
            </select>
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-neutral-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
          </div>
        </div>
      </div>
    </div>
    <p id="tsl-pv-stock" class="mt-4 flex items-center gap-2 text-sm text-amber-800"><span class="size-2 rounded-full bg-amber-500" aria-hidden="true"></span><span id="tsl-pv-stock-text">Only 4 left in this size</span></p>
    <button type="submit" class="mt-5 min-h-12 w-full rounded-lg bg-neutral-900 px-4 text-sm font-semibold text-white hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900">Add to cart</button>
    <p id="tsl-pv-status" role="status" class="mt-3 text-sm font-medium text-emerald-800 empty:hidden"></p>
  </div>
</form>

Job board toolbar with filter and sort selects

  • grid-cols-2 md:flex
  • col-span-2
  • hidden
  • aria-live
  • ring-*

Filter by department, then sort by salary.

The toolbar is a two-column grid on phones and a single flex row from md: up, with the sort select spanning both columns on small screens. Compact selects use the same chevron overlay at a smaller size. The script filters with the hidden property, reorders the cards with append and reports the result in a polite status line.

View code
<div class="w-full max-w-3xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-blue-950/10 sm:p-6">
  <div class="flex flex-wrap items-baseline justify-between gap-2">
    <h2 class="text-lg font-semibold text-blue-950">Open roles at Fernhill</h2>
    <p id="tsl-st-count" role="status" class="text-sm text-slate-600">Showing 7 of 7 roles</p>
  </div>
  <div class="mt-4 grid grid-cols-2 gap-3 rounded-xl bg-slate-50 p-3 md:flex md:items-end">
    <div class="md:w-48">
      <label for="tsl-st-dept" class="block text-xs font-medium text-slate-700">Department</label>
      <div class="mt-1 grid grid-cols-1">
        <select id="tsl-st-dept" class="col-start-1 row-start-1 w-full appearance-none rounded-md border border-slate-500 bg-white py-2 pr-8 pl-2.5 text-sm text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-700 forced-colors:appearance-auto">
          <option value="all">All departments</option>
          <option value="design">Design</option>
          <option value="engineering">Engineering</option>
          <option value="marketing">Marketing</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-slate-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div class="md:w-40">
      <label for="tsl-st-place" class="block text-xs font-medium text-slate-700">Location</label>
      <div class="mt-1 grid grid-cols-1">
        <select id="tsl-st-place" class="col-start-1 row-start-1 w-full appearance-none rounded-md border border-slate-500 bg-white py-2 pr-8 pl-2.5 text-sm text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-700 forced-colors:appearance-auto">
          <option value="all">Anywhere</option>
          <option value="remote">Remote</option>
          <option value="berlin">Berlin</option>
          <option value="lisbon">Lisbon</option>
          <option value="new-york">New York</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-slate-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div class="col-span-2 md:ml-auto md:w-48">
      <label for="tsl-st-sort" class="block text-xs font-medium text-slate-700">Sort by</label>
      <div class="mt-1 grid grid-cols-1">
        <select id="tsl-st-sort" class="col-start-1 row-start-1 w-full appearance-none rounded-md border border-slate-500 bg-white py-2 pr-8 pl-8 text-sm text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-700 forced-colors:appearance-auto">
          <option value="new">Newest first</option>
          <option value="salary">Highest salary</option>
          <option value="title">Title, A to Z</option>
        </select>
        <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 ml-2.5 size-4 self-center justify-self-start text-slate-600"><path d="M6 3.5v13m0 0-3-3m3 3 3-3M14 16.5v-13m0 0-3 3m3-3 3 3"/></svg>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2.5 size-4 self-center justify-self-end text-slate-600 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
  </div>
  <ul id="tsl-st-list" class="mt-4 divide-y divide-slate-200">
    <li class="tsl-st-job flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-3" data-dept="design" data-place="remote" data-salary="128" data-age="2"><div><a href="#" class="font-medium text-blue-900 hover:underline focus-visible:outline-2 focus-visible:outline-blue-700">Senior Product Designer</a><p class="text-sm text-slate-600">Design, Remote</p></div><p class="text-sm text-slate-700 tabular-nums">$128k, 2 days ago</p></li>
    <li class="tsl-st-job flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-3" data-dept="engineering" data-place="remote" data-salary="142" data-age="5"><div><a href="#" class="font-medium text-blue-900 hover:underline focus-visible:outline-2 focus-visible:outline-blue-700">Design Systems Engineer</a><p class="text-sm text-slate-600">Engineering, Remote</p></div><p class="text-sm text-slate-700 tabular-nums">$142k, 5 days ago</p></li>
    <li class="tsl-st-job flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-3" data-dept="design" data-place="lisbon" data-salary="74" data-age="9"><div><a href="#" class="font-medium text-blue-900 hover:underline focus-visible:outline-2 focus-visible:outline-blue-700">Brand Designer</a><p class="text-sm text-slate-600">Design, Lisbon</p></div><p class="text-sm text-slate-700 tabular-nums">$74k, 9 days ago</p></li>
    <li class="tsl-st-job flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-3" data-dept="engineering" data-place="berlin" data-salary="156" data-age="1"><div><a href="#" class="font-medium text-blue-900 hover:underline focus-visible:outline-2 focus-visible:outline-blue-700">Staff Frontend Engineer</a><p class="text-sm text-slate-600">Engineering, Berlin</p></div><p class="text-sm text-slate-700 tabular-nums">$156k, 1 day ago</p></li>
    <li class="tsl-st-job flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-3" data-dept="marketing" data-place="remote" data-salary="82" data-age="14"><div><a href="#" class="font-medium text-blue-900 hover:underline focus-visible:outline-2 focus-visible:outline-blue-700">Content Strategist</a><p class="text-sm text-slate-600">Marketing, Remote</p></div><p class="text-sm text-slate-700 tabular-nums">$82k, 14 days ago</p></li>
    <li class="tsl-st-job flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-3" data-dept="marketing" data-place="new-york" data-salary="118" data-age="3"><div><a href="#" class="font-medium text-blue-900 hover:underline focus-visible:outline-2 focus-visible:outline-blue-700">Growth Marketing Lead</a><p class="text-sm text-slate-600">Marketing, New York</p></div><p class="text-sm text-slate-700 tabular-nums">$118k, 3 days ago</p></li>
    <li class="tsl-st-job flex flex-wrap items-center justify-between gap-x-4 gap-y-1 py-3" data-dept="design" data-place="berlin" data-salary="96" data-age="7"><div><a href="#" class="font-medium text-blue-900 hover:underline focus-visible:outline-2 focus-visible:outline-blue-700">UX Researcher</a><p class="text-sm text-slate-600">Design, Berlin</p></div><p class="text-sm text-slate-700 tabular-nums">$96k, 7 days ago</p></li>
  </ul>
  <p id="tsl-st-empty" hidden class="py-8 text-center text-sm text-slate-600">No roles match these filters yet.</p>
</div>

Filter written as a sentence

  • field-sizing-content
  • inline-grid
  • border-dashed
  • sr-only
  • appearance-none

Change any underlined word.

Each select sits in an inline-grid wrapper so the chevron overlay works mid-sentence, and field-sizing-content shrinks it to the chosen option in Chrome 123+, Safari 26.2+ and Firefox 152+, while older browsers fall back to the width of the longest option. A dashed bottom border marks the editable words, and sr-only labels give each select a name. The script filters the activity feed below.

View code
<div class="w-full max-w-xl rounded-2xl bg-orange-50 p-6 ring-1 ring-orange-900/10 sm:p-8">
  <p class="text-lg leading-loose text-stone-800 sm:text-xl">
    Show
    <label for="tsl-is-type" class="sr-only">Activity type</label>
    <span class="inline-grid grid-cols-1 align-baseline">
      <select id="tsl-is-type" class="col-start-1 row-start-1 appearance-none field-sizing-content rounded-sm border-0 border-b-2 border-dashed border-orange-700 bg-transparent py-0 pr-5 pl-0.5 font-semibold text-orange-800 hover:bg-orange-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700 forced-colors:appearance-auto">
        <option value="all">all activity</option>
        <option value="comment">comments</option>
        <option value="upload">uploads</option>
        <option value="status">status changes</option>
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-end text-orange-700 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
    </span>
    from
    <label for="tsl-is-who" class="sr-only">Person</label>
    <span class="inline-grid grid-cols-1 align-baseline">
      <select id="tsl-is-who" class="col-start-1 row-start-1 appearance-none field-sizing-content rounded-sm border-0 border-b-2 border-dashed border-orange-700 bg-transparent py-0 pr-5 pl-0.5 font-semibold text-orange-800 hover:bg-orange-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700 forced-colors:appearance-auto">
        <option value="all">anyone</option>
        <option value="maya">Maya</option>
        <option value="jonas">Jonas</option>
        <option value="aiko">Aiko</option>
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-end text-orange-700 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
    </span>
    in the
    <label for="tsl-is-when" class="sr-only">Time range</label>
    <span class="inline-grid grid-cols-1 align-baseline">
      <select id="tsl-is-when" class="col-start-1 row-start-1 appearance-none field-sizing-content rounded-sm border-0 border-b-2 border-dashed border-orange-700 bg-transparent py-0 pr-5 pl-0.5 font-semibold text-orange-800 hover:bg-orange-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700 forced-colors:appearance-auto">
        <option value="1">last 24 hours</option>
        <option value="7" selected>last 7 days</option>
        <option value="30">last 30 days</option>
      </select>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-end text-orange-700 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
    </span>.
  </p>
  <ul id="tsl-is-feed" class="mt-5 space-y-2">
    <li class="tsl-is-item rounded-lg bg-white px-4 py-3 text-sm text-stone-700 ring-1 ring-orange-900/10" data-type="comment" data-who="maya" data-age="0"><span class="font-semibold text-stone-900">Maya</span> commented on Homepage hero, v3 <span class="text-stone-600">4 hours ago</span></li>
    <li class="tsl-is-item rounded-lg bg-white px-4 py-3 text-sm text-stone-700 ring-1 ring-orange-900/10" data-type="upload" data-who="jonas" data-age="2"><span class="font-semibold text-stone-900">Jonas</span> uploaded icons-export.zip <span class="text-stone-600">2 days ago</span></li>
    <li class="tsl-is-item rounded-lg bg-white px-4 py-3 text-sm text-stone-700 ring-1 ring-orange-900/10" data-type="status" data-who="aiko" data-age="3"><span class="font-semibold text-stone-900">Aiko</span> moved Checkout QA to Done <span class="text-stone-600">3 days ago</span></li>
    <li class="tsl-is-item rounded-lg bg-white px-4 py-3 text-sm text-stone-700 ring-1 ring-orange-900/10" data-type="comment" data-who="jonas" data-age="6"><span class="font-semibold text-stone-900">Jonas</span> commented on Pricing table spacing <span class="text-stone-600">6 days ago</span></li>
    <li class="tsl-is-item rounded-lg bg-white px-4 py-3 text-sm text-stone-700 ring-1 ring-orange-900/10" data-type="upload" data-who="maya" data-age="12"><span class="font-semibold text-stone-900">Maya</span> uploaded brand-photos-spring.pdf <span class="text-stone-600">12 days ago</span></li>
    <li class="tsl-is-item rounded-lg bg-white px-4 py-3 text-sm text-stone-700 ring-1 ring-orange-900/10" data-type="status" data-who="maya" data-age="20"><span class="font-semibold text-stone-900">Maya</span> moved Onboarding emails to In review <span class="text-stone-600">20 days ago</span></li>
  </ul>
  <p id="tsl-is-count" role="status" class="mt-3 text-sm text-stone-600"></p>
</div>

Regional settings with a dark mode switch

  • dark:
  • scheme-light dark:scheme-dark
  • group-aria-pressed:
  • md:grid-cols-2
  • <optgroup>

Press the Dark mode switch.

The switch adds the dark class to the card itself, so every dark: utility inside flips at once, and scheme-light dark:scheme-dark tells the browser to draw the native option lists in the matching scheme. The knob moves with group-aria-pressed:translate-x-5 because the button carries aria-pressed. The script also updates a live date preview when the format or first weekday changes.

View code
<div id="tsl-dt-card" class="w-full max-w-2xl rounded-2xl bg-white p-6 text-slate-900 shadow-sm ring-1 ring-slate-900/10 scheme-light transition-colors sm:p-8 dark:bg-slate-900 dark:text-slate-100 dark:ring-white/10 dark:scheme-dark">
  <div class="flex flex-wrap items-center justify-between gap-4">
    <div>
      <h2 class="text-lg font-semibold">Language and region</h2>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Applies to dates, invoices and emails.</p>
    </div>
    <button id="tsl-dt-toggle" type="button" aria-pressed="false" class="group flex min-h-11 items-center gap-3 rounded-full py-1.5 pr-1.5 pl-3 text-sm font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 dark:text-slate-200 dark:hover:bg-white/10 dark:focus-visible:outline-sky-400">
      Dark mode
      <span class="flex h-7 w-12 items-center rounded-full bg-slate-400 p-1 transition-colors group-aria-pressed:bg-sky-500" aria-hidden="true"><span class="size-5 rounded-full bg-white shadow transition-transform motion-reduce:transition-none group-aria-pressed:translate-x-5"></span></span>
    </button>
  </div>
  <div class="mt-6 grid gap-5 md:grid-cols-2">
    <div>
      <label for="tsl-dt-lang" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Language</label>
      <div class="mt-2 grid grid-cols-1">
        <select id="tsl-dt-lang" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-9 pl-3 text-base text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 sm:text-sm dark:border-slate-500 dark:bg-slate-800 dark:text-white dark:focus-visible:outline-sky-400 forced-colors:appearance-auto">
          <option>English (US)</option>
          <option>English (UK)</option>
          <option lang="de">Deutsch</option>
          <option lang="es">Espa&ntilde;ol</option>
          <option lang="fr">Fran&ccedil;ais</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-slate-600 dark:text-slate-400 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tsl-dt-zone" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Time zone</label>
      <div class="mt-2 grid grid-cols-1">
        <select id="tsl-dt-zone" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-9 pl-3 text-base text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 sm:text-sm dark:border-slate-500 dark:bg-slate-800 dark:text-white dark:focus-visible:outline-sky-400 forced-colors:appearance-auto">
          <optgroup label="Americas">
            <option>America/New_York</option>
            <option>America/Chicago</option>
            <option>America/Los_Angeles</option>
          </optgroup>
          <optgroup label="Europe">
            <option selected>Europe/Lisbon</option>
            <option>Europe/Berlin</option>
          </optgroup>
          <optgroup label="Asia Pacific">
            <option>Asia/Tokyo</option>
            <option>Australia/Sydney</option>
          </optgroup>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-slate-600 dark:text-slate-400 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tsl-dt-format" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Date format</label>
      <div class="mt-2 grid grid-cols-1">
        <select id="tsl-dt-format" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-9 pl-3 text-base text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 sm:text-sm dark:border-slate-500 dark:bg-slate-800 dark:text-white dark:focus-visible:outline-sky-400 forced-colors:appearance-auto">
          <option value="us">10/29/2026</option>
          <option value="eu" selected>29/10/2026</option>
          <option value="iso">2026-10-29</option>
          <option value="long">29 October 2026</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-slate-600 dark:text-slate-400 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
    <div>
      <label for="tsl-dt-week" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Week starts on</label>
      <div class="mt-2 grid grid-cols-1">
        <select id="tsl-dt-week" class="col-start-1 row-start-1 w-full appearance-none rounded-lg border border-slate-500 bg-white py-2.5 pr-9 pl-3 text-base text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 sm:text-sm dark:border-slate-500 dark:bg-slate-800 dark:text-white dark:focus-visible:outline-sky-400 forced-colors:appearance-auto">
          <option selected>Monday</option>
          <option>Sunday</option>
          <option>Saturday</option>
        </select>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-3 size-4 self-center justify-self-end text-slate-600 dark:text-slate-400 forced-colors:hidden"><path d="m4 6 4 4 4-4"/></svg>
      </div>
    </div>
  </div>
  <p class="mt-6 rounded-lg bg-slate-100 px-4 py-3 text-sm text-slate-700 dark:bg-white/5 dark:text-slate-300">Preview: <span id="tsl-dt-preview" class="font-medium text-slate-900 dark:text-white">Thursday 29/10/2026, weeks start on Monday</span></p>
</div>

What Is a Tailwind Select?

Spacing, borders, focus states and dark mode all live in the markup when you style a native select with Tailwind, so there’s no separate stylesheet to chase. The utility-first approach Tailwind takes supplies the looks. The browser keeps the behavior, meaning keyboard navigation and form submission, plus the platform popup.

So a page that sells a “Tailwind select component” is showing you a kit built on top of that element, or a replacement for it.

Don’t mix any of this up with the select-none, select-text, select-all and select-auto utilities. Those set user-select and decide whether people can highlight text in any element (Tailwind CSS documentation). They never touch the form control.

Basic select markup

Start with a <label for> whose value matches the select’s id. Then add the name attribute, because that becomes the key sent on submission.

Each choice gets its own <option value>, and the value is the data that actually gets submitted (MDN Web Docs). The first option should carry an empty value as a placeholder. The WHATWG HTML Standard requires that when the select is required, has no multiple and shows one row.

<label for="plan" class="block text-sm font-medium">Plan</label>
<select id="plan" name="plan" required class="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2">
  <option value="">Choose a plan</option>
  <option value="free">Free</option>
  <option value="pro">Pro</option>
</select>

Drop the placeholder option and a required select starts on its first real choice, so the form validates with a value nobody picked.

Grouped and multiple selects

Without multiple, a select shows one row. Add it and the default jumps to 4 rows (WHATWG HTML Standard).

Wrap related options in <optgroup label="..."> and browsers render a bold group title above indented choices. MDN lists group as the implicit ARIA role of optgroup, but how screen readers announce the group label varies by browser and select type. Keep each option label understandable on its own.

The implicit ARIA role of the select itself changes with the attributes. A plain select exposes a combobox role, while one with multiple or a size above 1 exposes a listbox (MDN Web Docs).

A multiple select renders as a list box, so it takes its own padding and border classes. The forms plugin gives it a separate class, covered next.

Setting Up the Forms Plugin for a Native Select

You can skip the forms plugin for a single select. It resets form controls to a consistent baseline that utilities then override, and without it you write that reset yourself with appearance-none, borders and padding.

Install it with npm install -D @tailwindcss/forms. In Tailwind CSS v4, register it in the main stylesheet:

@import "tailwindcss";
  @plugin "@tailwindcss/forms";

Tailwind CSS v3 projects add require('@tailwindcss/forms') to the plugins array in tailwind.config.js instead (plugin README).

On a select, padding becomes customizable, so rounded-full px-4 py-3 works as written. A select[multiple] gets the same treatment.

The plugin also generates form-select and form-multiselect classes. They apply the same reset to any element, such as a <div> that has to look like a select.

The maintainers call the plugin a form reset, not a set of finished components. They also admit the default approach can be heavy-handed in existing projects, which is why the strategy option exists.

@plugin "@tailwindcss/forms" {
  strategy: "class";
}
SetupWhat gets styledClasses neededMain risk
Base strategyEvery select on the pageNoneClashes with existing global styles
Class strategyOnly elements you markform-select, form-multiselectUnmarked selects stay unstyled
No pluginNothing automaticallyappearance-none plus your own bordersYou maintain the reset yourself

Add the plugin when a project has several form controls, since inputs, checkboxes and selects then share one baseline. For a single select on a marketing page, a few utilities do the job without it.

Those same resets carry over to the rest of the form, which the guide to building forms with Tailwind walks through.

How to Replace the Default Select Arrow With a Custom Chevron

Set appearance-none on the select, then lay an SVG chevron over its right edge with a grid wrapper and pointer-events-none. Tailwind’s own appearance documentation uses this overlay pattern.

  1. Wrap the select and the icon in a grid grid-cols-1 container.
  2. Put col-start-1 row-start-1 appearance-none on the select, plus w-full and right padding such as pr-8.
  3. Place the SVG in the same grid cell with col-start-1 row-start-1 self-center justify-self-end.
  4. Add pointer-events-none to the SVG so clicks reach the select underneath.
  5. Give the SVG currentColor strokes, so text color utilities recolor it in dark mode too.
<div class="grid grid-cols-1">
  <select id="plan" name="plan" class="col-start-1 row-start-1 w-full appearance-none rounded-md border border-gray-300 bg-white py-2 pr-8 pl-3 text-gray-900 dark:bg-gray-800 dark:text-white">
    <option>Free</option>
    <option>Pro</option>
  </select>
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-4 self-center justify-self-end text-gray-500">
    <path d="M5 7l5 5 5-5" />
  </svg>
</div>

The select stays the only interactive element, so focus, clicks and keyboard input behave natively.

Overlay icon or background image

The overlay recolors with text utilities and follows dark mode without a second asset. Heroicons offers ready-made chevrons you can paste in as inline SVG.

A background image keeps the markup to one element, using an arbitrary bg-[url(...)] value with bg-no-repeat and a right-hand position. The catch is that the arrow color is fixed inside the image, so a dark theme needs a second file.

Add bg-none to the select when only one arrow should show, so a background arrow from a plugin or kit cannot double up with the overlay.

The same idea works outside utilities, and the guide to styling a select in plain CSS shows the stylesheet version.

When forced colors mode hides your chevron

Tailwind’s appearance documentation says appearance-auto restores native rendering in certain accessibility modes. Its example pairs forced-colors:appearance-auto on the control with forced-colors:hidden on the custom SVG.

The docs demonstrate this on a checkbox, and the same two variants work on a select: the native arrow returns and the overlay disappears.

Test it by emulating forced-colors: active in your browser’s developer tools (Tailwind CSS documentation, October 2026).

Styling Focus, Disabled and Invalid States

Put a focus: or disabled: prefix on a utility, or invalid: for validation styling, and Tailwind generates CSS for the matching pseudo-class on the select. Variants stack, so focus:invalid:border-pink-500 targets a focused select that fails validation (Tailwind CSS documentation).

For focus, keep a visible indicator, for example focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600. Disabled selects usually want disabled:cursor-not-allowed disabled:opacity-60, and invalid:border-red-500 covers the error border.

Removing the outline without a replacement leaves keyboard users with no position marker. That is the most common focus mistake on a restyled select.

A disabled select is also left out of the form submission, so use it for display only and keep the value in a hidden input if the server needs it.

In Tailwind CSS v4, hover: applies only on devices that support hover. Touch screens skip it, so the select’s essential states belong on focus and disabled, not hover.

Invalid without the false alarm

The :invalid pseudo-class matches from page load for a required select still sitting on its empty placeholder option. Error styling can therefore appear before anyone touches the field.

Tailwind CSS 4.1, released April 1, 2025, added user-valid and user-invalid variants for this case (Tailwind CSS changelog). They follow the browser’s user-interaction rules, so an untouched select stays neutral. On v3, stay with invalid: and scope it to a submitted form.

Errors set by the server or by JavaScript need a different hook. Set the ARIA attribute aria-invalid="true" on the select and style it with an arbitrary variant such as aria-[invalid=true]:border-red-500. Tailwind’s built-in aria-* variants cover only attributes such as aria-checked, aria-disabled and aria-required, so aria-invalid needs the bracket syntax or a custom variant.

For helper text, mark the select with peer and put invisible peer-invalid:visible on an error message that follows it in the markup.

Dark Mode, Size and Width Classes

The dark: variant follows prefers-color-scheme out of the box, so dark:border-gray-600 on a select needs no setup. A manual toggle takes one line in the stylesheet (Tailwind CSS v4.3 documentation, October 2026).

@import "tailwindcss";
  @custom-variant dark (&:where(.dark, .dark *));

After that override, every dark: utility applies whenever a dark class sits higher in the tree, usually on <html>. A data-theme attribute works the same way with an attribute selector.

Color scheme for the parts your classes cannot reach

Tailwind’s scheme-* utilities set the CSS color-scheme property. Put scheme-light dark:scheme-dark on <html> and the browser draws its own form controls in matching defaults (Tailwind CSS documentation).

It does not make the open option list stylable. It only tells the browser which scheme to draw it in.

Size classes

SizePaddingTextChevron room
Smallpy-1 pl-2text-xspr-7
Mediumpy-2 pl-3text-smpr-8
Largepy-3 pl-4text-basepr-10

Scale the right padding with the icon, or long option text runs under the chevron. On the small size, drop the SVG to size-3 as well.

Width

Inside a form column, w-full is usually all you need. Use w-full max-w-xs for a capped field, or w-full sm:w-64 if the width should change at a breakpoint.

Without a width class, a native select sizes itself to its longest option (MDN Web Docs). One long label can stretch a whole form row.

When the chevron overlay is in use, set the width on the grid wrapper so the icon and the control stay aligned.

Accessibility Requirements for a Styled Select

A native select carries most of its accessibility with it. A Tailwind select stays accessible as long as the styling does not remove its name, its focus indicator or its error message, so those are worth checking first.

Give it a visible label, or aria-label when the design has none. Make sure the focus indicator contrasts with its surroundings. Errors need a message linked to the control, since a red border alone says nothing to a screen reader. And don’t script anything that changes native key behavior.

WCAG 2.2 success criterion 1.4.11 asks for 3:1 contrast on the visual information needed to identify a form field, often its border, and on author-styled focus indicators (W3C Understanding documentation). Pale borders such as border-gray-300 on a white page are a common miss.

Step the border darker, for example border-gray-500, and confirm the result with a color contrast checker before shipping.

Point aria-describedby at the error message id so screen readers announce it together with the select. Inside a Field, Headless UI’s Description component creates that link automatically (Headless UI documentation).

The wider rules for labels, grouping and error handling sit in this guide to accessible forms.

Keyboard behavior is where replacements drift from the native control. In the WAI-ARIA Authoring Practices select-only combobox pattern, Tab selects the highlighted option, closes the list and moves focus onward, while a native select closes the list and keeps focus in place.

Small differences like that are one more reason to keep the native element when the design allows it.

Why Can’t You Style the Dropdown Options?

Because the browser or operating system draws the open option list, Tailwind classes reach the closed control and nothing beyond it. Setting appearance-none removes the arrow and native chrome and leaves the popup untouched (MDN Web Docs).

What the browser keeps control of

Position, background and spacing of the option list all belong to the browser. On phones the select opens operating system components, which Chrome’s documentation lists as something base-select gives up. And images or SVG placed inside an option are parsed away in a regular select.

Utilities on <option> elements work only where a given popup honors them, so a design cannot depend on them.

What appearance: base-select changes

One CSS property opts the select and its popup into a styleable state.

Chrome 135 shipped appearance: base-select, announced March 24, 2025, and Safari 27.0 added it, according to WebKit’s release notes. Firefox 149 added the value behind the dom.select.customizable_select.enabled and layout.css.appearance-base.enabled preferences, which stay off by default. MDN Web Docs still marks the feature as limited availability.

Support is uneven, so cross-browser compatibility decides whether it fits a project. Older guides still describe Safari as unsupported, which predates the Safari 27.0 release.

@supports (appearance: base-select) {
  select,
  ::picker(select) {
    appearance: base-select;
  }
}

Tailwind’s appearance utilities cover only none and auto, so the opt-in belongs in the stylesheet, and the rest of the select stays on utilities.

New pseudo-elements address the parts: ::picker(select) for the popup, ::picker-icon for the arrow and ::checkmark for the selected mark (MDN Web Docs).

What you get is the real select with its keyboard handling and validation intact, options that can hold HTML such as images and SVG, a popup styled through ::picker(select), and no JavaScript at all.

What you pay is less obvious. Mobile operating system pickers no longer open, and the select stops taking the width of its longest option. Parser changes carried a risk of breaking existing markup, and Chrome kept the feature behind a Finch experiment at launch. If your options are rich, check the selected-value logic too.

Fallback for unsupported browsers

Wrap the opt-in in @supports (appearance: base-select) so unsupported browsers keep the ordinary select. WebKit gives the same advice: style for browsers that support it and let the rest fall back to built-in rendering.

Options whose meaning lives in an image or SVG lose it in the fallback, so keep a text label in every option.

Which Tailwind Select Approach Should You Choose?

A native select with utilities fits most forms. Step up to appearance: base-select when a styled popup matters and a native fallback is acceptable, and use a JavaScript component only for search or a fully custom option layout.

ApproachPopup controlJavaScriptBest fit
Native select with utilitiesBrowser-drawnNoneMost forms, mobile
appearance: base-selectFully styleable in supporting browsersNoneBranded popup with fallback
Headless UI SelectBrowser-drawnReact componentData-attribute states on a native select
Headless UI Listbox or ComboboxFully customYesCustom options, search
Radix UI or shadcn/ui SelectFully customYes, ReactDesign systems on React

If the options are plain text and nobody needs search, keep the native select. If you want a branded popup and a native fallback is fine, go with base-select. Search, avatars or a custom layout that has to look identical in every browser point to Listbox, Combobox or a Radix-based Select.

Headless UI Select, Listbox and Combobox

Only two of Headless UI’s select components replace the browser popup. The Select itself is a light wrapper around the native element that exposes hover, focus, invalid and disabled states as data attributes, so a class like data-focus:bg-blue-100 works (Headless UI documentation). The popup stays browser-drawn, and the component is documented for React.

Listbox is a custom select with full control over the option list. Combobox is the same idea with a search input added.

Both custom components bring Floating UI positioning since v2.0 for React, so popups stay inside the viewport. The trade-off is a custom popup in place of the native mobile picker.

Radix UI Select and shadcn/ui

shadcn/ui’s documentation recommends its Native Select for native browser behavior, better performance and mobile-optimized dropdowns, and its Select for custom styling, animations and complex interactions.

In the Radix variant of shadcn/ui, the Select is built on Radix UI’s Select primitive and copied into your project, so you own the Tailwind classes. The docs also offer Base UI and React Aria variants. Either one assumes a project already using Tailwind CSS in React.

For a short list of plain options on a phone, that official guidance points back to the native element.

Prebuilt kits and Tom Select

  • Tailwind Plus has select menus built by the Tailwind team. It is a paid product separate from the framework, and its @tailwindplus/elements library adds an el-select element for plain HTML.
  • Flowbite styles a native select with Tailwind classes and shows multiple, size, disabled and underline examples.
  • daisyUI uses a select class. Version 5 removed the select-bordered modifier because the border is now the default (daisyUI upgrade guide).
  • Preline relies on the forms plugin by default and sends searchable and multiple-selection needs to its advanced select.

A Tailwind UI kit saves time when the whole interface is built from one source, and costs consistency when only a single select is needed.

Tom Select adds search to an existing select and weighs about 16 KB gzipped, per its README. The original select stays in the DOM and keeps its value, so plain form posts still work, but the library ships its own stylesheet themes that need matching to your Tailwind design tokens.

Tailwind Select FAQ

Why does a multiple select show no arrow to replace?

A select with the multiple attribute displays as a list box, so the browser draws no dropdown arrow.

Skip the chevron overlay for that field and set the number of visible rows with the size attribute instead.

How do you gray out the placeholder text in a select?

A select has no placeholder pseudo-element, so the empty first option takes the same text color as every other option.

On a required select, that option makes the control invalid, so invalid:text-gray-400 grays it until a real choice is made. Use user-invalid for error styling on the same field, and check that the open list stays readable.

Can you put an icon on the left side inside a Tailwind select?

Yes. Reuse the grid overlay from the chevron, place the SVG with justify-self-start and pointer-events-none, and add left padding such as pl-10 to the select so the text clears the icon.

How do you use a Tailwind select in React or Vue?

Keep the same classes on the native element and bind its value, with value and onChange in React or v-model in Vue.

Headless UI’s Select wraps the native element for React and adds data-attribute states, but the styling approach does not change.

What to Test Before Shipping a Tailwind Select

Before release, do a keyboard-only pass, emulate forced colors, load the page in a browser without appearance: base-select support and open it on a real phone.

Run them in that order. The first three take minutes in a desktop browser and catch the failures that styling introduces, while the phone check needs a device and only pays off once the others pass.

The phone check carries a catch. Chrome’s documentation says base-select stops the operating system picker from opening, while WebKit says unsupported browsers fall back to built-in rendering, so one page can show two different mobile pickers depending on the browser.

Verified on October 29, 2026: Firefox keeps the feature behind a preference. Once it ships by default, the fallback check shrinks to older browser versions.

Run the finished form through a web accessibility checklist before it goes live.