Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 frameworksBeginner
A listbox with an icon per option, mirrored into the trigger once chosen.
<!--
The one thing a native <select> genuinely cannot do: an <option> may contain
text and nothing else, so a glyph per row forces a listbox. That is the trade
being made here - you are giving up the platform's popup to gain 16px of SVG,
so the icon had better be carrying information.
Every icon is aria-hidden. The row already says "Engineering"; an icon that
announces itself as well produces "Engineering Engineering".
-->
<div class="isel">
<span class="isel__label" id="isel-label">Team</span>
<button class="isel__trigger" id="isel-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="isel-listbox" aria-labelledby="isel-label isel-trigger">
<span class="isel__selected">
<svg class="isel__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>
<span>Engineering</span>
</span>
<svg class="isel__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6" /></svg>
</button>
<ul class="isel__listbox" id="isel-listbox" role="listbox" aria-labelledby="isel-label" tabindex="-1" hidden>
<li class="isel__option" id="isel-opt-design" role="option" data-value="design" aria-selected="false">
<span class="isel__marker" aria-hidden="true"></span>
<svg class="isel__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 19l7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5zM2 2l7.586 7.586" /><circle cx="11" cy="11" r="2" /></svg>
<span class="isel__option-label">Design</span>
</li>
<li class="isel__option" id="isel-opt-engineering" role="option" data-value="engineering" aria-selected="true">
<span class="isel__marker" aria-hidden="true"></span>
<svg class="isel__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>
<span class="isel__option-label">Engineering</span>
</li>
<li class="isel__option" id="isel-opt-marketing" role="option" data-value="marketing" aria-selected="false">
<span class="isel__marker" aria-hidden="true"></span>
<svg class="isel__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3 11 18-5v12L3 14v-3zM11.6 16.8a3 3 0 1 1-5.8-1.6" /></svg>
<span class="isel__option-label">Marketing</span>
</li>
<li class="isel__option" id="isel-opt-sales" role="option" data-value="sales" aria-selected="false">
<span class="isel__marker" aria-hidden="true"></span>
<svg class="isel__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 3v18h18M18 9l-5 5-3-3-4 4" /></svg>
<span class="isel__option-label">Sales</span>
</li>
<li class="isel__option" id="isel-opt-support" role="option" data-value="support" aria-selected="false">
<span class="isel__marker" aria-hidden="true"></span>
<svg class="isel__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 18v-6a9 9 0 0 1 18 0v6M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" /></svg>
<span class="isel__option-label">Support</span>
</li>
</ul>
</div>
<script>
document.querySelectorAll('.isel').forEach(function (root) {
var trigger = root.querySelector('.isel__trigger');
var listbox = root.querySelector('.isel__listbox');
var selectedSlot = root.querySelector('.isel__selected');
var options = Array.prototype.slice.call(root.querySelectorAll('.isel__option'));
var active = Math.max(0, options.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; }));
function paint() {
options.forEach(function (option, index) {
option.classList.toggle('isel__option--active', index === active);
});
listbox.setAttribute('aria-activedescendant', options[active] ? options[active].id : '');
if (options[active]) options[active].scrollIntoView({ block: 'nearest' });
}
function setOpen(open) {
listbox.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
if (open) paint();
}
function close() { setOpen(false); trigger.focus(); }
function commit(index) {
var option = options[index];
if (!option) return;
options.forEach(function (o) { o.setAttribute('aria-selected', String(o === option)); });
// Mirror the row's icon into the trigger - the glyph is half the value.
selectedSlot.innerHTML =
option.querySelector('.isel__icon').outerHTML +
'<span>' + option.querySelector('.isel__option-label').textContent + '</span>';
active = index;
close();
}
trigger.addEventListener('click', function () {
setOpen(trigger.getAttribute('aria-expanded') !== 'true');
});
options.forEach(function (option, index) {
option.addEventListener('click', function () { commit(index); });
option.addEventListener('mousemove', function () { active = index; paint(); });
});
trigger.addEventListener('keydown', function (event) {
var open = trigger.getAttribute('aria-expanded') === 'true';
var key = event.key;
if (!open) {
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ') {
event.preventDefault();
setOpen(true);
}
return;
}
if (key === 'Escape') { event.preventDefault(); close(); return; }
if (key === 'Enter' || key === ' ') { event.preventDefault(); commit(active); return; }
if (key === 'Tab') { setOpen(false); return; }
var next = active;
if (key === 'ArrowDown') next = active + 1;
else if (key === 'ArrowUp') next = active - 1;
else if (key === 'Home') next = 0;
else if (key === 'End') next = options.length - 1;
else return;
event.preventDefault();
active = (next + options.length) % options.length;
paint();
});
document.addEventListener('mousedown', function (event) {
if (!root.contains(event.target)) setOpen(false);
});
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | - | Accessible label announced while loading. |
itemsrequired | IconSelectItem[] | - | The array of items to render. |
value | string | - | The metric's current value, pre-formatted. |
onSelect | (value: string) => void | - | Fired with the menu item the user chose. |
disabled | boolean | false | Prevents interaction and dims the control. |
This is the case that justifies leaving `<select>` behind: an `<option>` may contain text and nothing else, so a glyph per row forces a listbox. Every icon is `aria-hidden` - the row already says "Engineering", and an icon that announces itself as well produces "Engineering Engineering". Mirroring the glyph into the closed trigger is not decoration either; a control that drops it loses half the information the moment it closes. Selection is carried by weight as well as tint, so the chosen row survives a greyscale screenshot.