Custom form controls: making inputs look the way your design requires without breaking labels, keyboard access, or screen reader support. The rule is simple – enhance the native control, do not replace it with a div that only works with a mouse.
Why native controls first
Browser inputs come with behaviour built in: focus styles, keyboard interaction, validation, autofill, and accessibility mappings. When you hide a checkbox and draw a fancy box with CSS, you inherit the job of replicating all of that. Sometimes it is worth it. Often it is not.
Before customising, ask whether plain CSS on the native element gets you close enough. Modern browsers allow styling radios, checkboxes, and ranges more than they used to. Reach for JavaScript-heavy widgets only when the design or behaviour genuinely needs it.
Pattern 1: visually hidden native input
The safest custom pattern keeps the real <input> in the DOM, moves it off-screen or makes it visually tiny, and uses a styled label as the visible control. Clicks on the label still toggle the input because the label is associated correctly.
<label class="choice">
<input class="choice__input" type="checkbox" name="newsletter" value="yes">
<span class="choice__ui" aria-hidden="true"></span>
<span class="choice__label">Email me monthly tips</span>
</label>
CSS might hide .choice__input with opacity and position, style .choice__ui as a square box, and show a tick when :checked + .choice__ui. The text lives in .choice__label. Screen readers still see a checkbox with its name from the label text.
Important details:
- Do not use
display: noneorvisibility: hiddenon the input – that removes it from accessibility trees in some cases and breaks focus. - Keep a visible focus indicator on the input or transfer a clear
:focus-visiblestyle to the custom UI via sibling selectors. - Mark purely decorative spans with
aria-hidden="true"so screen readers do not read ’empty group’ noise.
Pattern 2: custom radio group
Radio buttons must share a name and sit in a fieldset with a legend. Custom styling follows the same hidden-input pattern:
<fieldset class="plan-options">
<legend>Choose a plan</legend>
<label class="plan">
<input class="plan__input" type="radio" name="plan" value="basic" checked>
<span class="plan__card">
<span class="plan__title">Basic</span>
<span class="plan__price">£9 / month</span>
</span>
</label>
<label class="plan">
<input class="plan__input" type="radio" name="plan" value="pro">
<span class="plan__card">
<span class="plan__title">Pro</span>
<span class="plan__price">£19 / month</span>
</span>
</label>
</fieldset>
Arrow keys move between radios in the same group in most browsers when focus is on one of them – native behaviour you keep for free. Style the selected card with .plan__input:checked + .plan__card.
Pattern 3: toggle switch
A toggle is either a styled checkbox (on/off) or a button with aria-pressed. For form submission, prefer checkbox semantics:
<label class="switch">
<span class="switch__label">Dark mode</span>
<input class="switch__input" type="checkbox" name="dark_mode" role="switch">
<span class="switch__track" aria-hidden="true"></span>
</label>
role="switch" hints to assistive tech that this checkbox behaves like a switch. Support is good in modern browsers but test with NVDA or VoiceOver if the control is critical. The visible label text must describe what the switch does.
When you need ARIA widgets
Some UI patterns are not native HTML: tab panels, comboboxes, date pickers built from scratch. Those require ARIA roles, keyboard handlers, and often live regions for updates. That is advanced work – easy to get wrong.
HTML gives you usable building blocks that avoid most of the pain:
<select>for dropdowns – accessible by default, mobile-friendly<dialog>for modals – focus management built in when used correctly<details>and<summary>for disclosure widgetsinput type="date"where browser support matches your audience
If you must build a custom select, follow the ARIA Authoring Practices Guide patterns exactly – roving tabindex, aria-expanded, aria-activedescendant, typeahead, Escape to close. Missing one piece makes the control unusable for keyboard or screen reader users.
Styling selects and file inputs
Selects and file inputs are awkward to style consistently across browsers. Common approach: wrap them, accept limited styling on the native element, or use the hidden-native-input pattern with a styled label that triggers a hidden file input:
<label class="file-upload">
<span class="file-upload__button">Choose file</span>
<input class="file-upload__input" type="file" name="attachment" accept=".pdf,.doc,.docx">
</label>
<p id="file-name" aria-live="polite">No file chosen</p>
JavaScript can update #file-name when the user picks a file. aria-live="polite" announces the change without stealing focus. The native input still handles the file picker dialog.
Focus and contrast
Custom controls often fail focus visibility. Browsers outline native elements; your replacement div might show nothing when tabbed to. Always define :focus-visible styles that meet contrast guidelines.
Colour alone must not indicate state. A selected plan card should have a border, icon, or text change – not just a background colour shift that colour-blind users cannot distinguish.
Disabled and readonly states
Use the native disabled attribute when a control cannot be used. Style disabled custom widgets to match – and avoid pointer events on decorative layers that block the input underneath.
readonly applies to text inputs that users can focus and copy but not edit. Do not use disabled for fields you still need to submit; disabled values are not sent with the form.
Anti-patterns to avoid
- Div as button.
<div onclick="...">is not keyboard accessible unless you add tabindex, Enter/Space handlers, and ARIA – use<button>. - Placeholder as the only label. Still wrong on custom widgets.
- Removing outline without replacement. Focus must be visible.
- Click handlers on parent divs that do not forward activation to the hidden input – breaks keyboard use.
- Building a full design system of faux inputs before trying native styling and
accent-color.
Testing custom controls
Every custom control gets the same test pass as a plain form:
- Tab to the control. Is focus visible?
- Activate with Space or Enter where appropriate (checkbox, button, switch).
- Use arrow keys on radio groups.
- Listen with a screen reader. Does the name, role, and state (checked, pressed, expanded) announce correctly?
- Submit the form. Does the correct value reach the server?
Custom form controls are mostly CSS and discipline, not clever JavaScript. Keep the native input, associate labels properly, and treat accessibility as part of the design – not a bug fix at the end.

