UI Components Medium
Custom Select
Fully custom select dropdown with search/filter, option groups, multi-select with tags, and clearable single selection.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #050910;
--card: #0d1117;
--card2: #111827;
--border: rgba(255, 255, 255, 0.08);
--text: #f2f6ff;
--muted: #475569;
--accent: #38bdf8;
--accent-bg: rgba(56, 189, 248, 0.14);
--radius: 12px;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
padding: 3rem 1.5rem;
}
.demo {
max-width: 480px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.demo-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.demo-sub {
font-size: 0.875rem;
color: var(--muted);
margin-bottom: 1rem;
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-label {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--muted);
}
/* โโ Wrapper โโ */
.cs-wrap {
position: relative;
}
/* โโ Trigger โโ */
.cs-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: var(--card);
border: 1.5px solid var(--border);
color: var(--text);
padding: 0.6rem 0.875rem;
border-radius: 10px;
font-size: 0.875rem;
cursor: pointer;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
user-select: none;
}
.cs-trigger:hover,
.cs-trigger:focus {
border-color: rgba(255, 255, 255, 0.18);
}
.cs-wrap.open .cs-trigger,
.cs-trigger[aria-expanded="true"] {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12);
}
.cs-value {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cs-value:empty::before {
content: attr(data-placeholder);
color: var(--muted);
}
.cs-arrow {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--muted);
transition: transform 0.2s;
}
.cs-wrap.open .cs-arrow {
transform: rotate(180deg);
}
/* โโ Multi-trigger โโ */
.cs-trigger--multi {
flex-wrap: wrap;
gap: 0.35rem;
min-height: 2.5rem;
padding: 0.35rem 0.875rem;
align-items: center;
cursor: text;
}
.cs-tag-input {
flex: 1;
min-width: 80px;
background: none;
border: none;
color: var(--text);
font-size: 0.875rem;
outline: none;
padding: 0;
}
.cs-tag-input::placeholder {
color: var(--muted);
}
.cs-tags {
display: contents;
}
.cs-tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: var(--accent-bg);
color: var(--accent);
border: 1px solid rgba(56, 189, 248, 0.25);
border-radius: 6px;
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
font-weight: 500;
white-space: nowrap;
}
.cs-tag-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 0.9rem;
line-height: 1;
opacity: 0.7;
transition: opacity 0.15s;
}
.cs-tag-remove:hover {
opacity: 1;
}
/* โโ Dropdown โโ */
.cs-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--card2);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
z-index: 200;
overflow: hidden;
display: none;
animation: cs-in 0.13s ease;
}
@keyframes cs-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cs-wrap.open .cs-dropdown {
display: block;
}
/* โโ Search โโ */
.cs-search-wrap {
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
.cs-search {
width: 100%;
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 0.45rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
outline: none;
}
.cs-search:focus {
border-color: var(--accent);
}
/* โโ List โโ */
.cs-list {
list-style: none;
max-height: 220px;
overflow-y: auto;
padding: 0.375rem;
}
.cs-list::-webkit-scrollbar {
width: 4px;
}
.cs-list::-webkit-scrollbar-track {
background: transparent;
}
.cs-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
.cs-group-label {
padding: 0.35rem 0.75rem 0.2rem;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.cs-option {
padding: 0.5rem 0.75rem;
border-radius: 7px;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.12s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.cs-option:hover,
.cs-option.focused {
background: rgba(255, 255, 255, 0.05);
}
.cs-option.selected {
background: var(--accent-bg);
color: var(--accent);
}
.cs-option[hidden] {
display: none;
}
.cs-empty {
padding: 0.75rem;
text-align: center;
font-size: 0.85rem;
color: var(--muted);
}(function () {
"use strict";
// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function closeAll(except) {
document.querySelectorAll(".cs-wrap.open").forEach(function (w) {
if (w !== except) closeWrap(w);
});
}
function closeWrap(wrap) {
wrap.classList.remove("open");
const trigger = wrap.querySelector(".cs-trigger");
if (trigger) trigger.setAttribute("aria-expanded", "false");
}
function openWrap(wrap) {
closeAll(wrap);
wrap.classList.add("open");
const trigger = wrap.querySelector(".cs-trigger");
if (trigger) trigger.setAttribute("aria-expanded", "true");
}
// โโ Single / Grouped select โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function initSingle(wrap) {
const trigger = wrap.querySelector(".cs-trigger");
const valueEl = wrap.querySelector(".cs-value");
const search = wrap.querySelector(".cs-search");
const list = wrap.querySelector(".cs-list");
const empty = wrap.querySelector(".cs-empty");
const placeholder = wrap.dataset.placeholder || "Selectโฆ";
valueEl.dataset.placeholder = placeholder;
function toggle() {
if (wrap.classList.contains("open")) {
closeWrap(wrap);
} else {
openWrap(wrap);
if (search) {
search.value = "";
filterOptions();
search.focus();
}
}
}
function filterOptions() {
const q = search ? search.value.toLowerCase() : "";
let visible = 0;
wrap.querySelectorAll(".cs-option").forEach(function (opt) {
const match = opt.textContent.toLowerCase().includes(q);
opt.hidden = !match;
if (match) visible++;
});
if (empty) empty.hidden = visible > 0;
}
function selectOption(opt) {
wrap.querySelectorAll(".cs-option").forEach(function (o) {
o.classList.remove("selected");
});
opt.classList.add("selected");
valueEl.textContent = opt.textContent.trim();
closeWrap(wrap);
}
function moveFocus(dir) {
const opts = Array.from(wrap.querySelectorAll(".cs-option:not([hidden])"));
const focused = wrap.querySelector(".cs-option.focused");
const idx = focused ? opts.indexOf(focused) : -1;
const next = opts[Math.max(0, Math.min(opts.length - 1, idx + dir))];
if (focused) focused.classList.remove("focused");
if (next) {
next.classList.add("focused");
next.scrollIntoView({ block: "nearest" });
}
}
trigger.addEventListener("click", toggle);
trigger.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
if (e.key === "Escape") closeWrap(wrap);
if (e.key === "ArrowDown") {
e.preventDefault();
if (!wrap.classList.contains("open")) openWrap(wrap);
moveFocus(1);
}
if (e.key === "ArrowUp") {
e.preventDefault();
moveFocus(-1);
}
});
if (search) {
search.addEventListener("input", filterOptions);
search.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") {
e.preventDefault();
moveFocus(1);
}
if (e.key === "ArrowUp") {
e.preventDefault();
moveFocus(-1);
}
if (e.key === "Escape") closeWrap(wrap);
if (e.key === "Enter") {
const focused = wrap.querySelector(".cs-option.focused");
if (focused) selectOption(focused);
}
});
}
list.addEventListener("click", function (e) {
const opt = e.target.closest(".cs-option");
if (opt) selectOption(opt);
});
list.addEventListener("mousemove", function (e) {
const opt = e.target.closest(".cs-option");
if (!opt) return;
wrap.querySelectorAll(".cs-option.focused").forEach(function (o) {
o.classList.remove("focused");
});
opt.classList.add("focused");
});
}
// โโ Multi-select โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function initMulti(wrap) {
const trigger = wrap.querySelector(".cs-trigger--multi");
const tagsEl = wrap.querySelector(".cs-tags");
const tagInput = wrap.querySelector(".cs-tag-input");
const list = wrap.querySelector(".cs-list");
const empty = wrap.querySelector(".cs-empty");
const selected = new Set();
function toggle() {
if (wrap.classList.contains("open")) {
closeWrap(wrap);
} else {
openWrap(wrap);
tagInput.focus();
}
}
function filterOptions() {
const q = tagInput.value.toLowerCase();
let visible = 0;
wrap.querySelectorAll(".cs-option").forEach(function (opt) {
const match = opt.textContent.toLowerCase().includes(q);
opt.hidden = !match;
if (match) visible++;
});
if (empty) empty.hidden = visible > 0;
}
function renderTags() {
tagsEl.innerHTML = "";
selected.forEach(function (val) {
const opt = wrap.querySelector('.cs-option[data-value="' + val + '"]');
if (!opt) return;
const tag = document.createElement("span");
tag.className = "cs-tag";
const label = opt.textContent.trim();
tag.innerHTML =
label +
' <button class="cs-tag-remove" data-val="' +
val +
'" aria-label="Remove ' +
label +
'">\xd7</button>';
tagsEl.appendChild(tag);
});
wrap.querySelectorAll(".cs-option").forEach(function (opt) {
opt.classList.toggle("selected", selected.has(opt.dataset.value));
});
tagInput.placeholder = selected.size === 0 ? wrap.dataset.placeholder || "Pick\u2026" : "";
}
trigger.addEventListener("click", function (e) {
if (e.target.closest(".cs-tag-remove")) return;
toggle();
});
tagsEl.addEventListener("click", function (e) {
const btn = e.target.closest(".cs-tag-remove");
if (!btn) return;
selected.delete(btn.dataset.val);
renderTags();
});
tagInput.addEventListener("input", filterOptions);
tagInput.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeWrap(wrap);
if (e.key === "Backspace" && tagInput.value === "" && selected.size > 0) {
const last = Array.from(selected).pop();
selected.delete(last);
renderTags();
}
});
list.addEventListener("click", function (e) {
const opt = e.target.closest(".cs-option");
if (!opt) return;
const val = opt.dataset.value;
if (selected.has(val)) {
selected.delete(val);
} else {
selected.add(val);
}
tagInput.value = "";
filterOptions();
renderTags();
tagInput.focus();
});
renderTags();
}
// โโ Init โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
document.querySelectorAll(".cs-wrap").forEach(function (wrap) {
if (wrap.classList.contains("cs-multi")) {
initMulti(wrap);
} else {
initSingle(wrap);
}
});
document.addEventListener("click", function (e) {
if (!e.target.closest(".cs-wrap")) closeAll(null);
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom Select</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h1 class="demo-title">Custom Select</h1>
<p class="demo-sub">Searchable, grouped, and multi-select with tags.</p>
<!-- Basic searchable -->
<section class="section">
<p class="section-label">Searchable</p>
<div class="cs-wrap" id="cs-basic" data-placeholder="Choose a frameworkโฆ">
<div class="cs-trigger" tabindex="0" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-autocomplete="list">
<span class="cs-value"></span>
<svg class="cs-arrow" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.17l3.71-3.94a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"/></svg>
</div>
<div class="cs-dropdown" role="listbox">
<div class="cs-search-wrap">
<input class="cs-search" type="text" placeholder="Searchโฆ" autocomplete="off" />
</div>
<ul class="cs-list">
<li class="cs-option" data-value="react">React</li>
<li class="cs-option" data-value="vue">Vue</li>
<li class="cs-option" data-value="svelte">Svelte</li>
<li class="cs-option" data-value="angular">Angular</li>
<li class="cs-option" data-value="solid">SolidJS</li>
<li class="cs-option" data-value="qwik">Qwik</li>
<li class="cs-option" data-value="astro">Astro</li>
</ul>
<p class="cs-empty" hidden>No results</p>
</div>
</div>
</section>
<!-- Grouped options -->
<section class="section">
<p class="section-label">With groups</p>
<div class="cs-wrap" id="cs-grouped" data-placeholder="Select a languageโฆ">
<div class="cs-trigger" tabindex="0" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-autocomplete="list">
<span class="cs-value"></span>
<svg class="cs-arrow" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.17l3.71-3.94a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"/></svg>
</div>
<div class="cs-dropdown" role="listbox">
<div class="cs-search-wrap">
<input class="cs-search" type="text" placeholder="Searchโฆ" autocomplete="off" />
</div>
<ul class="cs-list">
<li class="cs-group-label">Frontend</li>
<li class="cs-option" data-value="js">JavaScript</li>
<li class="cs-option" data-value="ts">TypeScript</li>
<li class="cs-option" data-value="css">CSS</li>
<li class="cs-group-label">Backend</li>
<li class="cs-option" data-value="py">Python</li>
<li class="cs-option" data-value="go">Go</li>
<li class="cs-option" data-value="rust">Rust</li>
<li class="cs-group-label">Database</li>
<li class="cs-option" data-value="pg">PostgreSQL</li>
<li class="cs-option" data-value="mongo">MongoDB</li>
</ul>
<p class="cs-empty" hidden>No results</p>
</div>
</div>
</section>
<!-- Multi-select with tags -->
<section class="section">
<p class="section-label">Multi-select</p>
<div class="cs-wrap cs-multi" id="cs-multi" data-placeholder="Pick tagsโฆ">
<div class="cs-trigger cs-trigger--multi" tabindex="0" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-multiselectable="true">
<div class="cs-tags"></div>
<input class="cs-tag-input" type="text" placeholder="Pick tagsโฆ" autocomplete="off" />
<svg class="cs-arrow" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.17l3.71-3.94a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"/></svg>
</div>
<div class="cs-dropdown" role="listbox">
<ul class="cs-list">
<li class="cs-option" data-value="design">Design</li>
<li class="cs-option" data-value="animation">Animation</li>
<li class="cs-option" data-value="gsap">GSAP</li>
<li class="cs-option" data-value="threejs">Three.js</li>
<li class="cs-option" data-value="react">React</li>
<li class="cs-option" data-value="astro">Astro</li>
<li class="cs-option" data-value="webgl">WebGL</li>
<li class="cs-option" data-value="shader">Shader</li>
</ul>
<p class="cs-empty" hidden>No results</p>
</div>
</div>
</section>
</div>
<script src="script.js"></script>
</body>
</html>Custom Select
Replace the browserโs native <select> with a fully styled, searchable, keyboard-navigable dropdown.
Variants
- Searchable single select โ type to filter options
- Option groups โ options organized under group labels
- Multi-select โ select multiple, displays as removable tag chips
Keyboard support
โ โ navigate options ยท Enter selects ยท Escape closes ยท Tab closes and moves on