🧩 Custom Form Controls
Native controls are reliable, submittable, and accessible out of the box — but sometimes the design or the interaction genuinely needs something they can't do. This lesson shows how to build custom widgets that look bespoke while keeping everything native controls give you for free: keyboard support, screen-reader semantics, and clean form submission. The trick is to enhance, not replace.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Weigh the real trade-offs between native and custom controls, and default to native
- Apply progressive enhancement and the hidden-input pattern so forms still submit correctly
- Restore keyboard and screen-reader support with ARIA roles, states, and
tabindex - Build three accessible widgets: a toggle switch, a star rating, and a tag input
- Test custom controls for keyboard, screen-reader, and form-integration correctness
Estimated Time: 45–60 minutes • Difficulty: Intermediate–Advanced
Hands-on: Build a fully keyboard-accessible star-rating component backed by a hidden input.
In This Lesson
Why (and Why Not) Go Custom
HTML's built-in controls are the result of decades of accessibility and usability work. A native <input type="checkbox"> already handles focus, keyboard toggling, screen-reader announcements, form submission, and the browser's high-contrast modes — none of which you had to write. So the first rule of custom controls is: be sure you actually need one.
Legitimate reasons include a design a native control can't produce (a sliding toggle, a star strip), an interaction the platform lacks (a tag/token input, a multi-thumb slider), or a widget that must look identical across every browser.
💡 An analogy: Custom controls are like bespoke furniture. A mass-produced chair is tested, comfortable, and cheap. A hand-built one can fit your room perfectly — but you're now responsible for the ergonomics, the joinery, and making sure nobody gets a splinter. Only commission the bespoke piece when the standard one truly won't fit.
Native vs. Custom Trade-offs
| Aspect | Native controls | Custom controls |
|---|---|---|
| Accessibility | Built in | You implement it all |
| Keyboard support | Free | Hand-wired per key |
| Form submission | Automatic | Needs a real input behind it |
| Development time | Minutes | Hours, plus testing |
| Maintenance | The browser's job | Yours, forever |
| Visual control | Limited | Total |
💡 Analogy: Native controls are a dependable commuter car — efficient and low-maintenance. A custom control is a modified vehicle: powerful and eye-catching, but it needs a mechanic who knows exactly what they're doing.
Progressive Enhancement
Progressive enhancement means starting from working HTML and layering improvements on top, so that if the JavaScript or CSS fails, the user still has a usable form. Build the native control first; enhance it second.
📖 Key Terms
Progressive enhancement: a baseline that works everywhere, improved for capable browsers.
Graceful degradation: the flip side — a rich experience that still functions when features are missing.
Accessible name: the text a screen reader announces for a control, from a <label>, aria-label, or aria-labelledby.
⚠️ display:none hides from everyone
When you hide a native control to show a custom skin, never use display:none or visibility:hidden — those remove it from the accessibility tree and the tab order too. Use a "visually-hidden" utility that clips the element while leaving it focusable and announced:
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
Widget 1: Toggle Switch (CSS-only)
A toggle switch is just a checkbox in a nicer coat. Because a real checkbox drives it, this needs zero JavaScript — the browser gives us focus, Space-to-toggle, and submission for free. The label wraps everything so clicking anywhere flips the state.
<label class="switch">
<input type="checkbox" name="notifications" class="switch-input">
<span class="switch-track"><span class="switch-thumb"></span></span>
<span class="switch-text">Email notifications</span>
</label>
.switch { display: inline-flex; align-items: center; gap: .6rem; cursor: pointer; }
/* Hide the checkbox but keep it accessible & focusable */
.switch-input {
position: absolute; opacity: 0;
width: 1px; height: 1px;
}
.switch-track {
width: 48px; height: 26px;
background: #ccc; border-radius: 13px;
position: relative; transition: background .2s;
}
.switch-thumb {
position: absolute; top: 3px; left: 3px;
width: 20px; height: 20px;
background: #fff; border-radius: 50%;
transition: transform .2s;
}
/* State driven entirely by the real checkbox */
.switch-input:checked + .switch-track { background: #16a34a; }
.switch-input:checked + .switch-track .switch-thumb { transform: translateX(22px); }
/* Focus ring for keyboard users */
.switch-input:focus-visible + .switch-track {
outline: 3px solid rgba(37, 99, 235, .5); outline-offset: 2px;
}
✅ Why this is the gold standard
No role, no tabindex, no key handlers — the native checkbox already is a toggle to assistive tech. You only styled it. This is progressive enhancement at its best: turn off CSS and you still have a working, labeled checkbox.
Widget 2: Star Rating
There's no native "1–5 stars" control, so this one earns its custom status. The cleanest accessible approach is a radio group styled as stars — again mostly CSS — but here we'll show the hidden-value + role="radiogroup" approach to illustrate wiring ARIA and keyboard support by hand, since you'll meet that pattern constantly.
<div class="rating">
<span id="rating-label">Rate your experience</span>
<input type="hidden" name="rating" id="rating-value" value="0">
<div class="stars" role="radiogroup" aria-labelledby="rating-label">
<button type="button" class="star" role="radio"
aria-checked="false" data-value="1" aria-label="1 star">★</button>
<button type="button" class="star" role="radio"
aria-checked="false" data-value="2" aria-label="2 stars">★</button>
<button type="button" class="star" role="radio"
aria-checked="false" data-value="3" aria-label="3 stars">★</button>
<button type="button" class="star" role="radio"
aria-checked="false" data-value="4" aria-label="4 stars">★</button>
<button type="button" class="star" role="radio"
aria-checked="false" data-value="5" aria-label="5 stars">★</button>
</div>
</div>
The script keeps three things in sync: the hidden input's value, the visual fill, and the ARIA state — plus full arrow-key navigation, which is what a real radio group provides.
const group = document.querySelector('.stars');
const stars = [...group.querySelectorAll('.star')];
const hidden = document.querySelector('#rating-value');
function select(value) {
hidden.value = value;
stars.forEach(star => {
const starValue = Number(star.dataset.value);
star.classList.toggle('filled', starValue <= value);
star.setAttribute('aria-checked', String(starValue === value));
// Only the selected star stays in the tab order (roving tabindex)
star.tabIndex = starValue === value ? 0 : -1;
});
}
// Click to rate
stars.forEach(star => {
star.addEventListener('click', () => {
select(Number(star.dataset.value));
star.focus();
});
});
// Arrow keys move through the group, like native radios
group.addEventListener('keydown', (event) => {
const current = Number(hidden.value) || 1;
let next = current;
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') next = Math.min(5, current + 1);
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') next = Math.max(1, current - 1);
if (next !== current) {
event.preventDefault();
select(next);
stars[next - 1].focus();
}
});
// Make the first star reachable by Tab before any selection
stars[0].tabIndex = 0;
⚠️ Roving tabindex, not five tab stops
A radio group should be a single stop in the tab order — Tab moves into it, arrow keys move between options, Tab moves out. That's the "roving tabindex" technique above: exactly one star has tabindex="0" at a time; the rest are -1. Giving all five tabindex="0" forces keyboard users to Tab through every star, which is exhausting and wrong.
Testing Custom Controls
A custom control isn't done when it looks right with a mouse. Run it through four checks:
- Keyboard only: unplug the mouse. Can you reach, operate, and leave the control? Are focus states visible? Do arrow keys behave like the native equivalent?
- Screen reader: test with VoiceOver, NVDA, or Narrator. Is the control's role, name, and state announced when focused and when it changes?
- Form integration: submit the form and inspect the payload. Is the value present and correct? Does reset clear it? Does pre-filling work?
- No-JS / failure: what happens if the script errors? With the hidden-input and label patterns, a native control should remain.
💡 Lean on the WAI-ARIA Authoring Practices
Before hand-building a widget, check the ARIA Authoring Practices Guide. It documents the exact roles, states, and key bindings expected for switches, sliders, comboboxes, and more — so your custom control behaves the way assistive-tech users already expect.
💡 Consider a vetted library first
For complex needs, mature libraries have solved the accessibility corner cases: Choices.js / Tom Select (selects & tags), Flatpickr (dates), noUiSlider (ranges). Verify their keyboard and screen-reader support and bundle size before adopting — but don't reinvent a combobox on a deadline.
Hands-on Exercise
🏋️ Build an Accessible Star Rating from Scratch
Objective: Produce a rating widget that a keyboard user and a screen-reader user can both operate, backed by a hidden input.
Requirements:
- Five stars in a
role="radiogroup"with an accessible group label. - A hidden input that always holds the current value (0 when unrated).
- Click to set a rating; hovering previews the fill without committing it.
- Keyboard: the group is one Tab stop; Left/Right arrows change the rating (roving tabindex).
- Each star announces its value (e.g. "3 stars") and its checked state.
💡 Hint
Start from the star-rating code in this lesson — it already covers click, arrows, ARIA, and roving tabindex. To add hover preview: on mouseover a star, add a temporary .preview fill class up to that star; on mouseleave of the group, remove .preview and repaint from the committed value. Keep the committed filled class separate from the transient preview class so the mouse never destroys the real selection.
✅ Solution (hover layer added)
const group = document.querySelector('.stars');
const stars = [...group.querySelectorAll('.star')];
const hidden = document.querySelector('#rating-value');
function paint(value, className) {
stars.forEach(star => {
star.classList.toggle(className, Number(star.dataset.value) <= value);
});
}
function commit(value) {
hidden.value = value;
stars.forEach(star => {
const v = Number(star.dataset.value);
star.classList.toggle('filled', v <= value);
star.setAttribute('aria-checked', String(v === value));
star.tabIndex = v === value ? 0 : -1;
});
}
stars.forEach(star => {
const value = Number(star.dataset.value);
star.addEventListener('click', () => { commit(value); star.focus(); });
// Hover preview — transient, never touches the committed value
star.addEventListener('mouseover', () => {
stars.forEach(s => s.classList.remove('preview'));
paint(value, 'preview');
});
});
group.addEventListener('mouseleave', () => {
stars.forEach(s => s.classList.remove('preview'));
});
group.addEventListener('keydown', (event) => {
const current = Number(hidden.value) || 1;
let next = current;
if (['ArrowRight', 'ArrowUp'].includes(event.key)) next = Math.min(5, current + 1);
if (['ArrowLeft', 'ArrowDown'].includes(event.key)) next = Math.max(1, current - 1);
if (next !== current) {
event.preventDefault();
commit(next);
stars[next - 1].focus();
}
});
stars[0].tabIndex = 0;
.star { color: var(--border-color); background: none; border: 0;
font-size: 1.8rem; cursor: pointer; }
.star.filled { color: #f59e0b; } /* committed */
.star.preview { color: #fbbf24; } /* transient hover */
.star:focus-visible { outline: 3px solid rgba(37,99,235,.5); outline-offset: 2px; }
🎯 Quick Quiz
Question 1: Why should you hide a native control with a "visually-hidden" utility instead of display:none when building a custom skin over it?
Question 2: In a custom star-rating built as a radio group, what does the "roving tabindex" technique achieve?
Question 3: A custom tag input shows tokens on screen, but the server receives nothing for that field. What's the most likely fix?
Summary & Quiz
🎉 Key Takeaways
- Default to native. Only build custom when the design or interaction genuinely demands it.
- Progressive enhancement + the hidden-input pattern keep forms submittable and resilient.
- Hide native controls with a visually-hidden utility, never
display:none. - Custom widgets need hand-wired ARIA roles/states, keyboard support, and roving tabindex to match native behavior.
- Test with keyboard only, a screen reader, and by inspecting the submitted form data — and lean on the ARIA Authoring Practices and vetted libraries.
📚 Further Reading
🚀 What's Next?
You can now build widgets that look bespoke yet behave like first-class form controls. Next, Accessible Form Design pulls these threads together into a complete, inclusive form — labels, grouping, error handling, and focus management from start to submit.
🎉 Great work!
Custom on the outside, native at heart — that's how you ship widgets nobody gets locked out of.