CSS Styled Select Dropdown

Custom <select> appearance using appearance:none and an inline SVG background arrow.

Published May 24, 2026 Beginner 8 min read

A styled CSS select starts with appearance: none, which strips the box and the arrow the operating system draws and leaves an element that accepts a border, a background and a border radius. The replacement arrow is an inline SVG set as a background-image, pushed to the right with background-position, with extra right padding so the selected text never runs underneath it.

What CSS cannot touch is the list that opens when the control is clicked. That popup is drawn by the operating system, outside the page, and it ignores your stylesheet almost entirely. Everything below styles the closed control. Two more versions follow: a placeholder option that grays itself out until a real choice is made, and a wrapper whose arrow rotates on focus, which a background image cannot do.

Two styled selects

HTML

<div class="select-wrap">
  <div>
    <label>Default styled select</label>
    <select>
      <option>Choose an option</option>
      <option>Apple</option>
      <option>Banana</option>
      <option>Cherry</option>
      <option>Date</option>
    </select>
  </div>
  <div>
    <label>Accent border variant</label>
    <select class="select-accent">
      <option>Frontend</option>
      <option>Backend</option>
      <option>Full Stack</option>
      <option>Design</option>
    </select>
  </div>
</div>

CSS

.select-wrap {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  max-width: 320px;
  margin: 0 auto;
}

.select-wrap label {
  display: block;
  margin-bottom: .25rem;
  font-size: .82rem;
  font-weight: 500;
  color: #88888f;
}

select {
  appearance: none;
  -webkit-appearance: none;
  width: 100%;
  /* right padding keeps the text clear of the arrow */
  padding: .7rem 2.75rem .7rem 1rem;
  background-color: #1c1c1e;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2388888f' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 1rem center;
  border: 2px solid #2a2a2d;
  border-radius: 8px;
  color: #f0f0f0;
  font-family: system-ui, sans-serif;
  font-size: .95rem;
  cursor: pointer;
  outline: none;
  transition: border-color .2s;
}

select:hover {
  border-color: #88888f;
}

select:focus {
  border-color: #b8ff57;
}

