Skip to main content

Custom Select Dropdowns and File Inputs

Select dropdowns and file inputs are the hardest native form controls to style — they're partially or fully controlled by the OS. This guide covers the full range from simple CSS overrides to fully custom implementations.

1. Select Dropdown — CSS Approach

The <select> can be partially styled with CSS. Key: set appearance: none to remove the native arrow, then add your own.

<div class="select-wrapper">
<select class="form-select" id="country" name="country">
<option value="">— Select Country —</option>
<option value="us">United States</option>
<option value="gb">United Kingdom</option>
<option value="au">Australia</option>
<option value="id">Indonesia</option>
</select>
<span class="select-arrow" aria-hidden="true"></span>
</div>
.select-wrapper {
position: relative;
display: block;
}

.form-select {
/* Reset native styling */
appearance: none;
-webkit-appearance: none;

/* Layout */
display: block;
width: 100%;
padding: 0.7rem 2.5rem 0.7rem 1rem; /* Extra right padding for arrow */

/* Visual */
background: var(--color-surface, #1a1a1a);
border: 1.5px solid var(--color-border, rgba(255,255,255,0.08));
border-radius: var(--radius-md, 8px);
color: var(--color-text, #f0f0f0);
font-family: var(--font-body, system-ui);
font-size: var(--text-base, 1rem);
line-height: 1.5;
cursor: pointer;

/* Transition */
transition: border-color 0.2s, box-shadow 0.2s;
}

.form-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.2);
}

.form-select:disabled { opacity: 0.5; cursor: not-allowed; }

/* Custom arrow indicator */
.select-arrow {
position: absolute;
right: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
pointer-events: none; /* Click passes through to the select */
font-size: 0.75rem;
transition: transform 0.2s;
}

/* Rotate arrow when select is open (modern CSS) */
.form-select:focus + .select-arrow { transform: translateY(-50%) rotate(180deg); }

2. Select with Background Arrow (CSS-Only, No Span Needed)

A cleaner approach using background-image:

.form-select--clean {
appearance: none;
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='%23888' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 12px;
padding-right: 2.5rem;
}

3. Fully Custom Dropdown (JS Required)

For complete control over the appearance (including styling <option> elements), you need a custom implementation:

<div class="dropdown" id="custom-select">
<button class="dropdown__trigger" aria-haspopup="listbox" aria-expanded="false">
<span class="dropdown__value">Select an option</span>
<span aria-hidden="true"></span>
</button>
<ul class="dropdown__menu" role="listbox">
<li class="dropdown__option" role="option" data-value="a">Option A</li>
<li class="dropdown__option" role="option" data-value="b">Option B</li>
<li class="dropdown__option" role="option" data-value="c">Option C</li>
</ul>
</div>
.dropdown { position: relative; width: 100%; }

.dropdown__trigger {
display: flex; align-items: center; justify-content: space-between;
width: 100%;
padding: 0.7rem 1rem;
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: var(--text-base);
cursor: pointer;
transition: border-color 0.2s;
}
.dropdown__trigger:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.dropdown[data-open] .dropdown__trigger { border-color: var(--color-primary); }

.dropdown__menu {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.4));
list-style: none;
padding: 0.25rem;
z-index: 200;
max-height: 240px;
overflow-y: auto;

/* Hidden by default */
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
}
.dropdown[data-open] .dropdown__menu {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}

.dropdown__option {
padding: 0.6rem 0.875rem;
border-radius: calc(var(--radius-md) - 2px);
cursor: pointer;
transition: background 0.15s;
}
.dropdown__option:hover,
.dropdown__option[aria-selected="true"] { background: rgba(108, 99, 255, 0.15); color: var(--color-primary); }

4. File Input Styling

File inputs are extremely difficult to style natively. The best approach: hide the native input, style a <label> as the button.

<div class="file-upload">
<label class="file-upload__btn" for="avatar">
<span>📎 Choose File</span>
<input class="file-upload__input" type="file" id="avatar" accept="image/*">
</label>
<span class="file-upload__name" id="file-name">No file chosen</span>
</div>
/* Hide the native input */
.file-upload__input {
position: absolute;
opacity: 0;
width: 0.1px;
height: 0.1px;
overflow: hidden;
}

/* Style the label as the button */
.file-upload__btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.25rem;
background: var(--color-surface);
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text-muted);
transition: border-color 0.2s, color 0.2s;
}
.file-upload__btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.file-upload__input:focus-visible + .file-upload__btn {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}

/* File name text */
.file-upload__name {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
// Show selected file name
document.querySelector('.file-upload__input').addEventListener('change', function() {
const name = this.files[0]?.name || 'No file chosen';
document.getElementById('file-name').textContent = name;
});

5. Drag and Drop Upload Zone

<div class="drop-zone" id="drop-zone">
<div class="drop-zone__content">
<span class="drop-zone__icon">☁️</span>
<p><strong>Drag files here</strong> or <label for="file-dnd" class="drop-zone__browse">browse</label></p>
<p class="drop-zone__hint">PNG, JPG, WebP up to 10MB</p>
</div>
<input type="file" id="file-dnd" class="drop-zone__input" accept="image/*" multiple>
</div>
.drop-zone {
position: relative;
padding: 3rem 2rem;
text-align: center;
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
background: transparent;
transition: border-color 0.2s, background 0.2s;
cursor: pointer;
}
.drop-zone.is-active,
.drop-zone:focus-within {
border-color: var(--color-primary);
background: rgba(108, 99, 255, 0.05);
}
.drop-zone__input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.drop-zone__icon { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; }
.drop-zone__browse { color: var(--color-primary); text-decoration: underline; cursor: pointer; }
.drop-zone__hint { font-size: var(--text-sm); color: var(--color-text-muted); margin-top: 0.25rem; }

6. Number Input (Custom Stepper)

/* Remove default arrows */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; }
input[type="number"] { -moz-appearance: textfield; }

/* Custom stepper wrapper */
.number-input { display: flex; align-items: center; border: 1.5px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; }
.number-input__btn { padding: 0.6rem 1rem; background: var(--color-surface-2, #242424); color: var(--color-text); cursor: pointer; border: none; font-size: 1.25rem; line-height: 1; transition: background 0.15s; }
.number-input__btn:hover { background: var(--color-primary); color: white; }
.number-input input { border: none; text-align: center; width: 60px; padding: 0.6rem 0; background: var(--color-surface); color: var(--color-text); }