CSS Select: 16 Styled Dropdowns and the New base-select

The open list on a select element stayed out of CSS reach for a long time. Chrome 135 changed that on 1 April 2025, Safari 27 followed on 14 September 2026, and appearance: none still restyles the closed field everywhere.

Below are 16 CSS select examples you can try and copy, followed by a guide to base-select, fallbacks and accessible styling.

By Bogdan Sandu · Updated October 28, 2026

Classic styled selects

Every demo in this group is a native select with appearance: none and nothing else new, so it looks the same in Chrome, Safari and Firefox. The closed field, the arrow and the states are yours; the open list stays with the browser.

Classic select with a wrapper caret

  • appearance: none
  • wrapper ::after
  • custom property

We use this to suggest a plan. You can change it later.

The baseline pattern. appearance: none removes the native arrow, and a chevron drawn with two borders on the wrapper replaces it, because select elements do not render ::before or ::after. The border and the chevron share one custom property, so the focus color reaches both.

View code
.se-plan {
  --se-plan-line: #6b7080;
  box-sizing: border-box;
  width: min(100%, 320px);
  padding: 24px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 12px 32px rgba(20, 22, 28, .08);
}
.se-plan:focus-within {
  --se-plan-line: #2f4fe0;
}
.se-plan__label {
  display: block;
  margin-bottom: 8px;
  color: #14161c;
  font-size: 15px;
  font-weight: 600;
}
.se-plan__wrap {
  position: relative;
}
.se-plan__wrap::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 18px;
  width: 8px;
  height: 8px;
  border-right: 2px solid var(--se-plan-line);
  border-bottom: 2px solid var(--se-plan-line);
  transform: translateY(-70%) rotate(45deg);
  pointer-events: none;
}
.se-plan__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  height: 48px;
  margin: 0;
  padding: 0 46px 0 14px;
  border: 2px solid var(--se-plan-line);
  border-radius: 10px;
  background: #fff;
  color: #14161c;
  font: inherit;
  font-size: 16px;
  cursor: pointer;
  transition: background-color .2s ease, border-color .2s ease;
}
.se-plan__select:hover {
  background-color: #f4f5f8;
}
.se-plan__select:focus-visible {
  outline: 3px solid #2f4fe0;
  outline-offset: 2px;
}
.se-plan__help {
  margin: 10px 0 0;
  color: #555b68;
  font-size: 14px;
  line-height: 1.45;
}

Dark theme select

  • color-scheme: dark
  • SVG data URI arrow
  • background-position

nimbus-api / production

Current build a41f9c2 runs in 3 regions.

A deploy panel on a dark dashboard. color-scheme: dark on the select tells the browser to draw its open list with dark system colors, which a plain background-color on the field cannot do. The arrow is an SVG data URI on the select itself, so no wrapper is needed, at the cost of hardcoding its color.

View code
.se-dark {
  box-sizing: border-box;
  width: min(100%, 340px);
  padding: 22px;
  border: 1px solid #2a313d;
  border-radius: 14px;
  background: #161b23;
  color: #e6ebf2;
}
.se-dark__head {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 18px;
  color: #9fb0c4;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
}
.se-dark__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #3ddc97;
}
.se-dark__label {
  display: block;
  margin-bottom: 8px;
  color: #e6ebf2;
  font-size: 14px;
  font-weight: 600;
}
.se-dark__select {
  appearance: none;
  color-scheme: dark;
  box-sizing: border-box;
  width: 100%;
  height: 46px;
  margin: 0;
  padding: 0 42px 0 14px;
  border: 1px solid #5b6778;
  border-radius: 8px;
  background: #0e1218 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%239fb0c4' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 14px center / 12px 8px;
  color: #e6ebf2;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
  transition: border-color .2s ease, background-color .2s ease;
}
.se-dark__select:hover {
  border-color: #8494a8;
}
.se-dark__select:focus-visible {
  outline: 2px solid #3ddc97;
  outline-offset: 2px;
  border-color: #3ddc97;
}
.se-dark__meta {
  margin: 14px 0 0;
  color: #9fb0c4;
  font-size: 13px;
}
.se-dark__meta code {
  padding: 1px 6px;
  border-radius: 4px;
  background: #232a35;
  color: #e6ebf2;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
}

Accent color with a two-tone focus ring

  • :has(option:checked)
  • accent-color
  • two-color focus

Appearance

The select picks the accent, and :has(option:checked) on the card swaps one custom property, so the swatch, the checkbox (through accent-color), the button and the focus ring all follow without script. The ring is an accent outline over a white box-shadow, a two-color indicator that stays visible on any background.

View code
.se-acc {
  --se-acc: #2f4fe0;
  box-sizing: border-box;
  width: min(100%, 320px);
  padding: 24px;
  border: 1px solid #e3e5ea;
  border-radius: 16px;
  background: #fff;
  color: #14161c;
}
.se-acc:has(option[value="forest"]:checked) {
  --se-acc: #1f7a4d;
}
.se-acc:has(option[value="plum"]:checked) {
  --se-acc: #8a2f84;
}
.se-acc:has(option[value="rust"]:checked) {
  --se-acc: #b4441c;
}
.se-acc__title {
  margin: 0 0 16px;
  color: #14161c;
  font-family: inherit;
  font-size: 18px;
  font-weight: 700;
  letter-spacing: 0;
}
.se-acc__label {
  display: block;
  margin-bottom: 8px;
  font-size: 14px;
  font-weight: 600;
}
.se-acc__wrap {
  position: relative;
}
.se-acc__wrap::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 14px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--se-acc);
  transform: translateY(-50%);
  pointer-events: none;
}
.se-acc__wrap::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 16px;
  border-top: 6px solid #3b404c;
  border-right: 5px solid transparent;
  border-left: 5px solid transparent;
  transform: translateY(-50%);
  pointer-events: none;
}
.se-acc__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  height: 46px;
  margin: 0;
  padding: 0 40px 0 42px;
  border: 1px solid #7a7f8c;
  border-radius: 10px;
  background: #fff;
  color: #14161c;
  font: inherit;
  font-size: 16px;
  cursor: pointer;
}
.se-acc__select:focus-visible {
  outline: 2px solid var(--se-acc);
  outline-offset: 2px;
  box-shadow: 0 0 0 2px #fff;
}
.se-acc__check {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  margin-top: 12px;
  font-size: 14px;
  cursor: pointer;
}
.se-acc__check input {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: var(--se-acc);
}
.se-acc__check input:focus-visible {
  outline: 2px solid var(--se-acc);
  outline-offset: 2px;
}
.se-acc__save {
  width: 100%;
  min-height: 44px;
  margin-top: 10px;
  border: 0;
  border-radius: 10px;
  background: var(--se-acc);
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .25s ease;
}
.se-acc__save:focus-visible {
  outline: 2px solid var(--se-acc);
  outline-offset: 3px;
}

Compact selects inside a sentence

  • field-sizing: content
  • inline select
  • visually hidden label

Send me a digest about at .