/* Accent variant: matching border and arrow color. */
select.select-accent {
  border-color: #b8ff57;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23b8ff57' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

Other ways to build it

Placeholder option that grays out until a choice is made

A first option carrying value="" with both disabled and selected acts as a prompt: it shows on load, it cannot be picked again, and its empty value keeps a required select matching :invalid. That gives the stylesheet a state to hang the muted color on, so the prompt looks like placeholder text and a real answer does not. <optgroup> splits the list into labeled sections, which screen readers announce as you move through it. Pick an option and the text brightens.

HTML

<label for="role">Role</label>
<select id="role" class="select-prompt" required>
  <option value="" disabled selected>Choose a role</option>
  <optgroup label="Engineering">
    <option>Frontend</option>
    <option>Backend</option>
  </optgroup>
  <optgroup label="Product">
    <option>Design</option>
  </optgroup>
</select>

CSS

/* required + an empty first value means :invalid until a real choice */
.select-prompt:invalid {
  color: #88888f;
}

.select-prompt:user-valid {
  border-color: #57d9a3;
}

select:focus-visible {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

Arrow on a wrapper, so it can rotate

A <select> is a replaced element and cannot have ::before or ::after, which is why the default arrow has to be a background image. A background layer is not an element, so transform does not apply to it and the arrow cannot turn. Wrapping the select in a span moves the arrow onto a real pseudo-element, and :has(select:focus) lets the wrapper know when to flip it. The arrow needs pointer-events: none so clicks pass through to the control underneath.

HTML

<label for="sort">Sort by</label>
<span class="select-shell">
  <select id="sort">
    <option>Most recent</option>
    <option>Price, low to high</option>
  </select>
</span>

CSS

.select-shell {
  position: relative;
  display: block;
}

/* the wrapper draws the arrow now, so the select drops its own */
.select-shell select {
  background-image: none;
}

.select-shell::after {
  content: "";
  position: absolute;
  right: 1.15rem;
  top: 50%;
  width: 8px;
  height: 8px;
  border-right: 2px solid #88888f;
  border-bottom: 2px solid #88888f;
  transform: translateY(-70%) rotate(45deg);
  transition: transform .2s, border-color .2s;
  /* clicks have to reach the select underneath */
  pointer-events: none;
}

.select-shell:has(select:focus)::after {
  transform: translateY(-30%) rotate(-135deg);
  border-color: #b8ff57;
}

How it works

The native <select> is hard to style. appearance: none, with its -webkit- prefix, removes the browser's default arrow and box. A custom arrow is supplied as an inline SVG through background-image and pushed right with background-position. background-repeat: no-repeat stops it tiling, and the extra right padding keeps the selected text clear of it.

The arrow is a data URI rather than a separate file, which saves a request and lets the color be written into the markup of the SVG itself. Two characters have to be escaped for it to work inside url(): # becomes %23 and < becomes %3C. Leaving the hash unescaped is the usual reason an inline SVG arrow silently fails to appear, because the browser reads everything after it as a fragment identifier.

A <select> cannot have ::before or ::after. It is a replaced element whose rendering belongs to the platform, so generated content has nowhere to go. That constraint is the whole reason the arrow is a background image. It also means the arrow cannot rotate, since transform applies to elements rather than to a background layer. Wrapping the select in a span and drawing the arrow on the wrapper's ::after gets that back, which is what the second variant does.

background-position: right 1rem center uses the four value syntax: an edge, an offset from that edge, then the same for the vertical axis. It keeps the arrow a fixed distance from the right edge whatever the control's width, which the two value form cannot do because percentages there position the image proportionally rather than by distance.

The <option> elements inside the list are largely beyond reach. Chrome and Firefox honour color and background-color on an option to different degrees, Safari mostly ignores both, and none of them lets you change the font, the padding or the row height. Chrome and Edge have started shipping a genuinely styleable select behind appearance: base-select, but with no Firefox or Safari support it is not yet something to build on.

The appearance: none reset here is the same one behind custom checkboxes, custom radio buttons and a toggle switch. The difference is that those controls can be rebuilt completely, because their whole visual is in the page. A select keeps one part that CSS will never own.

CSS properties used

appearance
none removes the native box and arrow. Without it the control ignores background, border and padding in most browsers.
background-image
Carries the replacement arrow as an inline SVG data URI. Remember to escape # as %23 inside url().
background-position
The four value form pins the arrow a fixed distance from the right edge regardless of the control's width.
padding
The right padding reserves the space the arrow sits in. Without it the longest option runs underneath the arrow.
:invalid
With required and an empty first option, it matches until a real choice is made, which is how a placeholder option grays itself out.
:has()
Lets a wrapper react to select:focus, which is what allows an arrow drawn on the wrapper to rotate.

Browser support

FeatureChromeFirefoxSafariEdge
appearance838015.483
::after423.112
transition455.112
:has()10512115.4105
customizable select135NoNo135

Figures come from Can I Use. The -webkit-appearance: none line stays in the stylesheet because Safari needed it until 15.4. The last row is the newer customizable select, which allows the popup itself to be styled through appearance: base-select: Chrome and Edge have it, Firefox and Safari do not, so treat it as a progressive enhancement rather than a solution. Everything else on this page works across all four browsers.

Accessibility notes

The labels in the first demo are floating <label> elements with no for attribute and nothing wrapped inside them, which means they are not attached to anything. A screen reader announces each select as an unlabelled combo box, and clicking the label does not focus the control. Both variants below fix that by giving the select an id and pointing the label at it, which is one attribute and worth adding every time.

A native select is fully keyboard operable and that behavior survives appearance: none untouched: the arrow keys move through options, typing jumps to a matching entry, and Enter confirms. That is the strongest argument for restyling the native control rather than building a listbox out of div elements, where all of it has to be recreated and rarely is.

The base rule sets outline: none and shows focus only as a border color change. That is a weak indicator, and it is the same border already carrying the hover and accent states. This page's stylesheet adds a separate :focus-visible outline with an offset so keyboard focus is unmistakable and does not compete with the other border colors.

A disabled and selected first option is the accessible way to build a placeholder. <option value="" disabled selected> cannot be chosen again once the reader has picked something real, and because it has an empty value a required select stays invalid until a proper choice is made. Using a normal option as a prompt lets somebody submit the prompt itself as an answer.

Keep the arrow decorative. It is a background image, so it is never announced, which is correct: the control already tells assistive technology it is a combo box. Adding an aria-labeled icon on top of it would only produce a second, redundant announcement.

What you can build with it

  • Country and currency pickers. Long lists where a radio group would be unusable and the native popup's type-to-jump behavior is worth keeping.
  • Filter and sort controls. Sort by price, newest, rating. A compact control that has to match the surrounding interface.
  • Settings with grouped choices. <optgroup> splits a long list into labeled sections, and the grouping is announced by screen readers.
  • Quantity and size pickers on product pages. Short lists where a select stays out of the way until it is needed.
  • Forms that must work without script. Nothing here needs JavaScript, so the control behaves identically whether or not a bundle loaded.

Mistakes worth avoiding

  • Forgetting to escape # as %23 inside the SVG data URI. The arrow disappears entirely, with no error anywhere, because the browser treats the rest of the URL as a fragment.
  • Leaving out the right padding. The longest option's text runs under the arrow, which only shows up once real data is in the list.
  • Expecting to style the open dropdown. That popup belongs to the operating system. Font, row height and padding are not yours to set, and the options themselves accept color inconsistently across browsers.
  • Writing a label with no for attribute. It looks correct and is announced as nothing, so the select arrives as an unnamed combo box.
  • Setting outline: none without a replacement. Keyboard focus becomes a subtle border color change that is easy to miss, especially where hover uses the border too.

Frequently asked questions

Can I style the dropdown list of a select with CSS?
Only barely, and not consistently. The open list is drawn by the operating system rather than by the page. color and background-color on an <option> are partly honoured in Chrome and Firefox and largely ignored in Safari, and font, padding and row height are not available at all. Chrome and Edge now offer appearance: base-select for a fully styleable popup, but Firefox and Safari do not support it.
Why is my custom select arrow not showing?
Check the data URI. A # in a color value has to be written as %23, otherwise everything after it is read as a URL fragment and the image fails to load without any error. Also confirm appearance: none is applied, since the native arrow otherwise sits on top of yours.
How do I add a placeholder to a select?
Make the first option <option value="" disabled selected>. It shows on load, cannot be chosen again, and its empty value keeps a required select matching :invalid, which is what lets you gray the text until a real choice is made.
Should I build a custom dropdown with divs instead?
Only if you genuinely need styled options, and then expect to write the keyboard behavior yourself: arrow keys, Home and End, type ahead, Escape to close, focus management and the correct ARIA roles. A native select has all of that already and works on touch devices where a custom listbox usually does not.
Does appearance: none break the select on mobile?
No. Mobile browsers open their own picker, a wheel on iOS and a list on Android, and neither is affected by the styling on the closed control. What you style is only what the reader sees before they tap it.