GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

The order is not "most popular first." It follows what a select box design 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. When the choices are dates the shape changes entirely, and that version is filed separately as Date Range Picker UI Patterns and css calendar design patterns.

01Spring-open base select

What you see in this custom select dropdown css

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.

Spring-open base select
Open in a new tab

01 · Spring-open base select — Measured area 19.057% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

transform-originaria-expandedEscape

Where to look in the code

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

What happens on screen

Arrow keys slide a single tinted bar between the rows, and Home or End throws it to either end in one press.

Keyboard highlight rail
Open in a new tab

02 · Keyboard highlight rail — Measured area 15.938% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYaria-activedescendantArrowDown

How to read the code

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

What moves

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.

Searchable combobox
Open in a new tab

03 · Searchable combobox — Measured area 1.65% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

role=comboboxinputmark

What to change

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

What you see

Scroll the list, and the current group's title stays pinned at the top until the next group arrives and pushes it out.

Sticky grouped headers
Open in a new tab

04 · Sticky grouped headers — Measured area 1.897% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

position: stickyrole=grouparia-labelledby

Where to look in the code

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

What happens on screen

Chosen rows pile up as pill-shaped chips inside the field; the x on a chip drops that one, and Backspace drops the last.

Multi-select chip tokens
Open in a new tab

05 · Multi-select chip tokens — Measured area 0.526% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-multiselectableBackspacescale

How to read the code

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

What moves

Choosing a country swaps the flag and the currency on the button in the same move.

Country and currency select with flags
Open in a new tab

06 · Country and currency select with flags — Measured area 8.468% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

inline SVGaria-selectedopacity

What to change

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

What you see

Instead of a column of names, a four-wide grid of color dots opens, and a single check hops onto whichever dot you choose.

Color swatch grid select
Open in a new tab

07 · Color swatch grid select — Measured area 0.888% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

gridrole=listboxsteps(1, end)

Where to look in the code

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

What happens on screen

Every row pairs an initials circle with a presence dot, and picking one fills the empty avatar slot on the button.

Avatar assignee picker
Open in a new tab

08 · Avatar assignee picker — Measured area 4.726% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-selectedtranslateYbox-shadow

How to read the code

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

What moves

With only four choices, nothing opens: one pill slides between the cells.

Size segment picker
Open in a new tab

09 · Size segment picker — Measured area 3.94% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

flex: 0 0 autotranslateXaria-disabled

What to change

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. An open animation tangling with scroll also breaks the same way in Avatar UI Design once its icon list gets long. 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> for select option design?

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. For options nested in hierarchy, File Tree CSS Views works it out with folders that open in place.

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.

Enter the archive password

The password is inside this article. You will find it as you read.