Replacing a jQuery dropdown with an accessible one, and keeping the design identical
Reading time: ~ 4 minutes
If your application is more than a few years old, there’s a good chance it has a Selectric somewhere: a small UI plugin dropped in years ago to make the browser's native <select> match the design, quietly powering every dropdown since. It works, so nobody thinks about it. Then an accessibility audit lands, and that one convenient plugin turns out to be failing several success criteria on every page it touches.
For us, that audit was not hypothetical. A large storage-management company had been served with a legal claim that its site failed national accessibility standards—the ADA expectations that are measured in practice against the WCAG guidelines—and an earlier audit had already cataloged a long backlog of problems to fix. Overnight, that backlog gained a deadline and a legal team. So the work had to move fast, and the booking flow's dropdowns showed up on the list more than once.
The site was a mature Laravel codebase that’s been shipping and earning revenue for years. Its reservation and facility pages all relied on Selectric, an older jQuery select-replacement plugin. Selectric renders a nice-looking dropdown, but it was built when "accessible" effectively meant "the mouse works." It offered no meaningful roles, no keyboard model, and nothing a screen reader could describe.
The plugin's own history tells the story. Its last release shipped in 2017. Nearly nine years later, dozens of issues sit open on the project, several of them specifically about accessibility. A dependency that has not moved in almost a decade is a poor place to leave a legal requirement, especially one standing between customers and a checkout form.
// The original: styling, but no accessibility contract $('select.unit-size').selectric();
The goal we settled on was deliberately narrow: keep the exact custom appearance, and make the control behave like a real select for anyone using a keyboard or a screen reader.
Why the native element wasn't the answer
The obvious move is to delete the plugin and return to a plain <select>, which is accessible for free. Most of the time that’s the right call, and we considered it seriously.
The obstacle was the design system. A native <select> renders its option list using the operating system's own styling, which CSS cannot fully control. The client's dropdowns had a specific look, spacing, typography, the way selected and hovered options appear. That look was woven through the reservation flow.
Swapping in a native control would have meant either accepting a visibly different dropdown on a revenue-critical page, or fighting the browser over styling it deliberately does not expose. During a legal deadline, neither was worth it.
Building to the combobox pattern
The W3C publishes authoring practices for common widgets, and the combobox pattern describes a custom-styled single-select control exactly like the one we needed. Rather than invent our own accessibility contract, we implemented that pattern as one reusable component and pointed the existing markup at it.
The structure comes down to a trigger that reports its open state, a list with the right roles, and a clear way to mark the active and selected options:
<label id="unit-size-label">Unit size</label> <!-- Open state: aria-expanded="true" and one option is active. --> <button type="button" role="combobox" id="unit-size" aria-haspopup="listbox" aria-expanded="true" aria-controls="unit-size-listbox" aria-labelledby="unit-size-label unit-size-value" aria-activedescendant="unit-size-option-1"> <span id="unit-size-value" class="combobox-value">10' x 10'</span> </button> <div role="listbox" id="unit-size-listbox" aria-labelledby="unit-size-label"> <div role="option" id="unit-size-option-0" data-value="5x5">5' x 5'</div> <div role="option" id="unit-size-option-1" data-value="10x10" aria-selected="true">10' x 10' </div> <div role="option" id="unit-size-option-2" data-value="10x20">10' x 20'</div> </div>
With that in place, a screen reader announces the control's name, whether it is expanded, and which option is active. Someone using the page hears "10 by 10, selected, 2 of 3" instead of an anonymous div.
The part that takes the most care is the keyboard, because a native select gives you behavior people never consciously notice until it’s gone. Matching it means handling the arrow keys, Home and End, Enter or Space to commit, and Escape to close without changing anything:
const button = document.querySelector('[role="combobox"]'); const list = document.getElementById(button.getAttribute('aria-controls')); const box = createCombobox(button, list); // DOM plumbing lives here const keymap = { ArrowDown: { closed: box.open, open: () => box.move(+1) }, ArrowUp: { closed: box.open, open: () => box.move(-1) }, Home: { open: () => box.moveTo(0) }, End: { open: () => box.moveTo(box.last) }, Enter: { closed: box.open, open: box.commit }, ' ': { closed: box.open, open: box.commit }, Escape: { open: box.close }, // Any printable character (not Space) jumps to a match — in both states. typeahead: { closed: e => box.typeahead(e.key), open: e =>box.typeahead(e.key) } }; button.addEventListener('keydown', e => { const entry = keymap[e.key] ?? (isPrintable(e) ? keymap.typeahead : null); const action = keymap[e.key]?.[box.isOpen ? 'open' : 'closed']; if (action) { e.preventDefault(); action(); } });
One decision inside the pattern is worth naming, because it trips people up. Focus stays on the trigger while the list is open, and the component tracks the current option with aria-activedescendant rather than moving real focus into the list. For a single-select control this is the simpler model—there is one focused element, and it tells the screen reader which option is active. We also kept typeahead, since on a list of unit sizes that is how people actually use a dropdown.
Because it was built once as a shared component, every dropdown on the site inherited the same behavior. The audit had flagged them everywhere, so fixing the component fixed them everywhere.
Testing it
A pattern that is correct on paper can still be awkward to use, so the real check was not a linter. We validated the component with VoiceOver, the screen reader built into macOS, navigating the reservation flow the way a customer with low or no vision would: moving entirely by keyboard and audio. That surfaces what automated tools miss—whether the selected value announces clearly, whether the option count makes sense, or whether closing the list lands you back where you expected. A change counted as done when it held up to that test.
The reusable takeaway
The satisfying part of this fix is how ordinary it is. No rewrite, no migration. We took one aging plugin that a design constraint had locked into the app, replaced it with a component built to a published standard, and matched the existing styling so precisely that the change was invisible to everyone except the people who most needed it.
That’s usually the shape of accessibility work in a real codebase: the blockers are small, well-worn shortcuts that were reasonable when they were added. You fix them where they live, to a real standard, and you confirm the result with your ears as well as your eyes.
This dropdown was one item on a long list. If you want the wider view of that engagement, including how we scoped a legal deadline's worth of accessibility work without touching the architecture, we wrote it up as a case study.
And if you've got a Selectric of your own, or an audit report you haven't opened yet, we're happy to take a look.
References
- W3C WAI-ARIA combobox pattern — the authoring practice we built to
- Selectric and its open accessibility issues — the plugin we replaced, last released in 2017
- jQuery — the library Selectric depends on
- VoiceOver User Guide — the macOS screen reader we tested with