1Building Interactive UI Controls with JavaScript
▶
Building interactive UI controls entirely through JavaScript is one of the most practical and demanding skills in front-end development. Rather than relying on a framework or library, writing controls by hand gives you precise command over every element, every event, and every state transition. This approach demands that you think simultaneously about structure, behavior, appearance, and accessibility — and that you keep all four dimensions consistent at all times. The reward is a deep understanding of the browser's programming model and the ability to craft lightweight, dependency-free components that can be dropped into any project.
An "interactive UI control" can be almost anything: a custom dropdown select, an accordion panel, a modal dialog, a date picker, a tag input field, a color swatch chooser, or a multi-step wizard. What every one of these shares is a predictable anatomy — a hierarchy of DOM elements, a set of discrete states, a collection of user interactions that trigger transitions between those states, and a visual representation that always reflects the current state. When you understand that anatomy, you can build any control, no matter how complex.
The sections below walk through that anatomy stage by stage, from the first pencil sketch all the way to a reusable, accessible, self-cleaning component.
Planning the UI Control Structure
Experienced developers resist the urge to open a code editor the moment they receive a design. A few minutes of deliberate planning eliminates hours of refactoring later. Planning a UI control means answering four concrete questions: What does the element tree look like? Which nodes already exist in the HTML and which must be created programmatically? What states can the control be in? And what user gestures cause it to move between those states?
Start by sketching — even on paper — the element hierarchy the control requires. Consider a custom dropdown list. Its hierarchy might look like this:
div.dropdown ← outer wrapper, positioning context
button.dropdown__trigger ← the visible "selected value" button
ul.dropdown__list ← the panel that appears on open
li.dropdown__item ← one per option
li.dropdown__item
...
Knowing this hierarchy before writing a single line of code tells you exactly how many createElement calls you need, what class names and ARIA roles to assign, and which element will be the event delegation target.
Next, decide which elements are dynamic versus anchored. An anchored element already exists in the HTML — perhaps a <div id="dropdown-mount"> placed by a server-rendered template. A dynamic element is one you create entirely in JavaScript. In the dropdown example the outer wrapper and all internal nodes are dynamic; only the mount point is anchored. Knowing the boundary prevents you from accidentally overwriting existing content or duplicating structure.
State definition is the most intellectually demanding part of the plan. Write out every distinct condition the control can be in as a list of named states or a set of state variables:
- open / closed — is the list panel visible?
- selectedIndex — which item (if any) is currently chosen?
- focusedIndex — which item is highlighted by keyboard navigation?
- disabled — is the whole control inert?
Finally, map user interactions to state changes. This is essentially a transition table:
| User Action | Current State Condition | Resulting State Change |
|---|---|---|
| Click trigger button | closed | open = true |
| Click trigger button | open | open = false |
| Click list item | open | selectedIndex = clicked index, open = false |
| Press Escape | open | open = false, focus returns to trigger |
| Press ArrowDown | open | focusedIndex increments |
| Press Enter on item | open | selectedIndex = focusedIndex, open = false |
| Click outside control | open | open = false |
With this table in hand, writing event handlers becomes mechanical rather than speculative. You will never discover a missing edge case mid-demo.
Constructing Control Elements with JavaScript
document.createElement(tagName) is the foundation of programmatic DOM construction. It creates a new element node in memory — detached from the document — so you can configure it fully before it ever touches the live page. Inserting a fully configured subtree in one operation is both cleaner and, for large trees, more performant than inserting a bare element and then modifying it while it is already in the DOM.
Here is how the dropdown's element tree is built in code:
function buildDropdown(options) {
// Outer wrapper
const wrapper = document.createElement('div');
wrapper.className = 'dropdown';
// Trigger button
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'dropdown__trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
trigger.textContent = options[0].label; // default label
// List panel
const list = document.createElement('ul');
list.className = 'dropdown__list';
list.setAttribute('role', 'listbox');
list.setAttribute('tabindex', '-1');
list.hidden = true;
// One item per option
options.forEach((opt, index) => {
const item = document.createElement('li');
item.className = 'dropdown__item';
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
item.dataset.value = opt.value;
item.dataset.index = index;
item.textContent = opt.label;
list.appendChild(item);
});
wrapper.appendChild(trigger);
wrapper.appendChild(list);
return wrapper;
}
Several practices in this snippet deserve emphasis. trigger.type = 'button' prevents the browser from treating the button as a form submit control — a common and silent bug. Setting aria-haspopup="listbox" immediately tells assistive technologies what kind of panel this button controls, before the panel even exists in the DOM. Using list.hidden = true is preferable to toggling a CSS class initially because it is semantically meaningful: the element is genuinely not present for users or assistive technology until it is unhidden.
The dataset property provides a clean way to embed data values — like an option's underlying value and its positional index — directly in the markup. This means event handlers can read the data from the element itself rather than from a separate data structure, which simplifies coordination.
Once the tree is assembled, inject it into the live document:
const mountPoint = document.getElementById('dropdown-mount');
mountPoint.appendChild(wrapper);
This single appendChild call triggers exactly one reflow. If you had instead inserted wrapper bare and then called appendChild for trigger and then list, you would trigger three separate reflows. For small controls the difference is imperceptible, but the habit of building first, inserting once, scales well to large trees.
When insertion order matters — for example, inserting a new control before an existing sibling rather than at the end of the container — use insertBefore(newNode, referenceNode) or the more expressive insertAdjacentElement(position, element), where position can be 'beforebegin', 'afterbegin', 'beforeend', or 'afterend'.
Attaching Event Listeners to Control Components
With elements in the DOM, the control is visible but inert. Event listeners give it life. The guiding principle is to attach the minimum number of listeners necessary to handle all interactions, and to attach them in a way that allows clean removal later.
For the dropdown, three listeners cover nearly everything: one on the trigger for click, one on the list for click (delegation), and one on the document for a "click outside" dismissal.
function attachListeners(state, elements) {
const { wrapper, trigger, list } = elements;
function onTriggerClick(e) {
e.stopPropagation(); // prevent document click from immediately closing
toggleOpen(state, elements);
}
function onListClick(e) {
const item = e.target.closest('.dropdown__item');
if (!item) return;
selectItem(state, elements, parseInt(item.dataset.index, 10));
}
function onDocumentClick() {
if (state.open) {
closeDropdown(state, elements);
}
}
function onKeyDown(e) {
handleKeyboard(e, state, elements);
}
trigger.addEventListener('click', onTriggerClick);
list.addEventListener('click', onListClick);
document.addEventListener('click', onDocumentClick);
wrapper.addEventListener('keydown', onKeyDown);
// Return a cleanup function that references the exact same handler instances
return function detach() {
trigger.removeEventListener('click', onTriggerClick);
list.removeEventListener('click', onListClick);
document.removeEventListener('click', onDocumentClick);
wrapper.removeEventListener('keydown', onKeyDown);
};
}
Notice that each handler is a named function expression stored in a local variable. This is essential for removeEventListener to work. removeEventListener identifies a listener by reference equality — if you pass an anonymous function to addEventListener, you can never pass the same reference to removeEventListener, so the listener can never be removed, leading to a memory leak and potentially duplicate behavior if the control is ever re-initialized.
Event delegation on list is the mechanism behind onListClick. Rather than attaching a click listener to every <li> element — and having to re-attach when options are added or removed — a single listener on the parent <ul> catches all clicks that bubble up from any child. The line e.target.closest('.dropdown__item') walks up the DOM from the actual clicked element until it finds a matching ancestor (or returns null if the click was on the list background). This pattern handles scenarios where list items themselves contain child elements (like icons or badges) without any extra code.
e.stopPropagation() on the trigger click is an important detail. Without it, the click event would bubble to the document, firing onDocumentClick in the same synchronous tick and immediately closing the dropdown that was just opened. e.preventDefault() serves a different purpose: it cancels the browser's native behavior for the event (for example, preventing a link from navigating, or preventing a form from submitting). You rarely need both together, and overusing them makes debugging harder — apply each only where genuinely needed.
Managing Control State Dynamically
State management is the heart of an interactive control. Without a single, authoritative source of truth for the control's condition, the visual representation and the logical state inevitably drift apart, producing mysterious bugs.
The simplest approach is a plain JavaScript object that holds all state variables:
const state = {
open: false,
selectedIndex: 0,
focusedIndex: -1,
disabled: false
};
Every function that changes the control accepts this object and mutates it, then calls a render-style function to synchronize the DOM with the new state values. This separation — "change state, then reflect state" — is the same conceptual model used by React, Vue, and every other modern UI framework, just without the framework machinery.
function applyState(state, elements) {
const { trigger, list, items } = elements;
// Reflect open/closed
list.hidden = !state.open;
trigger.setAttribute('aria-expanded', state.open ? 'true' : 'false');
// Reflect selected item in trigger label
trigger.textContent = items[state.selectedIndex].textContent;
// Reflect aria-selected on all items
items.forEach((item, i) => {
item.setAttribute('aria-selected', i === state.selectedIndex ? 'true' : 'false');
item.classList.toggle('dropdown__item--focused', i === state.focusedIndex);
});
// Reflect disabled state
trigger.disabled = state.disabled;
wrapper.classList.toggle('dropdown--disabled', state.disabled);
}
By centralizing all DOM mutations in applyState, you guarantee that the visual and the logical are always synchronized. You never have a situation where aria-expanded says "true" but the list is actually hidden, because both are driven by the same state.open variable in the same function call.
Data attributes on DOM elements serve as a secondary, convenient place to store state that is tightly coupled to a specific element. In the dropdown, item.dataset.index = index was set during construction. In onListClick, the handler reads item.dataset.index to know which item was clicked, without needing to search through a separate array. This is not a replacement for the central state object — it is a lightweight tag for element-specific identity.
The principle of atomic updates means: do not update half the state and then do something slow in the middle. Update all related state variables and then call applyState once:
function selectItem(state, elements, index) {
// Atomic: update all related state at once
state.selectedIndex = index;
state.focusedIndex = index;
state.open = false;
// Then reflect the complete new state in one pass
applyState(state, elements);
// Then handle side effects
elements.trigger.focus();
}
If you updated state.open first, then called some function that triggered a repaint, then updated state.selectedIndex, the user might see a flash of inconsistent UI. Grouping all mutations before any rendering call prevents that entirely.
Modifying and Updating Control Elements at Runtime
Controls often need to change their structure after initial construction — loading options from an API, adding items to a list, removing an entry after the user deletes it. JavaScript's DOM manipulation methods handle all of these scenarios.
To add a new option to the dropdown at runtime:
function addOption(state, elements, option) {
const { list, items } = elements;
const index = items.length;
const item = document.createElement('li');
item.className = 'dropdown__item';
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', 'false');
item.dataset.value = option.value;
item.dataset.index = index;
item.textContent = option.label;
list.appendChild(item);
items.push(item); // keep the elements.items array in sync
// No need to re-attach listeners — event delegation on 'list' covers new items
}
Because click handling uses delegation on the parent <ul>, new items are automatically covered without any additional addEventListener calls. This is a major ergonomic and performance advantage of delegation for dynamic content.
To update an item's label:
function updateOptionLabel(elements, index, newLabel) {
const item = elements.items[index];
item.textContent = newLabel;
// If this item is currently selected, update the trigger label too
if (index === state.selectedIndex) {
elements.trigger.textContent = newLabel;
}
}
Prefer textContent over innerHTML for inserting plain text. innerHTML parses its argument as HTML, which is both slower and a potential XSS vector if the string ever comes from user input. Use innerHTML only when you intentionally need to insert markup, and sanitize the input first.
To remove an option:
function removeOption(state, elements, index) {
const item = elements.items[index];
item.remove(); // modern, clean equivalent of item.parentNode.removeChild(item)
elements.items.splice(index, 1);
// Rebuild data-index attributes after removal to keep them accurate
elements.items.forEach((el, i) => {
el.dataset.index = i;
});
// Adjust selectedIndex if needed
if (state.selectedIndex >= elements.items.length) {
state.selectedIndex = elements.items.length - 1;
}
applyState(state, elements);
}
When elements are fully replaced rather than modified (for example, clearing the list and rebuilding it from scratch), you must be careful about event listeners attached directly to the removed elements. With delegation, this is a non-issue — the parent listener survives. But if any removed element had direct listeners, those listeners are garbage-collected along with the element as long as no other reference to the element exists. If you hold a reference to those elements in a variable (like elements.items), the elements will not be garbage-collected and the listeners will leak. Always clear your reference arrays when rebuilding.
Ensuring Accessibility in JavaScript-Built Controls
A control built entirely in JavaScript with <div> and <span> elements is, by default, invisible to users of screen readers and keyboard-only navigation. Accessibility is not a finishing touch — it is a structural requirement that must be woven in from the planning stage.
The Web Accessibility Initiative's ARIA specification (WAI-ARIA) provides a vocabulary of roles, properties, and states that can be applied to any HTML element to communicate its semantics to assistive technology. For a custom dropdown acting as a listbox, the correct roles are:
| Element | ARIA Role / Attribute | Purpose |
|---|---|---|
Trigger <button> |
aria-haspopup="listbox" |
Tells screen readers a listbox popup is associated |
Trigger <button> |
aria-expanded="true|false" |
Communicates whether the popup is currently open |
Trigger <button> |
aria-controls="list-id" |
Links the trigger to the element it controls |
List <ul> |
role="listbox" |
Identifies the element as a list of selectable options |
Each item <li> |
role="option" |
Identifies each child as a selectable option |
Each item <li> |
aria-selected="true|false" |
Communicates selection state to screen readers |
Critically, ARIA state attributes must be kept in sync with JavaScript state. If state.open becomes true, trigger.setAttribute('aria-expanded', 'true') must happen in the same applyState call. Screen readers monitor these attribute changes through the browser's accessibility tree; a stale ARIA attribute is actively misleading.
Keyboard navigation is the other pillar of accessibility. The ARIA Authoring Practices Guide (APG) specifies keyboard interaction patterns for every ARIA widget type. For a listbox, the expected behaviors are:
- Enter / Space on the trigger: open the list
- ArrowDown: move focus to the next option
- ArrowUp: move focus to the previous option
- Enter on a focused option: select it and close
- Escape: close without selecting, return focus to trigger
- Tab: close the list and move focus to the next focusable element
function handleKeyboard(e, state, elements) {
if (state.disabled) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault(); // prevent page scroll
if (!state.open) {
openDropdown(state, elements);
} else {
state.focusedIndex = Math.min(
state.focusedIndex + 1,
elements.items.length - 1
);
applyState(state, elements);
elements.items[state.focusedIndex].focus();
}
break;
case 'ArrowUp':
e.preventDefault();
state.focusedIndex = Math.max(state.focusedIndex - 1, 0);
applyState(state, elements);
elements.items[state.focusedIndex].focus();
break;
case 'Enter':
if (state.open && state.focusedIndex >= 0) {
selectItem(state, elements, state.focusedIndex);
}
break;
case 'Escape':
closeDropdown(state, elements);
elements.trigger.focus();
break;
case 'Tab':
if (state.open) closeDropdown(state, elements);
break;
}
}
e.preventDefault() on arrow keys is essential — without it, pressing ArrowDown also scrolls the page, which is disorienting. element.focus() programmatically moves the browser's focus indicator to the target element, guiding keyboard users through the control in a predictable sequence. For list items to be focusable, they need either tabindex="0" (participates in normal tab order) or tabindex="-1" (focusable via script but not reachable by Tab key). Items in a listbox typically use tabindex="-1" so that Tab exits the widget rather than cycling through every option.
Encapsulating Controls for Reusability
A control written as scattered functions and global variables works once, in one place. A control written as a self-contained unit can be instantiated multiple times, on any page, by any developer, with confidence that instances will not interfere with each other. Encapsulation achieves this by making each instance own its own state, its own element references, and its own event listeners.
The two most common encapsulation patterns are the factory function and the class. Both are valid; the factory function is slightly simpler and avoids the subtleties of this binding:
function createDropdown(mountElement, options = [], config = {}) {
// --- Private state ---
const state = {
open: false,
selectedIndex: config.defaultIndex ?? 0,
focusedIndex: -1,
disabled: config.disabled ?? false
};
// --- Build DOM ---
const elements = buildElements(options);
mountElement.appendChild(elements.wrapper);
// --- Attach listeners, get cleanup function ---
const detach = attachListeners(state, elements);
// --- Initialize visual state ---
applyState(state, elements);
// --- Public API ---
return {
open() { openDropdown(state, elements); },
close() { closeDropdown(state, elements); },
select(i) { selectItem(state, elements, i); },
enable() { state.disabled = false; applyState(state, elements); },
disable() { state.disabled = true; applyState(state, elements); },
getValue() { return options[state.selectedIndex].value; },
destroy() {
detach(); // remove all event listeners
elements.wrapper.remove(); // remove DOM from page
// Nullify references to allow GC
Object.keys(elements).forEach(k => { elements[k] = null; });
}
};
}
Usage becomes as simple as:
const colourPicker = createDropdown(
document.getElementById('colour-mount'),
[
{ value: 'red', label: 'Red' },
{ value: 'green', label: 'Green' },
{ value: 'blue', label: 'Blue' }
],
{ defaultIndex: 1 }
);
// Later, programmatically open it:
colourPicker.open();
// Read the current value:
console.log(colourPicker.getValue()); // 'green'
// When the surrounding component unmounts:
colourPicker.destroy();
Each call to createDropdown creates a fresh closure with its own state object and its own elements object. Ten dropdowns on the same page will each have ten completely independent closures. Because state and elements are never exposed on the returned object, consumers cannot accidentally corrupt the control's internal state — they can only call the documented public methods.
The destroy method is not optional for long-lived applications. Single-page applications that mount and unmount components frequently will leak memory if listeners and DOM nodes from destroyed components persist. Calling detach() — the cleanup function returned by attachListeners — removes every listener that was added, including the document-level click listener that would otherwise persist indefinitely. Calling elements.wrapper.remove() detaches the DOM subtree. Nullifying the elements references removes the last obstacle to garbage collection.
The same pattern scales naturally to a class-based approach for developers who prefer that syntax:
class Dropdown {
#state;
#elements;
#detach;
constructor(mountElement, options = [], config = {}) {
this.#state = {
open: false,
selectedIndex: config.defaultIndex ?? 0,
focusedIndex: -1,
disabled: config.disabled ?? false
};
this.#elements = buildElements(options);
mountElement.appendChild(this.#elements.wrapper);
this.#detach = attachListeners(this.#state, this.#elements);
applyState(this.#state, this.#elements);
}
open() { openDropdown(this.#state, this.#elements); }
close() { closeDropdown(this.#state, this.#elements); }
getValue() { return options[this.#state.selectedIndex].value; }
destroy() {
this.#detach();
this.#elements.wrapper.remove();
}
}
The # prefix (private class fields, supported in all modern browsers) enforces the same privacy guarantee as the factory function closure. Internal state is inaccessible from outside the class, preventing accidental interference from consumer code.
Whether you use a factory function or a class, the discipline is the same: build the full element tree before inserting it, keep a single authoritative state object, reflect that state through one applyState function, use event delegation wherever possible, keep ARIA attributes synchronized with JavaScript state, and provide a destroy method that leaves no listeners or orphaned nodes behind. These principles, applied consistently, produce controls that are efficient, accessible, predictable, and ready for production.