A settings sentence where the choices are part of the copy. Each select inherits the serif font, loses its box and keeps a dashed underline as the affordance. field-sizing: content shrinks the field to the chosen option; where it is unsupported, the select keeps the width of its longest option, which still reads fine.

View code
.se-line {
  width: min(100%, 520px);
  color: #1f1a17;
}
.se-line__text {
  margin: 0;
  color: #1f1a17;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(22px, 5vw, 30px);
  line-height: 1.6;
}
.se-line__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.se-line__select {
  appearance: none;
  max-width: 100%;
  margin: 0;
  padding: 0 22px 0 2px;
  border: 0;
  border-bottom: 2px dashed #b8461b;
  border-radius: 0;
  background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23b8461b' stroke-width='2.2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 2px center / 12px 8px;
  color: #a33c14;
  font: inherit;
  font-style: italic;
  line-height: 1.2;
  cursor: pointer;
}
.se-line__select:hover {
  border-bottom-style: solid;
}
.se-line__select:focus-visible {
  outline: 2px solid #1f1a17;
  outline-offset: 3px;
  border-radius: 2px;
}
@supports (field-sizing: content) {
  .se-line__select {
    field-sizing: content;
  }
}
.se-line__go {
  min-height: 44px;
  margin-top: 20px;
  padding: 0 22px;
  border: 2px solid #1f1a17;
  border-radius: 999px;
  background: #1f1a17;
  color: #fdf8f2;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.se-line__go:hover {
  background: transparent;
  color: #1f1a17;
}
.se-line__go:focus-visible {
  outline: 2px solid #a33c14;
  outline-offset: 3px;
}

Large touch select

  • 56px targets
  • 16px+ font size
  • native picker
Step 2 of 3

When should we deliver?

Two-hour slots cost 3.90 extra. The four-hour slot is free.

A checkout step built for thumbs. Each select is 56px tall and full width, with an 18px font so iOS Safari does not zoom the page on focus (it does that for fields under 16px). The open list stays native on purpose: the phone wheel or sheet is the best picker a touch user can get.

View code
.se-touch {
  position: relative;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  padding: 16px 20px 24px;
  background: #fbf7f0;
  color: #1c1b19;
}
.se-touch__bar {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 18px;
}
.se-touch__back {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin-left: -10px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: #1c1b19;
  cursor: pointer;
}
.se-touch__back:focus-visible {
  outline: 3px solid #0b6e4f;
  outline-offset: 0;
}
.se-touch__step {
  color: #5e5a52;
  font-size: 14px;
}
.se-touch__title {
  margin: 0 0 22px;
  color: #1c1b19;
  font-family: "Fraunces", Georgia, serif;
  font-size: 28px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -.01em;
}
.se-touch__field {
  margin-bottom: 18px;
}
.se-touch__label {
  display: block;
  margin-bottom: 8px;
  color: #1c1b19;
  font-size: 16px;
  font-weight: 600;
}
.se-touch__wrap {
  position: relative;
}
.se-touch__wrap::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 22px;
  width: 10px;
  height: 10px;
  border-right: 2.5px solid #1c1b19;
  border-bottom: 2.5px solid #1c1b19;
  transform: translateY(-70%) rotate(45deg);
  pointer-events: none;
}
.se-touch__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  height: 56px;
  margin: 0;
  padding: 0 54px 0 18px;
  border: 2px solid #1c1b19;
  border-radius: 14px;
  background: #fff;
  color: #1c1b19;
  font: inherit;
  font-size: 18px;
  cursor: pointer;
}
.se-touch__select:focus-visible {
  outline: 3px solid #0b6e4f;
  outline-offset: 2px;
}
.se-touch__note {
  margin: 0;
  color: #5e5a52;
  font-size: 15px;
  line-height: 1.45;
}
.se-touch__cta {
  min-height: 56px;
  margin-top: auto;
  border: 0;
  border-radius: 14px;
  background: #0b6e4f;
  color: #fff;
  font: inherit;
  font-size: 17px;
  font-weight: 700;
  cursor: pointer;
}
.se-touch__cta:focus-visible {
  outline: 3px solid #1c1b19;
  outline-offset: 3px;
}

Disabled until it applies

  • :disabled
  • :has(select:disabled)
  • disabled option

Tick the gift box above to choose a paper.

The wrapping paper select stays disabled until the order is marked as a gift. :disabled dims the field and swaps the cursor, :has() on the wrapper fades the caret with it, and one option is disabled on its own because it is sold out. The helper text says why the field is locked, which a grey box alone never does.

View code
.se-gift {
  box-sizing: border-box;
  width: min(100%, 320px);
  padding: 22px;
  border-radius: 14px;
  background: #fff;
  color: #22201c;
  box-shadow: 0 1px 0 #e6e1d8, 0 10px 28px rgba(34, 32, 28, .07);
}
.se-gift__toggle {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  margin-bottom: 10px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.se-gift__check {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: #9c3d2e;
}
.se-gift__check:focus-visible {
  outline: 2px solid #9c3d2e;
  outline-offset: 2px;
}
.se-gift__label {
  display: block;
  margin-bottom: 8px;
  font-size: 14px;
  font-weight: 600;
}
.se-gift__wrap {
  position: relative;
}
.se-gift__wrap::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 16px;
  width: 7px;
  height: 7px;
  border-right: 2px solid #22201c;
  border-bottom: 2px solid #22201c;
  transform: translateY(-70%) rotate(45deg);
  pointer-events: none;
  transition: opacity .2s ease;
}
.se-gift__wrap:has(.se-gift__select:disabled)::after {
  opacity: .45;
}
.se-gift__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  height: 46px;
  margin: 0;
  padding: 0 40px 0 14px;
  border: 2px solid #22201c;
  border-radius: 10px;
  background: #fff;
  color: #22201c;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
  transition: background-color .2s ease, border-color .2s ease, color .2s ease;
}
.se-gift__select:focus-visible {
  outline: 3px solid #9c3d2e;
  outline-offset: 2px;
}
.se-gift__select:disabled {
  border-color: #c9c3b8;
  background: #f3f0ea;
  color: #6b655b;
  cursor: not-allowed;
}
.se-gift__why {
  margin: 10px 0 0;
  color: #5c574e;
  font-size: 13px;
  line-height: 1.45;
}

Customizable selects with base-select

These opt in to appearance: base-select inside @supports, which unlocks ::picker(select), selectedcontent, ::picker-icon and ::checkmark. Each one is written as a styled classic select first, so Firefox and older Safari still get a finished control.

Customizable select with a styled picker

  • appearance: base-select
  • ::picker(select)
  • @starting-style

Open the select. Browsers without base-select show the classic fallback.

The full opt-in. Each option holds a color chip, a name and a tasting note laid out with grid, selectedcontent hides the note so the closed button stays one line high, ::checkmark moves to the end, and the picker fades in from @starting-style. The fallback is a classic select with an SVG arrow, and the hidden separator gives it readable option text.

