Country and currency pickers, fintech card selectors, SaaS plan switchers, account menus, and dashboard filters — plus multi-select tag chips, status dropdowns, date-range presets, color-swatch previews, floating-label fields, and a glassmorphism showpiece. Scoped under .tcs-NN for no-collision pasting, prefers-reduced-motion guarded, framework-agnostic.
Building a whole Tailwind app, not just this component?
You just browsed 20 Tailwind custom selects. Untitled UI Pro unlocks 5,000+ components and sections and 250+ page examples for React, styled with Tailwind CSS v4. One licence is a one-time $349 with lifetime updates; teams start at $999.
The checkout country picker, ported to variants: the panel carries its closed and open geometry as utilities on one element, the selected row styles itself off aria-selected, and the roving highlight styles itself off data-active. The flags are still gradients — the one thing that stays in the stylesheet, because a flag is a recipe, not a design token.
A fintech currency select whose gradient border is a before: utility, not a stylesheet rule: the conic ring, the mask pair and the hover fade all live in the trigger's class list. Options are a three-column grid so $, € and C$ stay aligned, and the panel unfolds with an animated clip-path inset.
A saved-cards picker on a glass panel: backdrop-blur-[14px] is a utility, the spring open curve is a token, and the selected row's accent rail is aria-selected:border-l-tcs03-rail — the indicator and the semantics are the same attribute. The four brand marks stay in CSS, where attribute-keyed artwork belongs.
A plan picker whose highlight glides instead of jumping: one absolutely-positioned bar reads --n from the list and moves with [translate:0_calc(var(--n)*60px)]. The accent itself is a runtime variable the script rewrites per tier, which is exactly the case for a plain custom property rather than an @theme token.
The navbar account menu, and the port's win is that there is no state attribute to invent: the panel opens off the button's own aria-expanded through group-has-aria-expanded:. Actions, not values — so this one is a role="menu" with roving focus, not a listbox, and the sign-out row tints itself from the danger token.
The quiet one: an icon pill in a light header that opens a 210px list of languages with their endonyms. Almost the entire demo is utilities — the only stylesheet rule is the CSS globe, and the only new markup idea is lang on each option so a screen reader pronounces Français in French.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
A B2B workspace switcher: scrollable team list, plan badges, and a pinned Create workspace action that stays reachable at the bottom. Each team's logo colour is one number — style="--h:265" read by an arbitrary hsl() gradient utility, so a new team needs no new CSS at all.
Light / Dark / System, and the port makes an argument: @theme is a build-time mint, so a theme the user switches at runtime belongs in a plain [data-theme] block read through bg-[var(--tcs08-surf)]. One attribute repaints the whole stage — including color-scheme, so native controls follow.
Multi-select with chips in the field, a live count, and a checkbox drawn entirely in utilities — the tick is an after: pseudo-element with after:rotate-45, revealed by group-aria-selected/opt:. The panel stays open while you pick, and Backspace removes the last chip.
A tracker-style status filter where the field becomes the badge you chose. One pill class list serves all five statuses: it reads --s-bg and --s-fg, and a five-line attribute block binds the pair — the port's answer to when is a value a token and when is it data?
The dashboard period picker: four presets that each show their resolved dates, computed from new Date() at load, plus a separated Custom Range row whose chevron nudges right under the keyboard cursor — group-data-active/opt:translate-x-0.5, a micro-interaction that costs one utility.
The storefront sort control, light and pink: a single highlight bar tracks the pointer and the keyboard cursor by translating --n row heights, and the icons carry the sort direction so intent is visible before you read the label.
The role picker: gradient icon tile, bold title, description line. The port's detail worth stealing is the active row's inset ring — data-active:shadow-[inset_0_0_0_1px_...] — which reads as a crisp outline on a dark panel without the 1px jump a border would cause.
Each row renders its own name in its own face, and picking one repaints the trigger and the sample sentence through a single custom property — read by font-[family-name:var(--tcs14-ff,system-ui)], the arbitrary utility most people do not know exists.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
A format picker dressed as a drop zone: border-2 border-dashed on the trigger, extension badges that tint themselves from one per-row variable, and size limits as subtext. Five formats, five --x values, and exactly one badge class list.
Any hex, no new classes: the swatch is bg-[var(--c)] and each row sets --c, so a palette of six or six hundred costs one class list. The trigger's swatch adds a glow of its own colour — two stacked shadows, one variable.
The one demo whose CSS original was an imitation of Tailwind — hand-written shadows emulating ring-2, hex greys standing in for the palette. Here it is simply the thing itself: ring-3 ring-tcs17-acc/40, shadow-xs, shadow-lg, and a stylesheet that holds nothing but tokens.
The choreography demo: the panel resolves from blurred-and-small to sharp on a spring, and the rows cascade behind it on a per-index delay — group-data-open:[transition-delay:calc(var(--i)*45ms+60ms)], the per-item index pattern that replaces five :nth-child rules.
The floating label, and the port deletes its state machine. The label floats when the field is focused, when the panel is open, or when a value exists — group-focus-within:, group-data-open: and group-has-[[aria-selected=true]]:. The has-value class and the two focus listeners are simply gone.
The showpiece: a frosted select floating over an aurora that drifts behind it. --animate-tcs20-drift in @theme mints animate-tcs20-drift, the three-blob backdrop is a single arbitrary bg-[radial-gradient(...),...], and the only CSS left is the keyframes and Safari's mask shim.
FAQ
Frequently asked questions
How do I make these custom select dropdowns keyboard-accessible instead of just clickable?
A custom select is a listbox, so it needs the roles native form controls give you for free. Put role="combobox" and aria-expanded on the trigger, role="listbox" on the panel, and role="option" with aria-selected on each row. The user avatar menu in Demo 05 and the language switcher in Demo 06 open on click, but a real select must also open on Enter, Space and Arrow keys, move a highlight with Up/Down, commit on Enter and close on Escape returning focus to the trigger. The product sort in Demo 12 already tracks the keyboard focus with its highlight bar, so wire the bar's index to a roving tabindex rather than to hover alone. Never leave the options as bare <div> elements a screen reader reads as plain text with no selected state.
How does the sliding highlight bar in the plan switcher follow the active option without a class per row?
Demo 04 and Demo 12 use one absolutely-positioned bar instead of toggling a background on each row. Every option row sets a custom property — the plan switcher reads --n as its index — and the bar is translated by calc(var(--n) * row-height), so moving selection just rewrites one number and the bar glides on a transition. The win is that five rows share one pill class list; you are not adding and removing an active class in JavaScript. The common bug is a jump on first paint: if the bar starts at translate zero before --n is set, it animates in from the top. Set the initial index inline on the panel and guard the transition with a class you add after the first frame, or the bar slides across the whole list every time the dropdown opens.
How does the multi-select tag dropdown keep chips in the field and a live count in sync?
Demo 09 puts the chosen tags as chips inside the trigger itself and shows a count, and the checkbox tick is drawn entirely in utilities rather than a native input. The mechanism is one source of truth — an array of selected values — that renders both the chips in the field and the checked state of each row. Toggling a row pushes or splices that array, then you re-render chips and rewrite the count text. Two bugs bite here: removing a chip must also uncheck its row, so the chip's remove control has to mutate the same array the list reads, not a copy; and an empty selection should restore the placeholder, not leave a zero-width gap where chips were. For accessibility, mark the panel aria-multiselectable="true" and each row role="option" with aria-selected, because a checkmark alone is invisible to a screen reader.
Why does the dark mode theme selector use @theme, and can I swap themes at runtime with it?
Demo 08 offers Light, Dark and System, and the port makes a deliberate point: @theme in Tailwind is a build-time mint — it generates your utility classes and design tokens when the CSS compiles, so you cannot rewrite it in the browser to flip a theme live. The runtime switch has to run on CSS custom properties instead. Define your palette as variables on a root element, let the utilities reference those variables, and change the theme by setting a data-theme attribute or toggling a class that redefines the variables. The System option reads prefers-color-scheme through a media query. The trap is expecting @theme to behave like a live token store; if you try to mutate it after load nothing happens, and the selector looks broken while the compiled classes never change.
My dropdown won't close when I click outside it — what am I missing?
None of these panels close on their own; the open panels in Demo 01, Demo 05 and Demo 07 stay open until something toggles them shut. You need a document-level listener that closes the dropdown when a click lands outside both the trigger and the panel. The usual bug is that the same click which opens the menu immediately bubbles up to that document handler and closes it again, so the panel never appears. Fix it by checking whether the event target is contained inside your root element before closing, or stop propagation on the trigger. Also close on Escape and on selecting an option. For the scrollable workspace list in Demo 07, do not close on a scroll or on clicks inside the pinned Create workspace action — scope the outside check to the panel's bounding element, and remember to tear the listener down when the component unmounts or you leak handlers.
When should I not build a custom select and just use the native element instead?
Reach for a native <select> when the options are plain text and the value is all that matters — a native control gives you keyboard support, mobile's built-in wheel picker, form submission and screen-reader semantics with zero code, and these ports cannot fully match the native mobile experience. Build a custom one only when the option needs rich content a native <option> cannot render: the flags in Demo 01, the currency symbols in Demo 02, the avatar rows in Demo 05, the per-row typefaces in Demo 14 or the hex swatches in Demo 16. Even then, keep a real hidden native input carrying the value so the field submits inside a <form> and validation still works. If your dropdown is really navigation — the account menu in Demo 05 — it is a menu, not a select, and should use links and role="menu", not option semantics.
How does the color swatch picker render any hex without writing a new class per color?
Demo 16 avoids a class explosion by driving the swatch from a variable. Each row sets --c to its hex value, and the swatch element is a single bg-[var(--c)] — so one class list paints an unlimited palette because the color lives in the property, not the utility. Adding a color is adding a row that sets --c; you never touch the stylesheet. The same trick powers the font preview in Demo 14, where each row sets its own face and the trigger repaints from the chosen row. The pitfall is contrast: a light swatch on a light panel needs a ring or border so it does not vanish, and a hex you inject from user input must be validated before it reaches --c or a malformed value silently falls back to transparent. Give each option an aria-label with the color name, since a screen reader cannot announce a background color.
How is the slide-and-fade open animation built, and why does it feel springy rather than linear?
Demo 18 resolves the panel from blurred-and-small to sharp, and the rows cascade in behind it. The panel carries its closed and open geometry as utilities — a small scale, an opacity of zero and a blur in the closed state, transitioning to full scale and sharp when an open class is added, as Demo 01 does with its closed and open geometry. The spring feel comes from the timing function, not extra keyframes: a custom cubic-bezier that overshoots past one before settling reads as a spring. Demo 20's aurora uses a named --animate-tcs20-drift for the same reason. Two things to guard: set transform-origin toward the trigger so the panel grows out of the field instead of its own center, and wrap the motion in prefers-reduced-motion so users who opt out get an instant open with no scale or blur. Stagger rows with a per-row delay, but cap it or the last row lags noticeably.