Sort Dropdown — Free HTML CSS JS Custom Sort By Menu Snippet
Sort Dropdown · Forms · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="sort-wrap">
<button class="sort-trigger" id="sortTrigger" aria-haspopup="listbox" aria-expanded="false" onclick="toggleSortMenu()">
<span id="sortLabel">Sort: Relevance</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<ul class="sort-menu" id="sortMenu" role="listbox" aria-label="Sort options">
<li role="option" aria-selected="true" data-value="Relevance" onclick="selectSort(this)">
<span class="check">✓</span> Relevance
</li>
<li role="option" aria-selected="false" data-value="Price: Low to High" onclick="selectSort(this)">
<span class="check"></span> Price: Low to High
</li>
<li role="option" aria-selected="false" data-value="Price: High to Low" onclick="selectSort(this)">
<span class="check"></span> Price: High to Low
</li>
<li role="option" aria-selected="false" data-value="Newest" onclick="selectSort(this)">
<span class="check"></span> Newest
</li>
</ul>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; padding: 60px; display: flex; align-items: center; flex-direction: column; gap: 20px; justify-content: center; min-height: 100vh; }
.sort-wrap { position: relative; display: inline-block; }
.sort-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s;
}
.sort-trigger:hover { border-color: #94a3b8; }
.sort-trigger svg { transition: transform 0.15s; color: #94a3b8; }
.sort-trigger[aria-expanded="true"] svg { transform: rotate(180deg); }
.sort-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 220px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(15,23,42,0.12);
list-style: none;
padding: 6px;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
z-index: 20;
}
.sort-menu.open { opacity: 1; transform: translateY(0); pointer-events: auto; }
.sort-menu li {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
font-size: 13px;
color: #475569;
border-radius: 7px;
cursor: pointer;
}
.sort-menu li:hover { background: #f1f5f9; }
.sort-menu li[aria-selected="true"] { color: #6366f1; font-weight: 600; }
.check { width: 14px; color: #6366f1; font-weight: 700; }const trigger = document.getElementById('sortTrigger');
const menu = document.getElementById('sortMenu');
const label = document.getElementById('sortLabel');
function toggleSortMenu() {
const isOpen = menu.classList.toggle('open');
trigger.setAttribute('aria-expanded', String(isOpen));
}
function selectSort(item) {
document.querySelectorAll('.sort-menu li').forEach(li => {
li.setAttribute('aria-selected', 'false');
li.querySelector('.check').textContent = '';
});
item.setAttribute('aria-selected', 'true');
item.querySelector('.check').textContent = '✓';
label.textContent = 'Sort: ' + item.dataset.value;
toggleSortMenu();
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.sort-wrap')) {
menu.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
menu.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
}
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Sort Dropdown — HTML, CSS & JavaScript Custom Sort Menu

A native <select> is accessible and simple, but it can't be styled beyond its trigger — the dropdown list itself renders using the operating system's own styles, which breaks visual consistency on a polished product page. This snippet replaces it with a fully custom "Sort by" dropdown: a styled trigger button and an absolutely-positioned menu of radio-style options, each showing a checkmark next to the currently active choice.
How the open/close state works
The trigger button toggles an .open class on the .sort-menu and mirrors that state in aria-expanded on the button itself. The menu is always present in the DOM (not conditionally rendered) but hidden via opacity: 0, a slight translateY(-6px) offset, and pointer-events: none when closed — this lets the open/close transition animate smoothly, since a menu that's actually removed from the DOM (display: none) can't transition.
How the active option and checkmark stay in sync
Each <li> carries role="option" and aria-selected. Clicking an option calls selectSort(item), which first resets every option's aria-selected to "false" and clears its checkmark span, then sets the clicked item's aria-selected to "true" and fills in its checkmark character. The trigger's own label text is updated from item.dataset.value, so the visible button always reflects the true selected state stored on the DOM.
How click-outside and Escape-to-close work
A single click listener on document checks whether the click target is inside .sort-wrap using e.target.closest('.sort-wrap'). If not, the menu closes — this is the standard way to implement "click anywhere else closes the dropdown" without attaching a listener to every possible outside element. A parallel keydown listener closes the menu on Escape, which is expected behavior for any custom popup menu and is not something a native <select> needs since the browser already handles it.
Why not just use <select>
The native element remains the more accessible *default* choice for simple use cases. This custom version is for when a design system needs the dropdown menu itself — its spacing, checkmarks, hover states, and animation — to be fully brand-consistent, at the cost of reimplementing keyboard and outside-click handling yourself.
Step by step
How to Use
- 1Load the snippetClick "Sort Dropdown" in the sidebar Library tab to load the custom sort menu.
- 2Open and selectClick the trigger in the preview, then click a sort option — notice the checkmark moves and the trigger label updates.
- 3Test click-outside and EscapeOpen the menu, then click elsewhere on the page or press Escape to confirm it closes both ways.
- 4Add or rename optionsAdd a new <li role="option"> with a data-value attribute in the HTML panel — selectSort works for any number of options automatically.
- 5Wire to real sorting logicInside selectSort, add a call to your actual list-sorting function using item.dataset.value to know which sort order was chosen.
- 6Export and saveExport as HTML/JSX/Tailwind or click "Save as" to reuse this component in your product listing UI.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
A native select cannot be styled beyond its closed trigger — the open option list uses the operating system's own rendering. A custom dropdown gives full control over spacing, hover states, checkmarks, and animation to match a design system exactly.
selectSort first clears the checkmark text and aria-selected on every option, then sets aria-selected to true and writes a checkmark character into only the clicked option's check span.
A single click listener on the whole document checks whether the click target is inside the .sort-wrap container using e.target.closest('.sort-wrap'). If the click landed outside that container, the menu's open class is removed.
Keeping the element in the DOM and toggling opacity/transform lets the open and close transitions animate smoothly. An element removed from the DOM (or set to display:none) cannot transition, so it would just appear and disappear instantly.
The trigger has aria-haspopup and aria-expanded, the menu has role="listbox", and each option has role="option" and aria-selected, which mirrors native select semantics closely enough for most screen readers to announce state changes correctly. Escape closes the menu as expected.
Add a new <li role="option" aria-selected="false" data-value="Your Label"> with a .check span inside the sort-menu <ul>. selectSort works for any number of options without changes.
Add your own function call inside selectSort, using item.dataset.value to identify which sort order was picked, and call it after updating the label — e.g. applySortOrder(item.dataset.value).