9 Select Dropdown Design Patterns That Really Open
A hand-built select dropdown design replaces the gray box the browser draws for <select> with a button and a list of your own.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Spring-open base select
- 02 Keyboard highlight rail
- 03 Searchable combobox
- 04 Sticky grouped headers
- 05 Multi-select chip tokens
- 06 Country and currency select with flags
- 07 Color swatch grid select
- 08 Avatar assignee picker
- 09 Size segment picker
The order is not "most popular first." It follows what a select box actually goes through. It opens (01). Someone moves around inside it with the keyboard (02). The list grows long enough that typing beats scrolling (03), then long enough to need splitting into groups (04). Next the rule changes from one answer to several (05). The last four leave the plain row of words behind: a country reads as a flag (06), a label as a dot of color (07), an assignee as a circle of initials (08), and a size with only four choices doesn't deserve an opening animation at all (09). All nine carry role="listbox" and role="option", and all nine answer the arrow keys. Escape is wired in the seven that have a panel to close or a search field to clear, but not in the always-open rail (02) or the segment picker (09), which never opens.
01Spring-open base select
Pressing the button really opens the list, which grows downward from its own top edge, and choosing a row rewrites the label on the button. The bounce comes from pinning transform-origin to the top of the panel and pushing scaleY from 0.72 to 1 on a spring curve.
.ss__list {
transform-origin: top center;
transform: translateY(-6px) scaleY(.72);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ss.is-open .ss__list { transform: translateY(0) scaleY(1); opacity: 1; pointer-events: auto; }
02Keyboard highlight rail
Arrow keys slide a single tinted bar between the rows, and Home or End throws it to either end in one press. Repainting each row on focus would light up four separate areas over four keystrokes; moving one bar changes only its position, so the eye tracks a single object.
function move(i) {
root.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
rail.style.transform = 'translateY(' + (at * ROW) + 'px)';
list.setAttribute('aria-activedescendant', opts[at].id);
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
}
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { move(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { move(at - 1); e.preventDefault(); }
else if (e.key === 'Home') { move(0); e.preventDefault(); }
else if (e.key === 'End') { move(opts.length - 1); e.preventDefault(); }
});
03Searchable combobox
Typing really shortens the list, and a highlighter lands behind the matching characters. When nothing survives the filter, an empty-state line replaces the rows. Escape clears the field and brings every row back.
function render(q) {
root.classList.remove('is-demo');
var shown = 0;
opts.forEach(function (o, i) {
var hit = !q || names[i].indexOf(q) >= 0;
o.hidden = !hit;
if (hit) {
shown++;
var k = names[i].indexOf(q);
o.innerHTML = !q ? names[i]
: names[i].slice(0, k) + '<span class="cb__m is-on">' + q + '</span>' + names[i].slice(k + q.length);
}
});
empty.hidden = shown > 0;
at = -1;
}
04Sticky grouped headers
Scroll the list, and the current group's title stays pinned at the top until the next group arrives and pushes it out. The pinning is one line — position: sticky — and it works because the list box holds a real 68px scroll area rather than a simulated one.
.gs__scroll {
height: 68px;
overflow-y: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
}
.gs__head {
position: sticky;
top: 0;
z-index: 1;
box-sizing: border-box;
height: 20px;
margin: 0; padding: 0 $sp-2;
display: flex; align-items: center;
background: #fff;
font-size: 9px; font-weight: 800;
letter-spacing: .08em;
color: rgba(23, 20, 26, .38);
}
05Multi-select chip tokens
Chosen rows pile up as pill-shaped chips inside the field; the x on a chip drops that one, and Backspace drops the last. The listbox is marked aria-multiselectable="true", so picking a second row adds to the answer instead of replacing it.
field.addEventListener('keydown', function (e) {
if (e.key === 'Backspace') {
var last = chips.querySelector('.mc__chip:last-child');
if (last && last.dataset.for) sync(document.getElementById(last.dataset.for));
e.preventDefault();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
field.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' || e.key === ' ') { sync(opts[at]); e.preventDefault(); }
else if (e.key === 'Escape') { root.classList.remove('is-open'); field.blur(); }
});
06Country and currency select with flags
Choosing a country swaps the flag and the currency on the button in the same move. Each flag is neither an image file nor an emoji, but an inline SVG holding two or three rectangles — or, for Japan, a red circle on a white rectangle — inside viewBox="0 0 16 12", which is why it still looks like a flag on Windows Chrome, where flag emoji render as two letters.
function pick(i) {
at = i;
root.classList.remove('is-demo');
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === i)); });
swap.innerHTML = opts[i].querySelector('.cf__flag').innerHTML;
name.textContent = opts[i].querySelector('.cf__opt-name').textContent;
cur.textContent = opts[i].dataset.cur;
open(false);
btn.focus({ preventScroll: true });
}
07Color swatch grid select
Instead of a column of names, a four-wide grid of color dots opens, and a single check hops onto whichever dot you choose. Moving a single check, instead of fading one in on every swatch, keeps the number of animated elements at one no matter how many colors the grid grows to.
function place(i) {
ring.style.transform = 'translate(' + ((i % 4) * STEP) + 'px,' + (Math.floor(i / 4) * STEP) + 'px)';
}
function pick(i) {
root.classList.remove('is-demo');
at = i;
sws.forEach(function (s, k) { s.setAttribute('aria-selected', String(k === i)); });
place(i);
preview.innerHTML = '<span class="cs__dot" style="background:' + sws[i].style.background + '"></span>';
name.textContent = sws[i].getAttribute('aria-label');
}
08Avatar assignee picker
Every row pairs an initials circle with a presence dot, and picking one fills the empty avatar slot on the button. That empty slot is not a hand-drawn person shape, but the Phosphor Duotone user icon pasted in as inline SVG.
function pick(i) {
root.classList.remove('is-demo');
at = i;
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === i)); });
var pic = opts[i].querySelector('.av__pic');
wrap.innerHTML = '<span class="' + pic.className + '">' + pic.textContent + '</span>';
name.textContent = opts[i].querySelector('.av__who').textContent;
open(false);
btn.focus({ preventScroll: true });
}
09Size segment picker
With only four choices, nothing opens: one pill slides between the cells. The sold-out cell carries aria-disabled="true", so a click leaves the pill where it is, and the same guard blocks an arrow key from moving the pill onto it.
function pick(i) {
if (i < 0 || i >= cells.length) return;
if (cells[i].getAttribute('aria-disabled') === 'true') return;
root.classList.remove('is-demo');
at = i;
pill.style.transform = 'translateX(' + (i * W) + 'px)';
cells.forEach(function (c, k) { c.setAttribute('aria-selected', String(k === i)); });
track.setAttribute('aria-activedescendant', cells[i].id);
}
Where it breaks — the trap
The moment an open list is floated with position: absolute, it drops out of its parent's height. Centering is then calculated from the label and the button alone, and the list hangs below the box the parent thinks it occupies. On a wide screen the surrounding space hides the overflow. Measured at 320px wide, six of the nine demos reported a document taller than the viewport: number 01 had 230px of content in a 200px frame, more than enough to cut the last row off. The fix was not to put the list back in the flow, but to reserve the space it would have taken — a padding-bottom on the root equal to the height of the open panel. The value differs per item: 116px for 01, 80px for 04.
The second trap belongs to 04. position: sticky needs a real scroll to fire. In an autoplaying capture, the list is pushed up with transform, the scroll position stays at zero, and the header rides up with everything else, so the one moment worth filming never happens. The autoplay loop therefore gives the header its own counter-move so it appears to stay put, while a real wheel or keyboard scroll hands the job back to the untouched position: sticky rule. Both fixes ship in the sources inside the zip, which opens with the archive password c7dd2vxx.
Accessibility (reduced-motion)
Under prefers-reduced-motion: reduce, all nine drop the autoplay loop and keep every state value. The rail lands on its row without sliding, the panel appears already open instead of growing, and aria-selected still says which row is the answer. The six triggers that open a panel carry aria-haspopup="listbox", with aria-expanded announcing the state; the search field is a role="combobox" input; and all but the swatch grid move a virtual cursor with aria-activedescendant instead of moving focus between the rows. The roles and properties used here are defined in the MDN listbox role reference. Focus always comes back through focus({ preventScroll: true }), because these demos sit in iframes on a listing page and a bare focus() would drag the reader's page down to them.
More input parts live in the forms category, and parts that answer a press are in the click category.
FAQ
Is there a good reason not to use the native <select>?
If you only dislike how it looks, no. Every screen reader and keyboard already understands the native control, and on phones the operating system gives it a sheet you could not build as well yourself. Build your own when the options are not words — flags, color dots, and initials — or when the answer is several values at once. Then you inherit the whole job the native control was doing, roles and keys included.
Do all nine work without a mouse?
Yes. Tab reaches every trigger, the ones built as buttons open on Enter or Space, the open list moves on the up and down arrows and commits on Enter, and Escape closes it. Numbers 02 and 09 add Home and End, and number 07 reads left and right as the next swatch and up and down as the row above or below.
Can I change the animation values in the React version?
Yes. All nine components take duration, easing, and color as props and pass them down as the --duration, --easing, and --color custom properties, and the stylesheet reads nothing else. Change one value and all nine move to the same rhythm.