What's Included
index.html: the demo page and the markup to copyassets/style.css: component styles, plain CSS custom properties for coloursassets/script.js: readable, commented source with anonReadyguardOne script drives both shapes: add
data-select-multipleand the same field becomes a multi-select with chipsTypeahead filtering over each option's label and its meta line, with a group heading dropping out when it empties and an empty state when nothing survives
A single sliding highlight pill, tweened to the row's measured offset, shared by arrow keys, hover, click and filtering
In single-select, one tick travels from the old row to the new one; in multi-select each chosen row scales its own tick in
Chips animate in on a pick and out on removal, by their own button or by
Backspacein an empty fieldPlacement measured on open:
autoflips above when the room below runs out,belowandabovepin itDrives a real hidden
<select>: it supplies the initial value, receives every change, and submits with the formcombobox/listbox/option/grouproles witharia-activedescendant, and focus that never leaves the fieldDisabled options, a clear button, and light and dark themes on one token list
Quick Start
1. Add to your <head>:
Code snippet omitted: it ships with the download.
2. Add before the closing </body> tag:
Code snippet omitted: it ships with the download.
3. Copy a .sel block from index.html into your form.
The script finds every element with data-select and wires the hooks inside it:
| Attribute | On | What it is |
|---|---|---|
data-select |
the field root | Marks a field. Required |
data-select-multiple |
the field root | Makes it a multi-select with chips |
data-select-placement |
the field root | auto (default), below or above |
data-select-control |
the clickable field box | Required |
data-select-input |
the <input role="combobox"> |
Required |
data-select-listbox |
the panel | Required |
data-select-options |
the scrolling box inside it | Required; the pill and the tick measure against it |
data-select-pill |
the sliding highlight | Optional |
data-select-mark |
the travelling tick (single-select) | Optional |
data-select-chips |
where chips are appended (multi-select) | Required for chips |
data-select-clear |
the clear button | Optional |
data-select-empty / data-select-empty-term |
the no-results line and the term inside it | Optional |
Each option is an element with role="option", data-value (what the form submits), data-label (what is shown and searched) and an optional data-meta (also searched). aria-disabled="true" makes one unselectable and skips it with the arrow keys. Options can sit in role="group" blocks with an aria-labelledby heading, or directly in the listbox.
4. Keep the <select>. It is not decoration:
Code snippet omitted: it ships with the download.
Whatever is selected there becomes the field's initial value, every pick is written back to it, and the form submits from it. .sel-native { display: none } keeps it out of the tab order and the accessibility tree; a display: none control still submits, which is the whole reason the value lives there rather than in a JavaScript variable. Swap it for hidden inputs if you prefer, by changing syncNative().
Listen for changes with the bubbling change CustomEvent on the field root:
Code snippet omitted: it ships with the download.
The variant switcher, the placement buttons and the .stage* blocks in index.html are demo furniture, not part of the component; leave them behind.
Keyboard & Accessibility
| Key | What it does |
|---|---|
ArrowDown / ArrowUp |
Opens the panel, then moves the highlight, skipping disabled options and wrapping at the ends |
Home / End |
Jumps to the first or last selectable option |
Enter |
Selects the highlighted option. Single-select closes; multi-select stays open for the next one |
Escape |
Closes the panel. Focus stays in the field |
Tab / Shift+Tab |
Closes the panel and moves on to the next control |
| Any character | Filters the options; the highlight moves to the first match |
Backspace |
In a multi-select with an empty field, removes the last chip and its value |
- Roles: the field is an
<input role="combobox">witharia-expanded,aria-controls,aria-haspopup="listbox",aria-autocomplete="list"andaria-activedescendantpointing at the highlighted row. The panel isrole="listbox"(aria-multiselectable="true"when it is a multi-select), each row isrole="option"witharia-selected, and each block of rows isrole="group"labelled by its heading. - Focus never leaves the input while the panel is open, which is what the ARIA combobox pattern asks for: there is no focus trap, because there is nothing else to focus. Pointer presses inside the panel are prevented from stealing it, so the field keeps its focus ring throughout.
- A press outside the field closes the panel without moving focus.
prefers-reduced-motion: the same end states with every duration at zero. The panel opens straight into place, the pill and the tick jump rather than travel, and chips appear without a scale. Nothing is disabled.- The clear button and each chip's remove button carry their own labels ("Clear assignee", "Remove Blocker"), so they are not two dozen unnamed crosses.
How It Works
Opening and placement. openList() un-hides the panel, then measures: auto compares the panel's own height with the room under the field and adds is-above when there is not enough. GSAP fades and lifts the panel in over 0.22s, and a second tween staggers the visible rows up from 12px behind a small delay, so the surface lands a frame before its contents settle. Closing tweens the panel back and sets hidden in onComplete, because a faded-out panel still covers the field under it and eats every click meant for that one.
The sliding pill and the travelling tick. The highlight is one absolutely positioned element at left: 0; top: 0, moved entirely by x/y/width/height read from the row's offsetLeft/offsetTop. Keeping the CSS offset at zero is deliberate: any padding left in CSS would be added to the measured offset and the pill would sit a step off its row. Measurements are taken against .sel-options and re-run on resize and on document.fonts.ready, so a theme change or a late webfont cannot leave it stranded. Single-select's tick is one element tweened to the newly chosen row with a back.out scale on top; multi-select gives each row its own, since several can be on at once.
Chips and the form value. A pick in a multi-select appends a chip with gsap.from on a back.out ease and leaves the panel open, so picking four labels is four gestures rather than eight. Removing one scales it out and drops the node in onComplete. Every change runs syncNative(), which sets selected on the matching <option> of the hidden <select>, then dispatches the change CustomEvent. That split is the point: the animation layer never holds the value, so what you submit and what you see cannot drift apart.
Customisation
- Panel height:
max-heighton.sel-options(default262px). - Row height:
min-heighton.sel-option(40px, 44px under 560px wide). - The highlight's tint is
--accent-washand its ring is--accent; drop thebox-shadowon.sel-pillfor a flat tint. - Timings and easings are the
gsap.to/gsap.fromTocalls inassets/script.js; the stagger and the panel's head start are theduration,staggeranddelayinopenList(). - Colours live in
assets/style.cssas custom properties per theme.
Themes
Ships in two themes, light (the default) and dark. They are one design at two token values; set the attribute on the demo's <body>, or on the field root in your own page:
Code snippet omitted: it ships with the download.
Nothing else changes: same markup, same script, and no theme name is ever read in JavaScript. The tokens to re-value for your own brand are --ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --accent, --accent-ink, --accent-wash, --shadow, --font, --radius, --radius-sm, --radius-xs, plus the component's own --field-bg, --field-border, --panel-bg, --avatar-bg and --chip-bg. To add a third theme, copy either [data-variant="..."] block, rename the value and change the properties; no selector override is needed.
Give --accent-wash a solid hex rather than a color-mix with transparency: it sits behind the highlighted row's text, and a translucent value cannot be contrast-checked against what is underneath it.
Requirements
- GSAP 3.12+ (core only, no plugins)
- No build step, no framework
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.