View code
.se-roast {
  width: min(100%, 300px);
  padding-bottom: 270px;
  color: #2b1d14;
}
.se-roast__label {
  display: block;
  margin-bottom: 8px;
  color: #2b1d14;
  font-size: 14px;
  font-weight: 700;
}
.se-roast__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  min-height: 52px;
  margin: 0;
  padding: 0 44px 0 16px;
  border: 2px solid #2b1d14;
  border-radius: 12px;
  background: #fffaf3 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%232b1d14' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 16px center / 12px 8px;
  color: #2b1d14;
  font: inherit;
  font-size: 16px;
  cursor: pointer;
}
.se-roast__select:focus-visible {
  outline: 3px solid #b5651d;
  outline-offset: 2px;
}
@supports (appearance: base-select) {
  .se-roast__select,
  .se-roast__select::picker(select) {
    appearance: base-select;
  }
  .se-roast__select {
    display: flex;
    align-items: center;
    padding: 6px 14px;
    background-image: none;
  }
  .se-roast__btn {
    display: flex;
    flex: 1;
    align-items: center;
    min-width: 0;
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
  }
  .se-roast__select selectedcontent {
    display: flex;
    align-items: center;
    gap: 10px;
  }
  .se-roast__select selectedcontent .se-roast__desc,
  .se-roast__select selectedcontent .se-roast__sep {
    display: none;
  }
  .se-roast__select::picker-icon {
    color: #8b5a33;
  }
  .se-roast__select:open::picker-icon {
    rotate: 180deg;
  }
  .se-roast__select::picker(select) {
    margin-top: 6px;
    padding: 6px;
    border: 2px solid #2b1d14;
    border-radius: 14px;
    background: #fffaf3;
    box-shadow: 0 18px 40px rgba(43, 29, 20, .18);
  }
  .se-roast__select option {
    display: grid;
    grid-template-columns: auto 1fr auto;
    grid-template-areas: "chip name mark" "chip desc mark";
    column-gap: 12px;
    align-items: center;
    padding: 9px 10px;
    border-radius: 9px;
    color: #2b1d14;
  }
  .se-roast__select option:hover,
  .se-roast__select option:focus-visible {
    background: #f3e4d1;
    outline: none;
  }
  .se-roast__select option:checked {
    background: #ecd6bb;
    font-weight: 700;
  }
  .se-roast__select option::checkmark {
    grid-area: mark;
    color: #8b5a33;
  }
  .se-roast__sep {
    display: none;
  }
  .se-roast__name {
    grid-area: name;
  }
  .se-roast__desc {
    grid-area: desc;
    color: #6e5442;
    font-size: 13px;
    font-weight: 400;
  }
  .se-roast__chip {
    grid-area: chip;
    width: 22px;
    height: 22px;
    border-radius: 50%;
    box-shadow: inset 0 0 0 2px rgba(43, 29, 20, .2);
  }
  .se-roast__chip--1 {
    background: #c99a62;
  }
  .se-roast__chip--2 {
    background: #9a6532;
  }
  .se-roast__chip--3 {
    background: #6b3f1d;
  }
  .se-roast__chip--4 {
    background: #3a2110;
  }
  @media (prefers-reduced-motion: no-preference) {
    .se-roast__select::picker-icon {
      transition: rotate .3s ease;
    }
    .se-roast__select::picker(select) {
      opacity: 0;
      translate: 0 -8px;
      transition: opacity .22s ease, translate .22s ease, display .22s allow-discrete, overlay .22s allow-discrete;
    }
    .se-roast__select:open::picker(select) {
      opacity: 1;
      translate: 0 0;
    }
    @starting-style {
      .se-roast__select:open::picker(select) {
        opacity: 0;
        translate: 0 -8px;
      }
    }
  }
}

Options with inline SVG icons

  • SVG in option
  • selectedcontent
  • ::checkmark hidden

Open the select. Browsers without base-select show the classic fallback.

An export menu where each format has its own icon, drawn as inline SVG instead of emoji or flag images. The icon is copied into selectedcontent, so the closed button shows it too. In browsers without the new parser the SVG is dropped and the option text remains, and aria-hidden keeps the icons out of the announced name.

View code
.se-file {
  box-sizing: border-box;
  width: min(100%, 360px);
  padding-bottom: 190px;
  color: #172033;
}
.se-file__label {
  display: block;
  margin-bottom: 8px;
  color: #172033;
  font-size: 14px;
  font-weight: 600;
}
.se-file__row {
  display: flex;
  gap: 8px;
}
.se-file__select {
  appearance: none;
  box-sizing: border-box;
  flex: 1;
  min-width: 0;
  height: 44px;
  margin: 0;
  padding: 0 38px 0 12px;
  border: 1px solid #8a93a6;
  border-radius: 8px;
  background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23172033' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 12px center / 11px 7px;
  color: #172033;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
}
.se-file__select:focus-visible {
  outline: 2px solid #3156d3;
  outline-offset: 2px;
}
.se-file__go {
  min-height: 44px;
  padding: 0 16px;
  border: 0;
  border-radius: 8px;
  background: #172033;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.se-file__go:focus-visible {
  outline: 2px solid #3156d3;
  outline-offset: 2px;
}
@supports (appearance: base-select) {
  .se-file__select,
  .se-file__select::picker(select) {
    appearance: base-select;
  }
  .se-file__select {
    display: flex;
    align-items: center;
    padding: 0 10px 0 12px;
    background-image: none;
  }
  .se-file__btn {
    display: flex;
    flex: 1;
    align-items: center;
    min-width: 0;
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
  }
  .se-file__select selectedcontent {
    display: flex;
    align-items: center;
    gap: 8px;
    white-space: nowrap;
  }
  .se-file__select::picker-icon {
    color: #5a6478;
  }
  .se-file__select::picker(select) {
    margin-top: 4px;
    padding: 4px;
    border: 1px solid #d4d9e2;
    border-radius: 10px;
    background: #fff;
    box-shadow: 0 12px 32px rgba(23, 32, 51, .14);
  }
  .se-file__select option {
    gap: 10px;
    align-items: center;
    padding: 9px 12px 9px 9px;
    border-left: 3px solid transparent;
    border-radius: 6px;
    color: #172033;
    font-size: 15px;
  }
  .se-file__select option::checkmark {
    display: none;
  }
  .se-file__select option:hover,
  .se-file__select option:focus-visible {
    background: #eef1f7;
    outline: none;
  }
  .se-file__select option:checked {
    border-left-color: #3156d3;
    background: #e7edff;
    font-weight: 600;
  }
}
.se-file__ico {
  flex: none;
  width: 20px;
  height: 20px;
}
.se-file__ico--pdf {
  color: #c2311f;
}
.se-file__ico--png {
  color: #1f7a4d;
}
.se-file__ico--svg {
  color: #8a2f84;
}
.se-file__ico--csv {
  color: #2756b8;
}

Grouped options with legends

  • optgroup
  • legend in optgroup
  • label fallback

Open the select. Browsers without base-select show the classic fallback.

Pickup points grouped by city. In base-select mode each optgroup is an ordinary block, so it gets a divider and padding, and its legend child is a real element you can style. The label attribute stays on every optgroup because the legend is dropped by older parsers, and the classic fallback needs the label to show the group names.

