22 / 22
All 22 in 22 Tailwind Dropdown Menus
- 01Tailwind Slide-Down Fade Dropdown Menu
- 02Tailwind Clip-Path Curtain Reveal Dropdown
- 03Tailwind 3D Perspective Flip Dropdown
- 04Tailwind Elastic Bounce Dropdown Menu
- 05Tailwind Glassmorphism Float Panel Dropdown
- 06Tailwind Stagger Children Reveal Dropdown
- 07Tailwind Mega Menu Grid Dropdown
- 08Tailwind Underline Nav Fade Panel Dropdown
- 09Tailwind Details Summary Native Dropdown
- 10Tailwind Checkbox Hack Mobile Nav Dropdown
- 11Tailwind Nested Multi-Level Dropdown
- 12Tailwind Icon Sidebar Flyout Dropdown
- 13Tailwind Split-Screen Mega Menu Dropdown
- 14Tailwind Segmented Pill Nav Dropdown
- 15Tailwind Color Swatch Picker Dropdown
- 16Tailwind Right-Click Context Menu Dropdown
- 17Tailwind Keyboard Accessible ARIA Dropdown
- 18Tailwind Stagger Blur Entrance Dropdown
- 19Tailwind Command Palette Search Dropdown
- 20Tailwind Autocomplete Suggestion Dropdown
- 21Tailwind Tab Panel Dropdown
- 22Tailwind Custom Animated Select Dropdown
Tailwind Custom Animated Select Dropdown
A styled replacement for <select>, with the real element still in the DOM for form submission. Options stagger in on an overshoot curve, selection is aria-selected, open state is aria-expanded — three declarative states, and a script that only moves values between them.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tdd-22 w-full min-h-svh grid place-items-center bg-linear-[135deg,var(--color-tdd22-bg1),var(--color-tdd22-bg2)_60%,var(--color-tdd22-bg3)] font-tdd22-sans text-tdd22-ink px-5 py-10" aria-label="Custom animated select dropdown demo">
<div class="flex w-[min(360px,100%)] flex-col gap-3 rounded-[20px] border border-tdd22-edge bg-white px-6 py-7 shadow-[0_8px_40px_oklch(0.75_0.16_70/0.15)]">
<div>
<p class="text-xl font-bold tracking-[-0.3px] text-tdd22-ink">Choose your plan</p>
<p class="mt-1 text-[13px] text-tdd22-mut">Select the option that fits your needs</p>
</div>
<label for="tdd22-native" class="sr-only">Plan</label>
<select id="tdd22-native" tabindex="-1" class="sr-only">
<option value="">Select a plan…</option>
<option value="free">Free — $0/month</option>
<option value="pro">Pro — $12/month</option>
<option value="team">Team — $39/month</option>
<option value="enterprise">Enterprise — Custom</option>
</select>
<div class="group relative z-10">
<button type="button" id="tdd22-btn" aria-haspopup="listbox" aria-expanded="false" aria-controls="tdd22-list" class="flex min-h-11 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border-2 border-tdd22-edge bg-white px-4 py-3 text-left font-tdd22-sans text-[14.5px] font-medium text-tdd22-mut transition-[border-color,box-shadow,color] duration-200 hover:border-tdd22-acc focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd22-acc aria-expanded:border-tdd22-acc aria-expanded:text-tdd22-ink aria-expanded:shadow-[0_0_0_4px_oklch(0.75_0.16_70/0.16)] data-chosen:font-semibold data-chosen:text-tdd22-ink">
<span id="tdd22-val">Select a plan…</span>
<span class="shrink-0 text-sm text-tdd22-mut transition-transform duration-280 ease-tdd22 group-has-aria-expanded:rotate-180" aria-hidden="true">▾</span>
</button>
<ul id="tdd22-list" role="listbox" aria-label="Plan options" tabindex="-1" class="pointer-events-none absolute top-[calc(100%+8px)] right-0 left-0 origin-top scale-y-0 list-none rounded-2xl border border-tdd22-edge bg-white p-1.5 opacity-0 shadow-[0_12px_40px_oklch(0.75_0.16_70/0.18)] transition-[scale,opacity] duration-380 ease-tdd22 group-has-aria-expanded:pointer-events-auto group-has-aria-expanded:scale-y-100 group-has-aria-expanded:opacity-100 motion-reduce:duration-150">
<li role="option" aria-selected="false" data-value="free" style="--i:0" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🆕 Free</span><span class="text-[12.5px] font-medium text-tdd22-mut">$0/month</span></li>
<li role="option" aria-selected="false" data-value="pro" style="--i:1" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🚀 Pro</span><span class="text-[12.5px] font-medium text-tdd22-mut">$12/month</span></li>
<li role="option" aria-selected="false" data-value="team" style="--i:2" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">👥 Team</span><span class="text-[12.5px] font-medium text-tdd22-mut">$39/month</span></li>
<li role="option" aria-selected="false" data-value="enterprise" style="--i:3" class="mt-1 flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] border border-dashed border-tdd22-acc px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-sel data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🏗 Enterprise</span><span class="text-[12.5px] font-medium text-tdd22-mut">Custom</span></li>
</ul>
</div>
<button type="button" id="tdd22-submit" class="mt-1 min-h-11 w-full cursor-pointer rounded-xl border-0 bg-tdd22-acc px-3 py-3 font-tdd22-sans text-[15px] font-bold text-white transition-[background,translate] duration-150 hover:-translate-y-px hover:bg-tdd22-accdark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd22-accdark motion-reduce:transition-none">Continue →</button>
<p id="tdd22-status" role="status" class="min-h-4.5 text-center text-[12.5px] italic text-tdd22-mut">No plan selected</p>
</div>
</section><section class="tdd-22 w-full min-h-svh grid place-items-center bg-linear-[135deg,var(--color-tdd22-bg1),var(--color-tdd22-bg2)_60%,var(--color-tdd22-bg3)] font-tdd22-sans text-tdd22-ink px-5 py-10" aria-label="Custom animated select dropdown demo">
<div class="flex w-[min(360px,100%)] flex-col gap-3 rounded-[20px] border border-tdd22-edge bg-white px-6 py-7 shadow-[0_8px_40px_oklch(0.75_0.16_70/0.15)]">
<div>
<p class="text-xl font-bold tracking-[-0.3px] text-tdd22-ink">Choose your plan</p>
<p class="mt-1 text-[13px] text-tdd22-mut">Select the option that fits your needs</p>
</div>
<label for="tdd22-native" class="sr-only">Plan</label>
<select id="tdd22-native" tabindex="-1" class="sr-only">
<option value="">Select a plan…</option>
<option value="free">Free — $0/month</option>
<option value="pro">Pro — $12/month</option>
<option value="team">Team — $39/month</option>
<option value="enterprise">Enterprise — Custom</option>
</select>
<div class="group relative z-10">
<button type="button" id="tdd22-btn" aria-haspopup="listbox" aria-expanded="false" aria-controls="tdd22-list" class="flex min-h-11 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border-2 border-tdd22-edge bg-white px-4 py-3 text-left font-tdd22-sans text-[14.5px] font-medium text-tdd22-mut transition-[border-color,box-shadow,color] duration-200 hover:border-tdd22-acc focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd22-acc aria-expanded:border-tdd22-acc aria-expanded:text-tdd22-ink aria-expanded:shadow-[0_0_0_4px_oklch(0.75_0.16_70/0.16)] data-chosen:font-semibold data-chosen:text-tdd22-ink">
<span id="tdd22-val">Select a plan…</span>
<span class="shrink-0 text-sm text-tdd22-mut transition-transform duration-280 ease-tdd22 group-has-aria-expanded:rotate-180" aria-hidden="true">▾</span>
</button>
<ul id="tdd22-list" role="listbox" aria-label="Plan options" tabindex="-1" class="pointer-events-none absolute top-[calc(100%+8px)] right-0 left-0 origin-top scale-y-0 list-none rounded-2xl border border-tdd22-edge bg-white p-1.5 opacity-0 shadow-[0_12px_40px_oklch(0.75_0.16_70/0.18)] transition-[scale,opacity] duration-380 ease-tdd22 group-has-aria-expanded:pointer-events-auto group-has-aria-expanded:scale-y-100 group-has-aria-expanded:opacity-100 motion-reduce:duration-150">
<li role="option" aria-selected="false" data-value="free" style="--i:0" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🆕 Free</span><span class="text-[12.5px] font-medium text-tdd22-mut">$0/month</span></li>
<li role="option" aria-selected="false" data-value="pro" style="--i:1" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🚀 Pro</span><span class="text-[12.5px] font-medium text-tdd22-mut">$12/month</span></li>
<li role="option" aria-selected="false" data-value="team" style="--i:2" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">👥 Team</span><span class="text-[12.5px] font-medium text-tdd22-mut">$39/month</span></li>
<li role="option" aria-selected="false" data-value="enterprise" style="--i:3" class="mt-1 flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] border border-dashed border-tdd22-acc px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-sel data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🏗 Enterprise</span><span class="text-[12.5px] font-medium text-tdd22-mut">Custom</span></li>
</ul>
</div>
<button type="button" id="tdd22-submit" class="mt-1 min-h-11 w-full cursor-pointer rounded-xl border-0 bg-tdd22-acc px-3 py-3 font-tdd22-sans text-[15px] font-bold text-white transition-[background,translate] duration-150 hover:-translate-y-px hover:bg-tdd22-accdark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd22-accdark motion-reduce:transition-none">Continue →</button>
<p id="tdd22-status" role="status" class="min-h-4.5 text-center text-[12.5px] italic text-tdd22-mut">No plan selected</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-tdd22-bg1: #fffbeb;
--color-tdd22-bg1: oklch(0.98 0.02 95);
--color-tdd22-bg2: #fef3c7;
--color-tdd22-bg2: oklch(0.95 0.06 92);
--color-tdd22-bg3: #fde68a;
--color-tdd22-bg3: oklch(0.91 0.1 90);
--color-tdd22-ink: #1c1917;
--color-tdd22-ink: oklch(0.22 0.01 60);
--color-tdd22-mut: #78716c;
--color-tdd22-mut: oklch(0.56 0.01 60);
--color-tdd22-edge: #e7e5e4;
--color-tdd22-edge: oklch(0.91 0.005 60);
--color-tdd22-tint: #fffbeb;
--color-tdd22-tint: oklch(0.98 0.02 95);
--color-tdd22-sel: #fef3c7;
--color-tdd22-sel: oklch(0.95 0.06 92);
--color-tdd22-acc: #f59e0b;
--color-tdd22-acc: oklch(0.75 0.16 70);
--color-tdd22-accdark: #d97706;
--color-tdd22-accdark: oklch(0.65 0.15 60);
--font-tdd22-sans: 'Inter', system-ui, sans-serif;
--ease-tdd22: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tdd-22 {
width: 100%;
min-height: 100svh;
}
.tdd-22 ::selection {
background: var(--color-tdd22-acc);
color: #fff;
}@import "tailwindcss";
@theme {
--color-tdd22-bg1: #fffbeb;
--color-tdd22-bg1: oklch(0.98 0.02 95);
--color-tdd22-bg2: #fef3c7;
--color-tdd22-bg2: oklch(0.95 0.06 92);
--color-tdd22-bg3: #fde68a;
--color-tdd22-bg3: oklch(0.91 0.1 90);
--color-tdd22-ink: #1c1917;
--color-tdd22-ink: oklch(0.22 0.01 60);
--color-tdd22-mut: #78716c;
--color-tdd22-mut: oklch(0.56 0.01 60);
--color-tdd22-edge: #e7e5e4;
--color-tdd22-edge: oklch(0.91 0.005 60);
--color-tdd22-tint: #fffbeb;
--color-tdd22-tint: oklch(0.98 0.02 95);
--color-tdd22-sel: #fef3c7;
--color-tdd22-sel: oklch(0.95 0.06 92);
--color-tdd22-acc: #f59e0b;
--color-tdd22-acc: oklch(0.75 0.16 70);
--color-tdd22-accdark: #d97706;
--color-tdd22-accdark: oklch(0.65 0.15 60);
--font-tdd22-sans: 'Inter', system-ui, sans-serif;
--ease-tdd22: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tdd-22 {
width: 100%;
min-height: 100svh;
}
.tdd-22 ::selection {
background: var(--color-tdd22-acc);
color: #fff;
}(function () {
document.querySelectorAll('.tdd-22').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var btn = root.querySelector('#tdd22-btn'),
list = root.querySelector('#tdd22-list'),
val = root.querySelector('#tdd22-val'),
native = root.querySelector('#tdd22-native'),
submit = root.querySelector('#tdd22-submit'),
status = root.querySelector('#tdd22-status');
if (!btn || !list || !native) return;
var opts = Array.from(list.querySelectorAll('[role="option"]'));
var active = -1;
var isOpen = function () { return btn.getAttribute('aria-expanded') === 'true'; };
function highlight(i) {
opts.forEach(function (o) { o.removeAttribute('data-active'); });
if (i < 0 || i >= opts.length) return;
opts[i].setAttribute('data-active', '');
active = i;
}
function setOpen(open) {
btn.setAttribute('aria-expanded', String(open));
if (open) {
var sel = opts.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
highlight(sel > -1 ? sel : 0);
} else { highlight(-1); active = -1; }
}
function choose(opt) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opt)); });
var name = opt.children[0].textContent.trim(), price = opt.children[1].textContent.trim();
val.textContent = name + ' — ' + price;
btn.toggleAttribute('data-chosen', true);
native.value = opt.dataset.value;
native.dispatchEvent(new Event('change', { bubbles: true }));
status.textContent = 'Selected: ' + name + ' (' + price + ')';
setOpen(false);
btn.focus();
}
btn.addEventListener('click', function (e) { e.stopPropagation(); setOpen(!isOpen()); });
btn.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!isOpen()) setOpen(true); else list.focus();
} else if (e.key === 'Escape' && isOpen()) setOpen(false);
});
opts.forEach(function (opt, i) {
opt.addEventListener('click', function () { choose(opt); });
opt.addEventListener('mousemove', function () { highlight(i); });
});
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % opts.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + opts.length) % opts.length); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (active > -1) choose(opts[active]); }
else if (e.key === 'Escape' || e.key === 'Tab') { setOpen(false); btn.focus(); }
});
document.addEventListener('click', function (e) {
if (!btn.contains(e.target) && !list.contains(e.target)) setOpen(false);
});
submit && submit.addEventListener('click', function () {
if (!native.value) {
status.textContent = '⚠ Please select a plan first.';
btn.style.borderColor = 'oklch(0.63 0.22 25)';
setTimeout(function () { btn.style.borderColor = ''; }, 1500);
} else {
status.textContent = '✓ Proceeding with ' + val.textContent;
}
});
});
})();(function () {
document.querySelectorAll('.tdd-22').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var btn = root.querySelector('#tdd22-btn'),
list = root.querySelector('#tdd22-list'),
val = root.querySelector('#tdd22-val'),
native = root.querySelector('#tdd22-native'),
submit = root.querySelector('#tdd22-submit'),
status = root.querySelector('#tdd22-status');
if (!btn || !list || !native) return;
var opts = Array.from(list.querySelectorAll('[role="option"]'));
var active = -1;
var isOpen = function () { return btn.getAttribute('aria-expanded') === 'true'; };
function highlight(i) {
opts.forEach(function (o) { o.removeAttribute('data-active'); });
if (i < 0 || i >= opts.length) return;
opts[i].setAttribute('data-active', '');
active = i;
}
function setOpen(open) {
btn.setAttribute('aria-expanded', String(open));
if (open) {
var sel = opts.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
highlight(sel > -1 ? sel : 0);
} else { highlight(-1); active = -1; }
}
function choose(opt) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opt)); });
var name = opt.children[0].textContent.trim(), price = opt.children[1].textContent.trim();
val.textContent = name + ' — ' + price;
btn.toggleAttribute('data-chosen', true);
native.value = opt.dataset.value;
native.dispatchEvent(new Event('change', { bubbles: true }));
status.textContent = 'Selected: ' + name + ' (' + price + ')';
setOpen(false);
btn.focus();
}
btn.addEventListener('click', function (e) { e.stopPropagation(); setOpen(!isOpen()); });
btn.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!isOpen()) setOpen(true); else list.focus();
} else if (e.key === 'Escape' && isOpen()) setOpen(false);
});
opts.forEach(function (opt, i) {
opt.addEventListener('click', function () { choose(opt); });
opt.addEventListener('mousemove', function () { highlight(i); });
});
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % opts.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + opts.length) % opts.length); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (active > -1) choose(opts[active]); }
else if (e.key === 'Escape' || e.key === 'Tab') { setOpen(false); btn.focus(); }
});
document.addEventListener('click', function (e) {
if (!btn.contains(e.target) && !list.contains(e.target)) setOpen(false);
});
submit && submit.addEventListener('click', function () {
if (!native.value) {
status.textContent = '⚠ Please select a plan first.';
btn.style.borderColor = 'oklch(0.63 0.22 25)';
setTimeout(function () { btn.style.borderColor = ''; }, 1500);
} else {
status.textContent = '✓ Proceeding with ' + val.textContent;
}
});
});
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tdd22-bg1: #fffbeb;
--color-tdd22-bg1: oklch(0.98 0.02 95);
--color-tdd22-bg2: #fef3c7;
--color-tdd22-bg2: oklch(0.95 0.06 92);
--color-tdd22-bg3: #fde68a;
--color-tdd22-bg3: oklch(0.91 0.1 90);
--color-tdd22-ink: #1c1917;
--color-tdd22-ink: oklch(0.22 0.01 60);
--color-tdd22-mut: #78716c;
--color-tdd22-mut: oklch(0.56 0.01 60);
--color-tdd22-edge: #e7e5e4;
--color-tdd22-edge: oklch(0.91 0.005 60);
--color-tdd22-tint: #fffbeb;
--color-tdd22-tint: oklch(0.98 0.02 95);
--color-tdd22-sel: #fef3c7;
--color-tdd22-sel: oklch(0.95 0.06 92);
--color-tdd22-acc: #f59e0b;
--color-tdd22-acc: oklch(0.75 0.16 70);
--color-tdd22-accdark: #d97706;
--color-tdd22-accdark: oklch(0.65 0.15 60);
--font-tdd22-sans: 'Inter', system-ui, sans-serif;
--ease-tdd22: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tdd-22 {
width: 100%;
min-height: 100svh;
}
.tdd-22 ::selection {
background: var(--color-tdd22-acc);
color: #fff;
}@import "tailwindcss";
@theme {
--color-tdd22-bg1: #fffbeb;
--color-tdd22-bg1: oklch(0.98 0.02 95);
--color-tdd22-bg2: #fef3c7;
--color-tdd22-bg2: oklch(0.95 0.06 92);
--color-tdd22-bg3: #fde68a;
--color-tdd22-bg3: oklch(0.91 0.1 90);
--color-tdd22-ink: #1c1917;
--color-tdd22-ink: oklch(0.22 0.01 60);
--color-tdd22-mut: #78716c;
--color-tdd22-mut: oklch(0.56 0.01 60);
--color-tdd22-edge: #e7e5e4;
--color-tdd22-edge: oklch(0.91 0.005 60);
--color-tdd22-tint: #fffbeb;
--color-tdd22-tint: oklch(0.98 0.02 95);
--color-tdd22-sel: #fef3c7;
--color-tdd22-sel: oklch(0.95 0.06 92);
--color-tdd22-acc: #f59e0b;
--color-tdd22-acc: oklch(0.75 0.16 70);
--color-tdd22-accdark: #d97706;
--color-tdd22-accdark: oklch(0.65 0.15 60);
--font-tdd22-sans: 'Inter', system-ui, sans-serif;
--ease-tdd22: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tdd-22 {
width: 100%;
min-height: 100svh;
}
.tdd-22 ::selection {
background: var(--color-tdd22-acc);
color: #fff;
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- Four
:nth-childtransition-delay rules become one utility reading--i— the same pattern as demos 06 and 18, now applied to a form control. - Selection, keyboard cursor and open state are three separate attributes on purpose; the CSS version collapsed the first two into overlapping classes.
- The tick mark is
aria-selected:after:content-['\2713']: an ARIA-derived pseudo-element, so the visual and the announcement have one source. - The native
<select>issr-onlyrather than absolutely-positioned-and-transparent, which keeps it focusable and correctly labelled.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Custom Animated Select Dropdown
Source: https://codefronts.com/navigation/tailwind-dropdown-menus/tailwind-custom-animated-select-dropdown/
A styled replacement for <select>, with the real element still in the DOM for form submission. Options stagger in on an overshoot curve, selection is aria-selected, open state is aria-expanded — three declarative states, and a script that only moves values between them.
## HTML
```html
<section class="tdd-22 w-full min-h-svh grid place-items-center bg-linear-[135deg,var(--color-tdd22-bg1),var(--color-tdd22-bg2)_60%,var(--color-tdd22-bg3)] font-tdd22-sans text-tdd22-ink px-5 py-10" aria-label="Custom animated select dropdown demo">
<div class="flex w-[min(360px,100%)] flex-col gap-3 rounded-[20px] border border-tdd22-edge bg-white px-6 py-7 shadow-[0_8px_40px_oklch(0.75_0.16_70/0.15)]">
<div>
<p class="text-xl font-bold tracking-[-0.3px] text-tdd22-ink">Choose your plan</p>
<p class="mt-1 text-[13px] text-tdd22-mut">Select the option that fits your needs</p>
</div>
<label for="tdd22-native" class="sr-only">Plan</label>
<select id="tdd22-native" tabindex="-1" class="sr-only">
<option value="">Select a plan…</option>
<option value="free">Free — $0/month</option>
<option value="pro">Pro — $12/month</option>
<option value="team">Team — $39/month</option>
<option value="enterprise">Enterprise — Custom</option>
</select>
<div class="group relative z-10">
<button type="button" id="tdd22-btn" aria-haspopup="listbox" aria-expanded="false" aria-controls="tdd22-list" class="flex min-h-11 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border-2 border-tdd22-edge bg-white px-4 py-3 text-left font-tdd22-sans text-[14.5px] font-medium text-tdd22-mut transition-[border-color,box-shadow,color] duration-200 hover:border-tdd22-acc focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd22-acc aria-expanded:border-tdd22-acc aria-expanded:text-tdd22-ink aria-expanded:shadow-[0_0_0_4px_oklch(0.75_0.16_70/0.16)] data-chosen:font-semibold data-chosen:text-tdd22-ink">
<span id="tdd22-val">Select a plan…</span>
<span class="shrink-0 text-sm text-tdd22-mut transition-transform duration-280 ease-tdd22 group-has-aria-expanded:rotate-180" aria-hidden="true">▾</span>
</button>
<ul id="tdd22-list" role="listbox" aria-label="Plan options" tabindex="-1" class="pointer-events-none absolute top-[calc(100%+8px)] right-0 left-0 origin-top scale-y-0 list-none rounded-2xl border border-tdd22-edge bg-white p-1.5 opacity-0 shadow-[0_12px_40px_oklch(0.75_0.16_70/0.18)] transition-[scale,opacity] duration-380 ease-tdd22 group-has-aria-expanded:pointer-events-auto group-has-aria-expanded:scale-y-100 group-has-aria-expanded:opacity-100 motion-reduce:duration-150">
<li role="option" aria-selected="false" data-value="free" style="--i:0" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🆕 Free</span><span class="text-[12.5px] font-medium text-tdd22-mut">$0/month</span></li>
<li role="option" aria-selected="false" data-value="pro" style="--i:1" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🚀 Pro</span><span class="text-[12.5px] font-medium text-tdd22-mut">$12/month</span></li>
<li role="option" aria-selected="false" data-value="team" style="--i:2" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">👥 Team</span><span class="text-[12.5px] font-medium text-tdd22-mut">$39/month</span></li>
<li role="option" aria-selected="false" data-value="enterprise" style="--i:3" class="mt-1 flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] border border-dashed border-tdd22-acc px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-sel data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🏗 Enterprise</span><span class="text-[12.5px] font-medium text-tdd22-mut">Custom</span></li>
</ul>
</div>
<button type="button" id="tdd22-submit" class="mt-1 min-h-11 w-full cursor-pointer rounded-xl border-0 bg-tdd22-acc px-3 py-3 font-tdd22-sans text-[15px] font-bold text-white transition-[background,translate] duration-150 hover:-translate-y-px hover:bg-tdd22-accdark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd22-accdark motion-reduce:transition-none">Continue →</button>
<p id="tdd22-status" role="status" class="min-h-4.5 text-center text-[12.5px] italic text-tdd22-mut">No plan selected</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tdd22-bg1: #fffbeb;
--color-tdd22-bg1: oklch(0.98 0.02 95);
--color-tdd22-bg2: #fef3c7;
--color-tdd22-bg2: oklch(0.95 0.06 92);
--color-tdd22-bg3: #fde68a;
--color-tdd22-bg3: oklch(0.91 0.1 90);
--color-tdd22-ink: #1c1917;
--color-tdd22-ink: oklch(0.22 0.01 60);
--color-tdd22-mut: #78716c;
--color-tdd22-mut: oklch(0.56 0.01 60);
--color-tdd22-edge: #e7e5e4;
--color-tdd22-edge: oklch(0.91 0.005 60);
--color-tdd22-tint: #fffbeb;
--color-tdd22-tint: oklch(0.98 0.02 95);
--color-tdd22-sel: #fef3c7;
--color-tdd22-sel: oklch(0.95 0.06 92);
--color-tdd22-acc: #f59e0b;
--color-tdd22-acc: oklch(0.75 0.16 70);
--color-tdd22-accdark: #d97706;
--color-tdd22-accdark: oklch(0.65 0.15 60);
--font-tdd22-sans: 'Inter', system-ui, sans-serif;
--ease-tdd22: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tdd-22 {
width: 100%;
min-height: 100svh;
}
.tdd-22 ::selection {
background: var(--color-tdd22-acc);
color: #fff;
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tdd-22').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var btn = root.querySelector('#tdd22-btn'),
list = root.querySelector('#tdd22-list'),
val = root.querySelector('#tdd22-val'),
native = root.querySelector('#tdd22-native'),
submit = root.querySelector('#tdd22-submit'),
status = root.querySelector('#tdd22-status');
if (!btn || !list || !native) return;
var opts = Array.from(list.querySelectorAll('[role="option"]'));
var active = -1;
var isOpen = function () { return btn.getAttribute('aria-expanded') === 'true'; };
function highlight(i) {
opts.forEach(function (o) { o.removeAttribute('data-active'); });
if (i < 0 || i >= opts.length) return;
opts[i].setAttribute('data-active', '');
active = i;
}
function setOpen(open) {
btn.setAttribute('aria-expanded', String(open));
if (open) {
var sel = opts.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
highlight(sel > -1 ? sel : 0);
} else { highlight(-1); active = -1; }
}
function choose(opt) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opt)); });
var name = opt.children[0].textContent.trim(), price = opt.children[1].textContent.trim();
val.textContent = name + ' — ' + price;
btn.toggleAttribute('data-chosen', true);
native.value = opt.dataset.value;
native.dispatchEvent(new Event('change', { bubbles: true }));
status.textContent = 'Selected: ' + name + ' (' + price + ')';
setOpen(false);
btn.focus();
}
btn.addEventListener('click', function (e) { e.stopPropagation(); setOpen(!isOpen()); });
btn.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!isOpen()) setOpen(true); else list.focus();
} else if (e.key === 'Escape' && isOpen()) setOpen(false);
});
opts.forEach(function (opt, i) {
opt.addEventListener('click', function () { choose(opt); });
opt.addEventListener('mousemove', function () { highlight(i); });
});
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % opts.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + opts.length) % opts.length); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (active > -1) choose(opts[active]); }
else if (e.key === 'Escape' || e.key === 'Tab') { setOpen(false); btn.focus(); }
});
document.addEventListener('click', function (e) {
if (!btn.contains(e.target) && !list.contains(e.target)) setOpen(false);
});
submit && submit.addEventListener('click', function () {
if (!native.value) {
status.textContent = '⚠ Please select a plan first.';
btn.style.borderColor = 'oklch(0.63 0.22 25)';
setTimeout(function () { btn.style.borderColor = ''; }, 1500);
} else {
status.textContent = '✓ Proceeding with ' + val.textContent;
}
});
});
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Custom Animated Select Dropdown
Source: https://codefronts.com/navigation/tailwind-dropdown-menus/tailwind-custom-animated-select-dropdown/
A styled replacement for <select>, with the real element still in the DOM for form submission. Options stagger in on an overshoot curve, selection is aria-selected, open state is aria-expanded — three declarative states, and a script that only moves values between them.
## HTML
```html
<section class="tdd-22 w-full min-h-svh grid place-items-center bg-linear-[135deg,var(--color-tdd22-bg1),var(--color-tdd22-bg2)_60%,var(--color-tdd22-bg3)] font-tdd22-sans text-tdd22-ink px-5 py-10" aria-label="Custom animated select dropdown demo">
<div class="flex w-[min(360px,100%)] flex-col gap-3 rounded-[20px] border border-tdd22-edge bg-white px-6 py-7 shadow-[0_8px_40px_oklch(0.75_0.16_70/0.15)]">
<div>
<p class="text-xl font-bold tracking-[-0.3px] text-tdd22-ink">Choose your plan</p>
<p class="mt-1 text-[13px] text-tdd22-mut">Select the option that fits your needs</p>
</div>
<label for="tdd22-native" class="sr-only">Plan</label>
<select id="tdd22-native" tabindex="-1" class="sr-only">
<option value="">Select a plan…</option>
<option value="free">Free — $0/month</option>
<option value="pro">Pro — $12/month</option>
<option value="team">Team — $39/month</option>
<option value="enterprise">Enterprise — Custom</option>
</select>
<div class="group relative z-10">
<button type="button" id="tdd22-btn" aria-haspopup="listbox" aria-expanded="false" aria-controls="tdd22-list" class="flex min-h-11 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border-2 border-tdd22-edge bg-white px-4 py-3 text-left font-tdd22-sans text-[14.5px] font-medium text-tdd22-mut transition-[border-color,box-shadow,color] duration-200 hover:border-tdd22-acc focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd22-acc aria-expanded:border-tdd22-acc aria-expanded:text-tdd22-ink aria-expanded:shadow-[0_0_0_4px_oklch(0.75_0.16_70/0.16)] data-chosen:font-semibold data-chosen:text-tdd22-ink">
<span id="tdd22-val">Select a plan…</span>
<span class="shrink-0 text-sm text-tdd22-mut transition-transform duration-280 ease-tdd22 group-has-aria-expanded:rotate-180" aria-hidden="true">▾</span>
</button>
<ul id="tdd22-list" role="listbox" aria-label="Plan options" tabindex="-1" class="pointer-events-none absolute top-[calc(100%+8px)] right-0 left-0 origin-top scale-y-0 list-none rounded-2xl border border-tdd22-edge bg-white p-1.5 opacity-0 shadow-[0_12px_40px_oklch(0.75_0.16_70/0.18)] transition-[scale,opacity] duration-380 ease-tdd22 group-has-aria-expanded:pointer-events-auto group-has-aria-expanded:scale-y-100 group-has-aria-expanded:opacity-100 motion-reduce:duration-150">
<li role="option" aria-selected="false" data-value="free" style="--i:0" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🆕 Free</span><span class="text-[12.5px] font-medium text-tdd22-mut">$0/month</span></li>
<li role="option" aria-selected="false" data-value="pro" style="--i:1" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🚀 Pro</span><span class="text-[12.5px] font-medium text-tdd22-mut">$12/month</span></li>
<li role="option" aria-selected="false" data-value="team" style="--i:2" class="flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-tint data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">👥 Team</span><span class="text-[12.5px] font-medium text-tdd22-mut">$39/month</span></li>
<li role="option" aria-selected="false" data-value="enterprise" style="--i:3" class="mt-1 flex min-h-11 cursor-pointer translate-y-1 items-center justify-between rounded-[9px] border border-dashed border-tdd22-acc px-3.5 py-2.5 opacity-0 transition-[opacity,translate,background] duration-280 hover:bg-tdd22-sel data-active:bg-tdd22-tint data-active:outline-2 data-active:-outline-offset-2 data-active:outline-tdd22-acc aria-selected:bg-tdd22-sel group-has-aria-expanded:translate-y-0 group-has-aria-expanded:opacity-100 group-has-aria-expanded:[transition-delay:calc(40ms+var(--i)*50ms)] motion-reduce:[transition-delay:0ms]"><span class="flex items-center gap-2 text-sm font-semibold text-tdd22-ink after:text-[13px] after:font-bold after:text-tdd22-acc aria-selected:after:content-['\2713']">🏗 Enterprise</span><span class="text-[12.5px] font-medium text-tdd22-mut">Custom</span></li>
</ul>
</div>
<button type="button" id="tdd22-submit" class="mt-1 min-h-11 w-full cursor-pointer rounded-xl border-0 bg-tdd22-acc px-3 py-3 font-tdd22-sans text-[15px] font-bold text-white transition-[background,translate] duration-150 hover:-translate-y-px hover:bg-tdd22-accdark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tdd22-accdark motion-reduce:transition-none">Continue →</button>
<p id="tdd22-status" role="status" class="min-h-4.5 text-center text-[12.5px] italic text-tdd22-mut">No plan selected</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tdd22-bg1: #fffbeb;
--color-tdd22-bg1: oklch(0.98 0.02 95);
--color-tdd22-bg2: #fef3c7;
--color-tdd22-bg2: oklch(0.95 0.06 92);
--color-tdd22-bg3: #fde68a;
--color-tdd22-bg3: oklch(0.91 0.1 90);
--color-tdd22-ink: #1c1917;
--color-tdd22-ink: oklch(0.22 0.01 60);
--color-tdd22-mut: #78716c;
--color-tdd22-mut: oklch(0.56 0.01 60);
--color-tdd22-edge: #e7e5e4;
--color-tdd22-edge: oklch(0.91 0.005 60);
--color-tdd22-tint: #fffbeb;
--color-tdd22-tint: oklch(0.98 0.02 95);
--color-tdd22-sel: #fef3c7;
--color-tdd22-sel: oklch(0.95 0.06 92);
--color-tdd22-acc: #f59e0b;
--color-tdd22-acc: oklch(0.75 0.16 70);
--color-tdd22-accdark: #d97706;
--color-tdd22-accdark: oklch(0.65 0.15 60);
--font-tdd22-sans: 'Inter', system-ui, sans-serif;
--ease-tdd22: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tdd-22 {
width: 100%;
min-height: 100svh;
}
.tdd-22 ::selection {
background: var(--color-tdd22-acc);
color: #fff;
}
```
## JavaScript
```js
(function () {
document.querySelectorAll('.tdd-22').forEach(function (root) {
if (root.dataset.bound) return; root.dataset.bound = '1';
var btn = root.querySelector('#tdd22-btn'),
list = root.querySelector('#tdd22-list'),
val = root.querySelector('#tdd22-val'),
native = root.querySelector('#tdd22-native'),
submit = root.querySelector('#tdd22-submit'),
status = root.querySelector('#tdd22-status');
if (!btn || !list || !native) return;
var opts = Array.from(list.querySelectorAll('[role="option"]'));
var active = -1;
var isOpen = function () { return btn.getAttribute('aria-expanded') === 'true'; };
function highlight(i) {
opts.forEach(function (o) { o.removeAttribute('data-active'); });
if (i < 0 || i >= opts.length) return;
opts[i].setAttribute('data-active', '');
active = i;
}
function setOpen(open) {
btn.setAttribute('aria-expanded', String(open));
if (open) {
var sel = opts.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
highlight(sel > -1 ? sel : 0);
} else { highlight(-1); active = -1; }
}
function choose(opt) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opt)); });
var name = opt.children[0].textContent.trim(), price = opt.children[1].textContent.trim();
val.textContent = name + ' — ' + price;
btn.toggleAttribute('data-chosen', true);
native.value = opt.dataset.value;
native.dispatchEvent(new Event('change', { bubbles: true }));
status.textContent = 'Selected: ' + name + ' (' + price + ')';
setOpen(false);
btn.focus();
}
btn.addEventListener('click', function (e) { e.stopPropagation(); setOpen(!isOpen()); });
btn.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!isOpen()) setOpen(true); else list.focus();
} else if (e.key === 'Escape' && isOpen()) setOpen(false);
});
opts.forEach(function (opt, i) {
opt.addEventListener('click', function () { choose(opt); });
opt.addEventListener('mousemove', function () { highlight(i); });
});
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % opts.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + opts.length) % opts.length); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (active > -1) choose(opts[active]); }
else if (e.key === 'Escape' || e.key === 'Tab') { setOpen(false); btn.focus(); }
});
document.addEventListener('click', function (e) {
if (!btn.contains(e.target) && !list.contains(e.target)) setOpen(false);
});
submit && submit.addEventListener('click', function () {
if (!native.value) {
status.textContent = '⚠ Please select a plan first.';
btn.style.borderColor = 'oklch(0.63 0.22 25)';
setTimeout(function () { btn.style.borderColor = ''; }, 1500);
} else {
status.textContent = '✓ Proceeding with ' + val.textContent;
}
});
});
})();
```How this works
Three states, three attributes. aria-expanded on the button opens the list, aria-selected on an option paints it as chosen, data-active marks the keyboard cursor. None of them is a class, so the script never touches styling.
The tick is a pseudo-element on the selected state: aria-selected:after:content-['\2713']. One utility, no extra node, no JS insertion.
The native select stays. It is sr-only, not removed, so the control still posts a value and still participates in form validation — and the script mirrors every choice into it and dispatches change.
The stagger is declarative, reusing the same per-item index pattern as demos 06 and 18, so adding a fifth plan means copying a row.
Make it yours
- Search inside the list: reuse demo 20's filter over the options.
- Multi-select: keep
aria-selectedon several options and show a count in the trigger. - Option icons: the rows are flex; add a leading span, and mirror it into the trigger on select.
- Stagger tempo: the
50msmultiplier in the delay expression.
Gotchas — read before shipping
- Never remove the native
<select>— without it the control submits nothing. - On touch devices the OS picker is more accessible than any custom listbox; consider falling back to the native element there.
- Test with a real screen reader: custom listboxes announce inconsistently, and the native fallback is your safety net.
- Keep
data-active(keyboard cursor) andaria-selected(chosen value) as separate states; collapsing them breaks announcement.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Everything here is Baseline; the native select fallback keeps forms working regardless.