View code
.se-pick {
  box-sizing: border-box;
  width: min(100%, 320px);
  padding-bottom: 300px;
  color: #0f2e25;
}
.se-pick__label {
  display: block;
  margin-bottom: 8px;
  color: #0f2e25;
  font-size: 14px;
  font-weight: 700;
}
.se-pick__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  height: 48px;
  margin: 0;
  padding: 0 42px 0 14px;
  border: 2px solid #0f2e25;
  border-radius: 999px;
  background: #f2f8f4 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%230f2e25' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 18px center / 12px 8px;
  color: #0f2e25;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
}
.se-pick__select:focus-visible {
  outline: 3px solid #138a5e;
  outline-offset: 2px;
}
@supports (appearance: base-select) {
  .se-pick__select,
  .se-pick__select::picker(select) {
    appearance: base-select;
  }
  .se-pick__select {
    display: flex;
    align-items: center;
    padding: 0 18px;
    background-image: none;
  }
  .se-pick__btn {
    flex: 1;
    min-width: 0;
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    text-align: start;
  }
  .se-pick__select selectedcontent {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .se-pick__select::picker-icon {
    color: #0f2e25;
  }
  .se-pick__select::picker(select) {
    margin-top: 6px;
    padding: 4px 0;
    border: 2px solid #0f2e25;
    border-radius: 16px;
    background: #fff;
  }
  .se-pick__select optgroup {
    margin: 0;
    padding: 4px 6px 6px;
    border-top: 1px solid #d3e3da;
    font-weight: 400;
  }
  .se-pick__select optgroup:first-of-type {
    border-top: 0;
  }
  .se-pick__select legend {
    padding: 8px 10px 4px;
    color: #138a5e;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: .02em;
  }
  .se-pick__select option {
    padding: 9px 10px;
    border-radius: 10px;
    color: #0f2e25;
    font-size: 15px;
  }
  .se-pick__select option:hover,
  .se-pick__select option:focus-visible {
    background: #e3f1e8;
    outline: none;
  }
  .se-pick__select option:checked {
    background: #0f2e25;
    color: #fff;
  }
  .se-pick__select option::checkmark {
    order: 1;
    margin-left: auto;
  }
}

Selects in real forms

Filters, validation, sorting and dependent fields. The filter bar is CSS only with :has(); the others use a few lines of script that only read values and write textContent.

Multi-select list box

  • multiple + size
  • option:checked
  • change event

Hold Ctrl or Cmd to pick several.

2 of 8 selected

With multiple and a size, the select renders as an in-page list box instead of a dropdown, so the options themselves take padding, radius and a :checked color in Chrome and Firefox. Safari applies less of it. A small script counts the selection into a status line, since the list box gives no summary of its own.

View code
.se-tags {
  box-sizing: border-box;
  width: min(100%, 300px);
  color: #1d1d1f;
}
.se-tags__label {
  display: block;
  color: #1d1d1f;
  font-size: 15px;
  font-weight: 700;
}
.se-tags__hint {
  margin: 2px 0 10px;
  color: #5b5b62;
  font-size: 13px;
}
.se-tags__list {
  appearance: none;
  box-sizing: border-box;
  display: block;
  width: 100%;
  margin: 0;
  padding: 6px;
  border: 2px solid #1d1d1f;
  border-radius: 14px;
  background: #fff;
  color: #1d1d1f;
  font: inherit;
  font-size: 15px;
  overflow-y: auto;
  scrollbar-width: thin;
}
.se-tags__list:focus-visible {
  outline: 3px solid #7a5a00;
  outline-offset: 2px;
}
.se-tags__list option {
  margin-bottom: 2px;
  padding: 7px 10px;
  border-radius: 8px;
  color: #1d1d1f;
}
.se-tags__list option:hover {
  background: #f3f0e4;
}
.se-tags__list option:checked {
  background: #ffd23f;
  color: #1d1d1f;
  font-weight: 600;
}
.se-tags__count {
  margin: 10px 0 0;
  color: #1d1d1f;
  font-size: 14px;
  font-weight: 600;
}

Filter bar with several selects

  • :has(option:checked)
  • CSS counters
  • type="reset"
  • Pine Hollow CabinMountains, pets ok120 / night
  • Harbour LoftCoast95 / night
  • Casa MaretaCoast, pets ok240 / night
  • Dune ShackCoast80 / night
  • Ridge StudioMountains, pets ok110 / night
  • Alpenrose VillaMountains310 / night

Three selects filter six listings without a line of filtering script. Each rule reads like "when Cabin is checked, hide cards that are not cabins", and the rules stack across selects. A CSS counter only counts cards that are still displayed, so the result line updates itself, and a native reset button restores every select at once.

View code
.se-stay {
  display: flex;
  flex-direction: column;
  box-sizing: border-box;
  width: min(100%, 680px);
  margin: 0;
  color: #1b1f24;
}
.se-stay__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 10px;
  padding: 12px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 6px 20px rgba(27, 31, 36, .08);
}
.se-stay__field {
  flex: 1 1 130px;
  min-width: 0;
}
.se-stay__label {
  display: block;
  margin: 0 0 4px 2px;
  color: #4b525c;
  font-size: 12px;
  font-weight: 600;
}
.se-stay__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  height: 42px;
  margin: 0;
  padding: 0 32px 0 12px;
  border: 1px solid #c5cbd3;
  border-radius: 10px;
  background: #f6f7f9 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%231b1f24' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 12px center / 10px 7px;
  color: #1b1f24;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.se-stay__select:has(option[value="any"]:checked) {
  color: #4b525c;
}
.se-stay__select:not(:has(option[value="any"]:checked)) {
  border-color: #1b1f24;
  background-color: #fff;
  font-weight: 600;
}
.se-stay__select:focus-visible,
.se-stay__reset:focus-visible {
  outline: 2px solid #d24e2a;
  outline-offset: 2px;
}
.se-stay__reset {
  flex: 0 0 auto;
  height: 42px;
  padding: 0 16px;
  border: 0;
  border-radius: 10px;
  background: #1b1f24;
  color: #fff;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.se-stay__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
  gap: 12px;
  margin: 14px 0 0;
  padding: 0;
  list-style: none;
  counter-reset: se-stay;
}
.se-stay__card {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin: 0;
  padding: 0 0 12px;
  overflow: hidden;
  border-radius: 12px;
  background: #fff;
  font-size: 13px;
  color: #4b525c;
  counter-increment: se-stay;
}
.se-stay__card b {
  padding: 0 12px;
  color: #1b1f24;
  font-size: 14px;
}
.se-stay__card span:not(.se-stay__tile) {
  padding: 0 12px;
}
.se-stay__price {
  color: #1b1f24;
  font-weight: 600;
}
.se-stay__tile {
  height: 54px;
  margin-bottom: 8px;
}
.se-stay__tile--1 { background: #2f5d50; }
.se-stay__tile--2 { background: #3d6fa8; }
.se-stay__tile--3 { background: #e0a43b; }
.se-stay__tile--4 { background: #d9c7a0; }
.se-stay__tile--5 { background: #6a5a8c; }
.se-stay__tile--6 { background: #b2533e; }
.se-stay__count {
  order: -1;
  margin: 0 0 10px 2px;
  color: #1b1f24;
  font-size: 14px;
  font-weight: 600;
}
.se-stay__count::after {
  content: counter(se-stay) " of 6 stays match";
}
.se-stay:has(.se-stay__type option[value="cabin"]:checked) .se-stay__card:not([data-type="cabin"]),
.se-stay:has(.se-stay__type option[value="loft"]:checked) .se-stay__card:not([data-type="loft"]),
.se-stay:has(.se-stay__type option[value="villa"]:checked) .se-stay__card:not([data-type="villa"]),
.se-stay:has(.se-stay__area option[value="coast"]:checked) .se-stay__card:not([data-area="coast"]),
.se-stay:has(.se-stay__area option[value="hills"]:checked) .se-stay__card:not([data-area="hills"]),
.se-stay:has(.se-stay__pets option[value="yes"]:checked) .se-stay__card:not([data-pets="yes"]) {
  display: none;
}

Required select with :user-invalid

  • :user-invalid
  • required placeholder
  • :has() message

Contact support

Pick a topic so the right team sees your ticket.

The topic select is required and starts on an empty placeholder option, so it is invalid from the start, but :user-invalid waits until the person has changed it or tried to submit. Only then does the red border and the message appear. :required:invalid greys the placeholder text, and :user-valid confirms a fixed field.

View code
.se-ticket {
  box-sizing: border-box;
  width: min(100%, 330px);
  margin: 0;
  padding: 24px;
  border-radius: 16px;
  background: #fff;
  color: #171a21;
  box-shadow: 0 10px 30px rgba(23, 26, 33, .08);
}
.se-ticket__title {
  margin: 0 0 18px;
  color: #171a21;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: 0;
}
.se-ticket__label {
  display: block;
  margin-bottom: 8px;
  font-size: 14px;
  font-weight: 600;
}
.se-ticket__label span {
  color: #b42318;
}
.se-ticket__wrap {
  position: relative;
}
.se-ticket__wrap::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 16px;
  width: 7px;
  height: 7px;
  border-right: 2px solid #171a21;
  border-bottom: 2px solid #171a21;
  transform: translateY(-70%) rotate(45deg);
  pointer-events: none;
}
.se-ticket__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  height: 46px;
  margin: 0;
  padding: 0 40px 0 14px;
  border: 2px solid #6f7685;
  border-radius: 10px;
  background: #fff;
  color: #171a21;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
  transition: border-color .2s ease, background-color .2s ease;
}
.se-ticket__select:required:invalid {
  color: #5a606d;
}
.se-ticket__select option {
  color: #171a21;
}
.se-ticket__select:user-invalid {
  border-color: #b42318;
  background-color: #fff6f5;
}
.se-ticket__select:user-valid {
  border-color: #1d7a46;
}
.se-ticket__select:focus-visible {
  outline: 3px solid #3d5afe;
  outline-offset: 2px;
}
.se-ticket__msg {
  display: none;
  margin: 8px 0 0;
  padding-left: 22px;
  color: #b42318;
  font-size: 13px;
  line-height: 1.4;
  background: no-repeat left top 1px / 16px 16px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23b42318'/%3E%3Cpath d='M8 4v5' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='8' cy='11.8' r='1.1' fill='%23fff'/%3E%3C/svg%3E");
}
.se-ticket__field:has(.se-ticket__select:user-invalid) .se-ticket__msg {
  display: block;
}
.se-ticket__send {
  width: 100%;
  min-height: 46px;
  margin-top: 18px;
  border: 0;
  border-radius: 10px;
  background: #171a21;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.se-ticket__send:focus-visible {
  outline: 3px solid #3d5afe;
  outline-offset: 2px;
}
.se-ticket__status {
  min-height: 1.4em;
  margin: 10px 0 0;
  color: #1d7a46;
  font-size: 14px;
  font-weight: 600;
}

Sort-by select that reorders a list

  • change event
  • appendChild reorder
  • role="status"

Notebooks

  • Fieldnote Dot A54.718.00
  • Pocket Grid A64.29.00
  • Atelier Sketch B54.932.00
  • Kraft Ruled Trio3.914.00
  • Daily Planner Weekly4.524.00

Showing 5 notebooks, featured first.

A product list with a compact sort control in the header. On change, the script reads data attributes, sorts the existing list items and appends them back in the new order, so no markup is rebuilt and focus is never lost. The status line announces the new order to screen readers.

View code
.se-sort {
  box-sizing: border-box;
  width: min(100%, 440px);
  padding: 20px;
  border-radius: 16px;
  background: #fff;
  color: #1e1c1a;
  box-shadow: 0 10px 30px rgba(30, 28, 26, .08);
}
.se-sort__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 12px;
}
.se-sort__title {
  margin: 0;
  color: #1e1c1a;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: 0;
}
.se-sort__ctrl {
  display: flex;
  align-items: center;
  gap: 8px;
}
.se-sort__label {
  color: #5d5852;
  font-size: 13px;
}
.se-sort__select {
  appearance: none;
  height: 36px;
  margin: 0;
  padding: 0 30px 0 12px;
  border: 1px solid #d8d3cc;
  border-radius: 999px;
  background: #f5f2ee url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%231e1c1a' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 12px center / 10px 7px;
  color: #1e1c1a;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.se-sort__select:focus-visible {
  outline: 2px solid #d6492f;
  outline-offset: 2px;
}
.se-sort__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.se-sort__list li {
  display: grid;
  grid-template-columns: 28px 1fr auto auto;
  align-items: center;
  gap: 12px;
  margin: 0;
  padding: 10px 0;
  border-top: 1px solid #ece8e2;
  font-size: 14px;
}
.se-sort__swatch {
  width: 28px;
  height: 36px;
  border-radius: 3px 6px 6px 3px;
}
.se-sort__swatch--1 { background: #d6492f; }
.se-sort__swatch--2 { background: #2e6f95; }
.se-sort__swatch--3 { background: #2f2a26; }
.se-sort__swatch--4 { background: #b88b4a; }
.se-sort__swatch--5 { background: #4f8a5b; }
.se-sort__name {
  min-width: 0;
  font-weight: 600;
}
.se-sort__rate {
  color: #5d5852;
}
.se-sort__rate::before {
  content: "\2605  ";
  color: #b8860b;
}
.se-sort__price {
  min-width: 48px;
  font-variant-numeric: tabular-nums;
  text-align: right;
}
.se-sort__status {
  margin: 10px 0 0;
  color: #5d5852;
  font-size: 13px;
}

Country and region dependent selects

  • createElement option
  • disabled until ready
  • label textContent

The second select stays disabled until a country is chosen, then fills with the regions of that country and renames its own label (province, state, prefecture). Options are built with createElement and textContent, never HTML strings, and the region resets whenever the country changes so a stale value cannot be submitted.

View code
.se-addr {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 14px;
  box-sizing: border-box;
  width: min(100%, 460px);
  margin: 0;
  padding: 22px;
  border: 1px solid #d9dee7;
  border-radius: 14px;
  background: #f9fbfd;
  color: #13233a;
}
.se-addr__label {
  display: block;
  margin-bottom: 6px;
  color: #13233a;
  font-size: 14px;
  font-weight: 600;
}
.se-addr__select {
  appearance: none;
  box-sizing: border-box;
  width: 100%;
  height: 46px;
  margin: 0;
  padding: 0 38px 0 12px;
  border: 1px solid #6c7a8f;
  border-radius: 8px;
  background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%2313233a' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 14px center / 11px 7px;
  color: #13233a;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
  transition: background-color .2s ease, border-color .2s ease;
}
.se-addr__select:focus-visible {
  outline: 3px solid #1d64d8;
  outline-offset: 1px;
}
.se-addr__select:disabled {
  border-style: dashed;
  border-color: #a9b3c2;
  background-color: #eef1f5;
  color: #5b6779;
  cursor: not-allowed;
}

When a select is the wrong control

Two native alternatives worth knowing: radio buttons styled as a segmented control for a handful of options, and an input with a datalist when people should be able to type their own value.

Segmented radios instead of a select

  • fieldset + radios
  • :has(:checked)
  • sliding thumb
Billing period

12 per month115 per year, two months free290 once

With two to four short options, a select hides choices that would fit on screen and costs an extra click. Radios show every option, keep arrow-key navigation and need no script; the thumb and the price swap are driven by :has(:checked). Keep the select once options grow past about five, get long, or change with data.

View code
.se-seg {
  box-sizing: border-box;
  width: min(100%, 340px);
  color: #1a1530;
}
.se-seg__set {
  min-inline-size: 0;
  margin: 0;
  padding: 0;
  border: 0;
}
.se-seg__legend {
  margin-bottom: 10px;
  padding: 0;
  color: #1a1530;
  font-size: 15px;
  font-weight: 700;
}
.se-seg__track {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  padding: 4px;
  border-radius: 12px;
  background: #e9e5f5;
}
.se-seg__thumb {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 4px;
  width: calc((100% - 8px) / 3);
  border-radius: 9px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(26, 21, 48, .15);
}
.se-seg__track:has(input[value="year"]:checked) .se-seg__thumb {
  transform: translateX(100%);
}
.se-seg__track:has(input[value="life"]:checked) .se-seg__thumb {
  transform: translateX(200%);
}
.se-seg__opt {
  position: relative;
  display: grid;
  place-items: center;
  min-height: 44px;
  border-radius: 9px;
  color: #4a4366;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.se-seg__opt input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
.se-seg__opt:has(input:checked) {
  color: #1a1530;
}
.se-seg__opt:has(input:focus-visible) {
  outline: 2px solid #5b3fd1;
  outline-offset: -2px;
}
.se-seg__price {
  margin: 14px 0 0;
  color: #1a1530;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
}
.se-seg__p {
  display: none;
  font-weight: 600;
}
.se-seg:has(input[value="month"]:checked) .se-seg__p--month,
.se-seg:has(input[value="year"]:checked) .se-seg__p--year,
.se-seg:has(input[value="life"]:checked) .se-seg__p--life {
  display: inline;
}
@media (prefers-reduced-motion: no-preference) {
  .se-seg__thumb {
    transition: transform .28s cubic-bezier(.3, .7, .4, 1);
  }
}
@media (forced-colors: active) {
  .se-seg__opt:has(input:checked) {
    outline: 2px solid CanvasText;
    outline-offset: -2px;
  }
}

Combobox-like input with a datalist

  • input list=
  • datalist
  • free text allowed

Pick a suggestion or type any city with an airport.

When the list is long or people may need a value that is not on it, an input with a datalist gives suggestions that filter as you type, and any text is still accepted. The field is fully stylable, but the suggestion popup is drawn by the browser and cannot be styled, so treat it like the open list of a classic select.

View code
.se-city {
  box-sizing: border-box;
  width: min(100%, 330px);
  padding: 22px;
  border-radius: 16px;
  background: #0f2a43;
  color: #eaf2fa;
}
.se-city__label {
  display: block;
  margin-bottom: 8px;
  color: #eaf2fa;
  font-size: 14px;
  font-weight: 600;
}
.se-city__wrap {
  position: relative;
}
.se-city__ico {
  position: absolute;
  top: 50%;
  left: 14px;
  color: #0f2a43;
  transform: translateY(-50%);
  pointer-events: none;
}
.se-city__input {
  box-sizing: border-box;
  width: 100%;
  height: 48px;
  margin: 0;
  padding: 0 14px 0 42px;
  border: 2px solid transparent;
  border-radius: 10px;
  background: #fff;
  color: #0f2a43;
  font: inherit;
  font-size: 16px;
}
.se-city__input::placeholder {
  color: #5a6b7d;
  opacity: 1;
}
.se-city__input::-webkit-calendar-picker-indicator {
  opacity: .6;
}
.se-city__input:focus-visible {
  border-color: #ffb547;
  outline: 2px solid #ffb547;
  outline-offset: 2px;
}
.se-city__help {
  margin: 10px 0 0;
  color: #b9cadb;
  font-size: 13px;
  line-height: 1.45;
}

What can CSS style on a select element?

Less than you’d want, and more than it used to be. A select is really two controls glued together. The closed field styles like any other form control, while the list that opens below it is drawn by the browser or the operating system.

MDN’s form styling guide files select, option and optgroup under the controls whose internals can’t be styled in CSS alone, then makes one exception: customizable select.

The classic route means setting appearance: none and styling whatever the browser leaves behind. It works everywhere, but the open list stays mostly out of reach. The opt-in route is appearance: base-select on the select and its picker, which turns the button, list, options and checkmark into normal stylable parts. Supporting browsers only.

Here is where each part stands.

  • Select box (border, padding, font, background): both routes
  • Arrow: removable in the classic route, restyled with ::picker-icon in the opt-in route
  • Option text: font inheritance only in the classic route, full styling in the opt-in route
  • Checkmark, optgroup layout and picker position: opt-in route only

Styling a native dropdown without JavaScript was a closed-box job for two decades.

How to style the closed select box

The usual box and text properties all work on the closed field: border, border-radius, padding, background-color, font, width and height.

Box and typography properties

  • font: inherit, so the field matches the surrounding text
  • box-sizing: border-box, so width: 100% fits its container
  • padding and height set together, since browsers size the field differently by default
  • border-radius plus a soft inset box-shadow for the usual rounded-field look

MDN’s form styling guide applies one normalizing block to every control, selects included, so the fields end up the same size across browsers.

A shared block is easier to maintain when form styles live in one place, and the walkthrough on styling forms with CSS shows that pattern.

border-radius reshapes the closed box only. In classic mode the open list stays with the browser.

Hover, focus and disabled states

Keep a visible outline on focus. A select with appearance: none and no replacement focus style is invisible to keyboard users.

Hover needs very little. A background shift is enough, with a short transition so it doesn’t flicker. Disabled selects get dimmed text and background through the :disabled pseudo-class, plus cursor: not-allowed.

How to remove and replace the dropdown arrow

Set appearance: none on the select to remove the native arrow, then draw a new one with a wrapper pseudo-element or a background image.

The property has been available across browsers since March 2022 (MDN), so removing the arrow carries no support risk.

Old tutorials still add -webkit-appearance, -moz-appearance and a select::-ms-expand rule for Internet Explorer. Current browsers need only the unprefixed property.

Wrapper with a pseudo-element

A wrapper is needed because ::before and ::after do not work on select elements, since the browser controls their content (MDN).

  • Wrap the select in a div with position: relative
  • Place the arrow on the wrapper’s ::after, absolutely positioned at the right edge
  • Set pointer-events: none on the arrow so clicks reach the select
  • Add padding-right to the select so long labels never run under the arrow

This is the pattern MDN uses. The native icon also differs between browsers and shifts when the box is resized, which is the problem the wrapper avoids.

Some tutorials draw the arrow as a triangle with clip-path, which keeps image files out of the stylesheet. A clip-path generator saves the coordinate math.

Define the border color once as a custom property and reuse it for the arrow. When the focus state changes the border, the arrow follows.

Background image on the select

The markup is simpler this way, but you get less control.

Set background-image to an inline SVG data URI, background-repeat to no-repeat, and background-position to the right edge, with padding-right on the select.

Some guides list drawing the arrow on the select itself among the common mistakes. It holds up when the field has a fixed size and the markup cannot change.

Recoloring means editing the SVG string, since custom properties do not reach inside a data URI. That is the main reason to prefer the wrapper.

Selects in base-select mode skip all of this, because the arrow is a real pseudo-element, ::picker-icon.

Can you style option elements in a classic select?

Only partly. In classic mode the open list inherits the parent font, but colors, spacing and the list box itself stay with the browser (MDN, Advanced form styling).

Published guides disagree on the details. MDN says font inheritance works while spacing and colors do not. Sentry goes further and says option elements cannot be styled in any way. Other tutorials report that font-style set on the select reaches list items in Chrome but not in Firefox, or show background and text color rules written directly on option elements.

The positions differ because engines draw the open list differently. A rule that works in one browser on one platform can do nothing in another, and that is a cross-browser compatibility problem, not a bug in your stylesheet.

Treat option color and background rules as cosmetic extras, never as the design.

Ways around the limit

Adding the multiple attribute gets you out of the problem, since every option renders on the page and there is no closed list to fight with (MDN). Opting in with appearance: base-select is the other clean way, because option, optgroup and the picker then take normal CSS.

Replacing the control with a JavaScript library works too. It costs accessibility work and bundle weight, though.

What does appearance: base-select change?

A lot, mostly on the picker side. It opts a select and its picker out of OS-drawn rendering, so the button, option list, checkmark and arrow become ordinary stylable parts.

Chrome shipped it first and Safari 27 followed. Firefox has not shipped it (since Firefox 149 it sits behind a preference that is off by default), so MDN and the web-features data still list it as limited availability.

What the opt-in switches on

  • A changed HTML parser, so option elements accept rich content such as images and SVG
  • New internal parts and states: a button as first child, selectedcontent, ::picker-icon, ::checkmark and :open
  • A picker rendered in the top layer like a popover, through an implicit invoker and popover link between select and picker (Popover API), so transitions can animate it
  • Implicit CSS anchor positioning, which places the picker below the button by default
  • Browser-defined position-try fallbacks for pickers that would overflow
  • A minimal base look built for customization

Those fallbacks reposition the picker when it would overflow the viewport, so a select near the bottom of the screen opens upward instead of clipping.

Both the select and the picker need the value. Opting in only the select leaves the picker with OS styling, and the picker cannot be opted in alone (MDN).

::picker(select) targets everything inside the select except the first button child, so one rule styles the whole list.

What the opt-in switches off

Chrome for Developers documented what you give up in its 24 March 2025 announcement. The select no longer renders outside the browser pane, and the built-in mobile operating system components stop appearing.

It also stops taking the width of the longest option, which catches layouts by surprise. Set the width explicitly.

Where the feature stands

Chrome 135 was the first stable version (stable on 1 April 2025, announced by Chrome for Developers on 24 March 2025), and Safari 27 was released on 14 September 2026. Firefox does not support it in stable releases as of October 2026.

MDN’s feature list names 9 building blocks, from the plain select, option and optgroup elements to ::checkmark. Its availability status is limited, not Baseline (page last modified 13 August 2026), and the web-features data lists Firefox as the browser blocking Baseline.

How to build a customizable select step by step

Every selector below comes from MDN’s customizable select walkthrough.

  1. Write the markup: a label tied to the select by for and id, then the select with a button as its first child holding selectedcontent, then option elements with value attributes
  2. Opt in with appearance: base-select on both the select and ::picker(select)
  3. Style the select box like any block: border, background, padding, and a short transition on hover and focus
  4. Style ::picker-icon: set its color, then rotate it 180deg on select:open
  5. Strip the default picker border with ::picker(select), then pad and round the options
  6. Mark the chosen option with :checked (a heavier font-weight works), and move or hide ::checkmark
  7. Hide decorative icons inside selectedcontent so the closed button keeps a stable height

The label and select pair in step 1 is the same pattern covered in guides to accessible forms. Skip it and the control loses its name.

The arrow rotation in step 4 is a small micro-interaction. MDN’s example transitions the rotate property over 0.4s, which is slow enough to read and fast enough to feel instant.

Selected content inside the button

selectedcontent holds a clone of the chosen option’s content, made with cloneNode under the hood (MDN).

Leave it out and the browser prints the chosen option’s text in the default button, which is harder to style. Extra content inside the button can change the accessible value the select exposes, and the button is inert by default, so links or buttons placed inside it are neither focusable nor clickable.

Put display: none on the icon span inside selectedcontent. The option keeps its icon in the picker, and the closed field stays one line high.

Optgroups and legends

In base-select mode an optgroup acts like any block container, so border, radius, background and margin all apply.

A legend child replaces the label attribute text and gives you an element to target. Center it and add a bottom margin. Give the last option in each group the same bottom radius as the group, or square corners poke through.

Mark decorative icon spans aria-hidden=”true”. Without it, screen readers announce each option twice, as in “cat cat” (MDN).

Which browsers support customizable select, and how should the fallback work?

Chromium browsers and Safari 27 or later support base-select. Firefox has not shipped it in a stable release, so Firefox and older versions of Safari and Chromium need a fallback to a classic select that still works.

Support by engine

Chrome and Edge 135 and later render the opt-in route. So does Safari 27, released 14 September 2026, though WebKit redesigned its default base styles before release, so test the unstyled defaults in both. Earlier Safari versions get classic styling.

Firefox gets classic styling as well. Mozilla’s standards-position issue for the feature is positive and Firefox 149 added it behind a preference, but the feature is not available in stable releases as of October 2026.

Engine status moves faster than articles do. Check Can I use before launch.

Third-party posts also disagree on the first stable Chrome version. SitePoint says 133, Modern CSS says 134 and later, and OpenReplay says 135 and later.

The Chrome for Developers announcement is the primary source, and it sides with the 135 group.

How the fallback renders

The select element has been available across browsers since July 2015 (MDN), so a working control always sits underneath the new styles.

The button child and selectedcontent are ignored or removed, and rich option content gets stripped down to its text. Selection, keyboard use and form submission stay intact.

SitePoint adds a warning: parsers without the new behavior can eject the button child from the select. MDN’s select reference confirms the mechanism, since older parsers keep only option, optgroup and hr.

Layering with @supports

Write the classic route first: appearance: none, the wrapper arrow, the closed-box styles. Then wrap only the base-select rules in @supports (appearance: base-select).

Rules that touch ::picker(select), ::picker-icon or ::checkmark belong inside that block.

The feature query tests the property, not the parser. Older browsers still receive the new markup, so keep the classic select styled defensively.

Where the specs stand

Customizable select sat at stage 2 in WHATWG when Chrome filed its intent to ship and reached stage 3 in February 2025, and Open UI hosts the component design for the select.

::picker is specified in CSS Form Control Styling Level 1, and appearance in CSS Basic User Interface Module Level 4 (MDN specification tables).

How to keep a styled select accessible

Keep the native element, a visible focus indicator, a label, and contrast of at least 3:1 on every part a user needs to see.

That 3:1 figure comes from WCAG 2.2 SC 1.4.11 for user interface components and their states (W3C Understanding docs, updated September 2026). The dropdown arrow counts, because W3C’s own examples treat it as required to show that a menu exists.

Run the box border, the arrow and the focus indicator through a color contrast check before shipping.

Disabled controls are exempt from the 3:1 rule, so dimming them is allowed. W3C names selects among the controls where pointer position is the hover indicator, which means extra hover styling doesn’t need 3:1 either. An untouched browser focus ring is exempt too, but restyle it and the exemption ends.

Focus

SC 2.4.7 Focus Visible (Level AA) requires a visible keyboard focus indicator. W3C lists removing it through outline or border styling as failure F78.

A focus style that only recolors the border fails when the new color does not contrast with the component’s own background. A two-color indicator (W3C technique C40) passes on any background.

Labels, roles and keyboard

  • Tie the label to the select with for and id
  • Single selects expose the combobox role, while selects with multiple or a size above 1 expose listbox (MDN)
  • appearance changes the look and not the functionality, so native keyboard behavior survives appearance: none
  • Chrome’s announcement calls the opt-in route accessible, because the browser’s own select keeps the interaction
  • ::checkmark and ::picker-icon sit outside the accessibility tree, so generated content on them is never announced

A hand-built replacement carries the whole burden. MDN says it needs JavaScript and ARIA roles and states to supply the semantics a native select gives for free.

When does CSS select styling stop working?

Forgetting the opt-in is the easiest way to break it. A ::picker(select) rule only styles the picker when both the select and the picker have appearance: base-select. Without that, the rule has no effect (MDN).

Frameworks can get in the way too. MDN warns that some JavaScript frameworks block these features, and that others cause hydration failures when server-side rendering is on. MDN names no frameworks, so test your own server render.

Older parsers strip everything but option, optgroup and hr. Script that looks for the button or selectedcontent then finds nothing, so guard those queries (MDN).

A select with the multiple attribute renders a scrolling list box with no open state, so :open never matches (MDN). Listboxes get their own customizable select guide there.

If options carry images or SVG, test the selected value in JavaScript. Chrome changed how the selected content string is determined, and options with a value attribute are safe (Chrome for Developers).

Cases where restyling is the wrong call

Touch-first forms often do better with the native picker. Scope the opt-in with @media (pointer: fine) to leave touch devices on it.

Long lists that need search are another case, and so are forms where the design gains nothing from a custom picker, since leaving appearance: auto costs nothing.

No CSS adds search or filtering. That is a library job.

Should you use a native select or a JavaScript replacement?

Use the native select unless the control needs search, tagging or remote data. A closed-box restyle needs no library, and the opt-in route covers a styled picker.

OptionWhat you can styleJavaScriptSupport and accessibility
Classic select, appearance: noneClosed box and arrowNoneEvery browser, native keyboard and roles
Customizable select, appearance: base-selectBox, picker, options, checkmark, arrowNoneLimited availability, falls back to classic select
Library (Tom Select, Choices.js, Select2)Whatever the library exposesRequiredSame look everywhere, accessibility depends on the library

What the libraries cost

Tom Select weighs about 16kb gzipped (project site) and is framework agnostic. It was forked from selectize.js to drop jQuery, and it adds autocomplete and native-feeling keyboard navigation.

Choices.js comes in at about 20kb gzipped (project README) with no jQuery dependency. Setting labelId adds aria-labelledby to the generated element.

Select2 is built on jQuery, with search, tagging, remote data and over 40 built-in languages. Its documentation also lists Internet Explorer 11 support.

How to pick

If only the closed field needs branding, stay with a classic select and skip the library. If the picker has to match the design and a fallback is acceptable, use base-select behind @supports. Searching long lists, creating tags or loading options from a server is where a library earns its place.

MDN’s legacy advice for full control points to a library or a hand-built dropdown. The opt-in route now adds a third path, and for plain styling it is the cheaper one.

Libraries shift accessibility from the browser to a package, so budget screen reader and keyboard testing as part of your wider web accessibility work.

CSS Select FAQ

How do you style a select with the multiple attribute?

Treat it as a scrolling list box, not a dropdown. Style the select itself with border, padding, font and height, and set size to control how many rows show (the default is 4). MDN covers the base-select version in its customizable select listboxes guide.

Can you style the placeholder option in a select?

Only indirectly. The placeholder is the first option with an empty value, and with the required attribute the select matches :invalid while it is selected. Use select:required:invalid to gray out the closed box, since rules on the option itself are unreliable in a classic select.

How do you make a select as wide as its longest option?

Leave the width unset. A classic select sizes itself to its widest option by default, and a fixed width overrides that. Add max-width: 100% to stop overflow in narrow containers, and check MDN’s field-sizing property if you need more control.

Does a styled select follow dark mode?

Not automatically once appearance: none is set. You own the colors then, so define them as custom properties and swap the values inside a prefers-color-scheme: dark query. For an unstyled select, color-scheme: dark switches the native control and its open list to dark system colors.

Can you animate the select dropdown opening and closing?

Yes, but only the base-select picker. Add a transition on opacity and transform to ::picker(select), define the entry state with @starting-style, and set transition-behavior: allow-discrete so the exit animates too. The open list of a classic select cannot be animated.

Testing a CSS Select Before Release

Test the classic route first, since that styling reaches every visitor. The base-select route comes second, because only Chromium and Safari 27 or later render it.

The order that works best goes from the failures that hide best to the ones that show on screen:

  • Keyboard pass: Tab, arrow keys, visible focus
  • Fallback pass in Firefox and an older Safari
  • Touch pass on a real phone

Keyboard comes first because a missing focus indicator gives no visible sign to mouse users, so nobody on the team notices it by accident. The fallback pass comes second because it covers every browser without base-select.

Firefox visitors, and anyone on an older Safari or Chromium, see the classic select, so build that fallback as the finished product.

This position was verified on 27 October 2026. A stable base-select release in Firefox would shrink the fallback pass to a spot check.