Chapter 11
Component thinking without a framework
How to turn interface patterns into reusable components with clear boundaries, inputs, outputs, and accessibility contracts, and build a tiny vanilla JavaScript component library with no npm and no build step.
By now you have built a disclosure in one chapter, tabs in another, a dialog in a third, and form fields with accessible errors in several. If you copied the disclosure code into three pages, you probably fixed a bug in one copy and not the others, and now the three behave slightly differently. A teammate who wants a disclosure has to read your code to learn which attributes it needs and which events it sends, if it sends any at all.
A component solves that. It is a piece of interface with its own structure, styling, behavior, state, inputs, outputs, and rules, packaged so you can use it again without reading its insides. Frameworks call these props, events, and slots, and they add a lot of machinery around them. You can understand the idea with plain modules, and once you do, every framework’s version will look familiar.
The mental model
A component is a boundary. Inside the boundary are the details only the component needs to know: its internal state, its private helper functions, and the exact DOM updates it makes. Outside the boundary is everything a user of the component sees: the HTML it expects, the options it accepts, the events it sends, and the functions it exposes. That outside surface is the component’s public API.
The four questions from the introduction apply to each component on its own:
- What state does it remember? A disclosure remembers whether it is open. Tabs remember which tab is selected. That state is internal, and the page only learns about it through events.
- What events change that state? Clicks and key presses from the user, plus calls from the page through the public API, such as
disclosure.open(). - What updates? The component updates its own DOM and ARIA attributes. It never reaches outside its root element to change the page.
- How is it usable and accessible? Each component has an accessibility contract: the keyboard behavior, roles, and states it promises to provide, and what it requires from your markup to keep that promise.
Information flows in two directions across the boundary. Inputs come in as options. Outputs go out as events. The page configures a component and listens to it, and the component never needs to know what page it is on.
Boundaries
Choosing a boundary means deciding what belongs together. A good rule is that a component owns one root element and everything inside it, and it finds its parts relative to that root. Chapter 10’s accordion bug, where every button opened the first panel, came from searching the whole document instead of the component’s own root.
In this chapter each component has a root marked with a data attribute such as data-disclosure, and its parts are marked with hooks such as data-disclosure-button. The page finds every root and initializes it:
document.querySelectorAll('[data-disclosure]').forEach((root) => initDisclosure(root));
Some interface patterns are mostly markup and CSS. A card or an empty state has structure and variants but little or no behavior, so it can be a documented HTML pattern with a class name, with no JavaScript at all. Add a module only when there is behavior to share.
Inputs: data attributes and options
A component’s inputs are its configuration. Should the disclosure start open? Which tab is selected first? What message should a required field show? Frameworks call these props.
Each module in this chapter accepts inputs from two places. Data attributes on the root let you configure a component in HTML, which suits content authors and server-rendered pages. An options object passed to the init function lets other JavaScript configure it. When both are present, the order is predictable: the component’s defaults come first, data attributes override the defaults, and the options object overrides both.
const defaults = { open: false };
const fromMarkup = {};
if (root.dataset.open !== undefined) fromMarkup.open = root.dataset.open !== 'false';
const config = { ...defaults, ...fromMarkup, ...options };
Data attributes are always strings, so each component converts the ones it reads. Here data-open with no value, or with any value other than "false", means open.
Outputs: custom events
A component’s outputs tell the page that something happened: a panel opened, a tab changed, a dialog closed with a particular result. The browser already has a mechanism for this, which is events. You can create your own with CustomEvent:
root.dispatchEvent(new CustomEvent('ui-disclosure:toggle', {
bubbles: true,
detail: { open: true },
}));
Three details matter here:
- The name is namespaced.
ui-disclosure:togglecan’t collide with a native event or with another library’stoggleevent. Theui-prefix marks it as part of your library, and the part before the colon names the component. The browser accepts any string as an event name. bubbles: trueis required for delegation. Without it, the event fires only on the root, and a listener ondocumentnever hears it.detailcarries the data. Listeners readevent.detail.open, andevent.targetis the component’s root, so a single listener can tell components apart.
Because these are ordinary DOM events, the page listens the way it listens for clicks:
document.addEventListener('ui-disclosure:toggle', (event) => {
console.log(event.target, event.detail.open);
});
Internal state, external state, and the public API
Internal state belongs to the component. The page shouldn’t reach in and change it, because the component’s DOM and ARIA attributes would no longer match. External state belongs to the page, such as the list of tasks or the user’s saved theme. Sometimes a page needs to change a component’s internal state, for example opening a disclosure when the URL contains its id. The component allows that through its public API: the object its init function returns.
const shipping = initDisclosure(document.querySelector('#shipping'));
shipping.open();
Everything not in that returned object stays private inside the module. The module’s render and setOpen functions can change freely as long as open, close, toggle, isOpen, and destroy keep working. This lets you fix bugs inside a component without breaking every page that uses it.
destroy removes the component’s listeners. Each module attaches its listeners with the signal from one AbortController, so a single controller.abort() call removes them all. This matters when part of a page is replaced and you don’t want old listeners running against removed elements.
Composition, variants, and states
Components combine. A dialog can contain a form field, and a tab panel can contain a disclosure. That works as long as each component searches only inside its own root and doesn’t assume what else is on the page. Frameworks call the open regions of a component, where you put your own content, slots. In plain HTML the slot is simply the element you are allowed to fill: the disclosure’s panel and the dialog’s body can hold any content.
Variants are visual versions of the same component, such as an info alert and an error alert. Use a modifier class for these, because variants are a styling concern: alert alert--error. States are conditions the component moves between, such as open, selected, or invalid. Represent states with the ARIA attribute that already describes them, and style from that attribute so the visual state and the announced state can’t disagree:
.disclosure__button[aria-expanded="true"] .disclosure__icon {
transform: rotate(90deg);
}
The smallest version: a disclosure module
Here is a complete disclosure component and a page that uses it. The panel is visible in the HTML, and the module hides it when it initializes, so the content is still readable if the script fails. If you need only show and hide with no events or API, the native <details> and <summary> elements do that without any JavaScript.
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Disclosure component</title>
</head>
<body>
<div class="disclosure" data-disclosure id="shipping">
<button type="button" class="disclosure__button" data-disclosure-button>
Shipping details
</button>
<div class="disclosure__panel" data-disclosure-panel>
<p>Orders ship within two business days.</p>
</div>
</div>
<div class="disclosure" data-disclosure data-open>
<button type="button" class="disclosure__button" data-disclosure-button>
Returns
</button>
<div class="disclosure__panel" data-disclosure-panel>
<p>Return any item within 30 days.</p>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>
// components/disclosure.js
const defaults = { open: false };
export function initDisclosure(root, options = {}) {
const button = root.querySelector('[data-disclosure-button]');
const panel = root.querySelector('[data-disclosure-panel]');
if (!button || !panel) {
throw new Error('initDisclosure needs [data-disclosure-button] and [data-disclosure-panel] inside the root.');
}
const fromMarkup = {};
if (root.dataset.open !== undefined) fromMarkup.open = root.dataset.open !== 'false';
const config = { ...defaults, ...fromMarkup, ...options };
const state = { open: config.open };
const controller = new AbortController();
function render() {
button.setAttribute('aria-expanded', String(state.open));
panel.hidden = !state.open;
}
function setOpen(open) {
if (open === state.open) return;
state.open = open;
render();
root.dispatchEvent(new CustomEvent('ui-disclosure:toggle', {
bubbles: true,
detail: { open: state.open },
}));
}
button.addEventListener('click', () => setOpen(!state.open), {
signal: controller.signal,
});
render();
return {
open: () => setOpen(true),
close: () => setOpen(false),
toggle: () => setOpen(!state.open),
isOpen: () => state.open,
destroy: () => controller.abort(),
};
}
// app.js
import { initDisclosure } from './components/disclosure.js';
document.querySelectorAll('[data-disclosure]').forEach((root) => initDisclosure(root));
document.addEventListener('ui-disclosure:toggle', (event) => {
console.log(`${event.target.id || 'disclosure'} is now ${event.detail.open ? 'open' : 'closed'}`);
});
The first render doesn’t dispatch an event, because nothing changed. The component is only reporting its starting state to the DOM.
The custom element version
Chapter 8 introduced custom elements. The same module can sit behind one, which lets the browser handle initialization:
// components/ui-disclosure.js
import { initDisclosure } from './disclosure.js';
class UiDisclosure extends HTMLElement {
connectedCallback() {
this.disclosure = initDisclosure(this);
}
disconnectedCallback() {
this.disclosure?.destroy();
}
}
customElements.define('ui-disclosure', UiDisclosure);
With <ui-disclosure> as the root instead of a <div data-disclosure>, the browser calls connectedCallback for every instance, including ones added to the page later, so you never call init by hand or forget to. disconnectedCallback cleans up when the element is removed. The tradeoffs are that the element has no behavior until its definition loads, the name must contain a hyphen, and if the module ran before the element’s children were parsed, connectedCallback could find an empty element. Module scripts run after the document is parsed, which avoids that here. The init-function approach in this chapter is simpler to debug and works on any existing markup, and the custom element is a thin layer you can add on top later.
Accessibility check
Every component in this library has an accessibility contract, and you test the contract the same way each time: use the component with only a keyboard, check its role, name, and state in the devtools Accessibility pane, then listen with a screen reader.
The alert component is the one most likely to fail this check. A tempting first version creates a new <div role="alert"> with its message and appends it to the page in one step. It looks right, but some screen reader and browser combinations announce nothing, because they start watching a live region only once it exists in the page. Content that arrives in the same moment as the region can be missed. For someone who can’t see the screen, a “Your changes couldn’t be saved” message that is never announced means they move on believing their work is safe.
The fix is in the component’s HTML expectations. The live region must already be in the page, empty, and the component inserts messages into it:
<div class="alert-region" data-alert-region role="status"></div>
The second problem the check finds is focus. An alert with a Dismiss button removes itself when dismissed, and when a focused element is removed, focus falls back to the <body>. The component now remembers which element had focus when the alert was shown, usually the button that triggered it, and returns focus there when a keyboard user dismisses the alert.
Debugging challenge: the event nobody hears
Change the disclosure’s dispatchEvent call so the options no longer include bubbles: true. Open the page and toggle a disclosure. The panel opens and closes correctly, but the message in app.js never logs. There is no error.
Start with the most likely hypothesis: the event isn’t being dispatched at all. Add console.log('dispatching') just before dispatchEvent and toggle again. It logs, so the event is created and sent. Next hypothesis: the listener is on the wrong element or uses the wrong name. Temporarily add a listener directly on the root:
document.querySelector('#shipping').addEventListener('ui-disclosure:toggle', () => {
console.log('heard on the root');
});
That one logs, and the one on document still doesn’t. The event fires and the name is right, but it never travels up the tree. Custom events don’t bubble unless you ask them to, which is the opposite of click. Put bubbles: true back, and the document listener hears every disclosure on the page. Then delete the temporary listener and logs.
This works because…
The disclosure component works because everything it does is scoped to its root. It finds its button and panel inside the root, keeps its open state in one object, and updates aria-expanded and hidden from that object in one render function. Its inputs are merged in a fixed order, so markup and JavaScript can both configure it without surprises. Its output is a namespaced CustomEvent that bubbles, so the page can listen once on document and use event.target to tell which disclosure changed. The page can drive it through the returned object without touching its internals, and destroy removes every listener at once through the shared AbortController signal.
Project: a tiny vanilla component library
Build a small library of components that the earlier chapters introduced: disclosure, tabs, alert, dialog, and form field. There is no package, no npm, and no build step, just a folder of modules and a demo page that uses them.
ui-library/
index.html demo page with every component
app.js initializes components and logs their events
styles.css
components/
disclosure.js
tabs.js
alert.js
dialog.js
form-field.js
docs/
disclosure.md one documentation page per component
...
Each component is a module that exports an init function taking a root element and an options object. It reads data attributes, merges options in the order defaults, then data attributes, then options, and returns a public API with at least destroy. It dispatches bubbling ui-<name>:<event> events, where <name> is the component’s short name (disclosure, tabs, alert, dialog, field).
These modules replace the chapter 9 versions. They keep the same hooks where they can, and the dialog now takes the <dialog> element itself and finds its open buttons by id.
Tabs
The tabs markup follows the ARIA tabs pattern from chapter 9. All panels are visible in the HTML, so content stays readable without JavaScript. The module selects one tab, hides the other panels, and adds arrow-key navigation with a roving tabindex.
<div class="tabs" data-tabs data-selected="tab-yarn">
<div role="tablist" aria-label="Install command" data-tablist>
<button type="button" role="tab" id="tab-npm" aria-controls="panel-npm" data-tab>npm</button>
<button type="button" role="tab" id="tab-yarn" aria-controls="panel-yarn" data-tab>Yarn</button>
</div>
<div role="tabpanel" id="panel-npm" aria-labelledby="tab-npm" tabindex="0">
<pre><code>npm install example</code></pre>
</div>
<div role="tabpanel" id="panel-yarn" aria-labelledby="tab-yarn" tabindex="0">
<pre><code>yarn add example</code></pre>
</div>
</div>
// components/tabs.js
export function initTabs(root, options = {}) {
const tablist = root.querySelector('[data-tablist]');
const tabs = [...tablist.querySelectorAll('[data-tab]')];
const panels = tabs.map((tab) => document.getElementById(tab.getAttribute('aria-controls')));
const fromMarkup = root.dataset.selected ? { selected: root.dataset.selected } : {};
const config = { selected: tabs[0].id, ...fromMarkup, ...options };
if (!tabs.some((tab) => tab.id === config.selected)) config.selected = tabs[0].id;
const state = { selectedId: config.selected };
const controller = new AbortController();
const { signal } = controller;
function render() {
tabs.forEach((tab, index) => {
const selected = tab.id === state.selectedId;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
panels[index].hidden = !selected;
});
}
function select(id, { focus = false } = {}) {
const tab = tabs.find((item) => item.id === id);
if (!tab) return;
if (focus) tab.focus();
if (id === state.selectedId) return;
state.selectedId = id;
render();
root.dispatchEvent(new CustomEvent('ui-tabs:change', {
bubbles: true,
detail: { selectedId: id },
}));
}
tablist.addEventListener('click', (event) => {
const tab = event.target.closest('[data-tab]');
if (tab) select(tab.id);
}, { signal });
tablist.addEventListener('keydown', (event) => {
const current = tabs.findIndex((tab) => tab.id === state.selectedId);
const keys = {
ArrowRight: (current + 1) % tabs.length,
ArrowLeft: (current - 1 + tabs.length) % tabs.length,
Home: 0,
End: tabs.length - 1,
};
if (!Object.hasOwn(keys, event.key)) return;
event.preventDefault();
select(tabs[keys[event.key]].id, { focus: true });
}, { signal });
render();
return {
select: (id) => select(id),
getSelected: () => state.selectedId,
destroy: () => controller.abort(),
};
}
The keyboard listener is on the tablist, so arrow keys pressed inside a panel, for example in a text field, are left alone.
Alert
// components/alert.js
export function initAlertRegion(region, options = {}) {
const config = { dismissLabel: region.dataset.dismissLabel ?? 'Dismiss', ...options };
const controller = new AbortController();
function show(message, { variant = 'info' } = {}) {
const opener = document.activeElement;
const alert = document.createElement('div');
alert.className = `alert alert--${variant}`;
const text = document.createElement('p');
text.textContent = message;
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'alert__dismiss';
dismiss.textContent = config.dismissLabel;
dismiss.addEventListener('click', () => {
const hadFocus = alert.contains(document.activeElement);
alert.remove();
if (hadFocus && opener instanceof HTMLElement && opener.isConnected) opener.focus();
region.dispatchEvent(new CustomEvent('ui-alert:dismiss', {
bubbles: true,
detail: { message, variant },
}));
}, { signal: controller.signal });
alert.append(text, dismiss);
region.append(alert);
region.dispatchEvent(new CustomEvent('ui-alert:show', {
bubbles: true,
detail: { message, variant },
}));
}
return {
show,
clear: () => region.replaceChildren(),
destroy: () => controller.abort(),
};
}
Use role="status" on the region for most messages, which waits for the screen reader to finish what it is saying. Use a separate region with role="alert" only for urgent errors, because it interrupts. Alerts stay until dismissed. Auto-dismissing messages can disappear before someone has read them, and an action inside one may vanish before a keyboard user can reach it.
Dialog
The dialog module wraps the native <dialog> element. showModal() already makes the rest of the page inert, handles Escape, and returns focus when the dialog closes, so the module adds the open buttons, explicit focus return, an optional backdrop click, and events.
<button type="button" data-dialog-open="delete-project">Delete project</button>
<dialog class="dialog" id="delete-project" data-dialog data-close-on-backdrop
aria-labelledby="delete-project-title">
<div class="dialog__body">
<h2 id="delete-project-title">Delete this project?</h2>
<p>This removes the project and its files for everyone.</p>
<form method="dialog">
<button type="submit" value="cancel" autofocus>Cancel</button>
<button type="submit" value="delete">Delete project</button>
</form>
</div>
</dialog>
// components/dialog.js
export function initDialog(dialog, options = {}) {
const config = {
closeOnBackdrop: dialog.dataset.closeOnBackdrop !== undefined,
...options,
};
const controller = new AbortController();
const { signal } = controller;
let opener = null;
function open() {
if (dialog.open) return;
opener = document.activeElement;
dialog.returnValue = '';
dialog.showModal();
dialog.dispatchEvent(new CustomEvent('ui-dialog:open', { bubbles: true }));
}
document.querySelectorAll(`[data-dialog-open="${dialog.id}"]`).forEach((button) => {
button.addEventListener('click', open, { signal });
});
dialog.addEventListener('click', (event) => {
if (config.closeOnBackdrop && event.target === dialog) dialog.close('backdrop');
}, { signal });
dialog.addEventListener('close', () => {
if (opener?.isConnected) opener.focus();
dialog.dispatchEvent(new CustomEvent('ui-dialog:close', {
bubbles: true,
detail: { returnValue: dialog.returnValue },
}));
}, { signal });
return {
open,
close: (value) => dialog.close(value),
destroy: () => controller.abort(),
};
}
The <form method="dialog"> closes the dialog and sets returnValue to the value of the button that was pressed, so the page can listen for ui-dialog:close and act only when event.detail.returnValue === 'delete'. The module resets returnValue on open, because closing with Escape doesn’t change it and an old value would otherwise leak into the next close. The backdrop check works because a click on the backdrop targets the <dialog> itself. Give the dialog padding: 0 and put the padding on .dialog__body, so a click on the dialog’s own padding isn’t mistaken for a backdrop click.
Form field
The form field module applies the validation timing rule from chapter 6: validate when the field loses focus, re-validate on every input once an error is showing, and let the form validate every field on submit.
<form data-signup-form novalidate>
<div class="field" data-field data-message-value-missing="Enter your email address.">
<label for="email">Email</label>
<input id="email" name="email" type="email" required
aria-describedby="email-hint email-error">
<p class="field__hint" id="email-hint">We send the receipt here.</p>
<p class="field__error" id="email-error" data-field-error hidden></p>
</div>
<button type="submit">Sign up</button>
</form>
// components/form-field.js
const validityKeys = ['valueMissing', 'typeMismatch', 'patternMismatch', 'tooShort',
'tooLong', 'rangeUnderflow', 'rangeOverflow', 'stepMismatch'];
export function initFormField(root, options = {}) {
const input = root.querySelector('input, select, textarea');
const error = root.querySelector('[data-field-error]');
const config = { messages: {}, ...options };
const state = { message: '' };
const controller = new AbortController();
const { signal } = controller;
function messageFor(validity) {
const key = validityKeys.find((name) => validity[name]);
if (!key) return input.validationMessage;
const dataKey = `message${key[0].toUpperCase()}${key.slice(1)}`;
return config.messages[key] ?? root.dataset[dataKey] ?? input.validationMessage;
}
function render() {
error.textContent = state.message;
error.hidden = !state.message;
if (state.message) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
}
function validate() {
state.message = input.validity.valid ? '' : messageFor(input.validity);
render();
root.dispatchEvent(new CustomEvent('ui-field:validate', {
bubbles: true,
detail: { name: input.name, valid: !state.message, message: state.message },
}));
return !state.message;
}
input.addEventListener('blur', validate, { signal });
input.addEventListener('input', () => {
if (state.message) validate();
}, { signal });
return {
validate,
focus: () => input.focus(),
destroy: () => controller.abort(),
};
}
// app.js (excerpt)
import { initFormField } from './components/form-field.js';
const form = document.querySelector('[data-signup-form]');
const fields = [...form.querySelectorAll('[data-field]')].map((root) => initFormField(root));
form.addEventListener('submit', (event) => {
event.preventDefault();
const results = fields.map((field) => field.validate());
const firstInvalid = results.indexOf(false);
if (firstInvalid !== -1) {
fields[firstInvalid].focus();
return;
}
// Submit the data here, as in chapter 6.
});
The error element is always listed in aria-describedby. When it is empty it adds nothing to the description, and when it has a message, screen readers read it along with the hint. Because the form has novalidate, the browser’s own error bubbles don’t appear, but the constraint validation API still reports validity for each field. Message precedence follows the same rule as other inputs: an options message wins over a data attribute, which wins over the browser’s default text.
Documenting each component
Write one documentation page per component with these sections:
- HTML expectations. The root, the hooks, and any roles, ids, or attributes the markup must already have.
- Behavior. What it does with a mouse and with a keyboard.
- Options. Each option’s name, data attribute, type, and default.
- Events. Each event name, when it fires, and what
detailcontains. - Public API. Each function on the returned object.
- Accessibility notes. The contract: roles, states, keyboard behavior, focus handling, and live region requirements.
- Usage example. The smallest HTML and JavaScript that works.
- Known limitations. Be honest. For example, initializing the same root twice attaches duplicate listeners, the tabs module doesn’t support vertical tabs, and the alert region has no limit on how many alerts it holds.
Done means
- Five components exist as modules that each export one init function.
- Each component reads data attributes and an options object in the documented order.
- Each component dispatches namespaced, bubbling
CustomEvents, and the demo page logs them all from one listener per event ondocument. - Each returns a public API with
destroy, and calling it removes every listener. - Every component passes a keyboard-only test and an Accessibility pane check, and the alert region is announced by a screen reader.
- Each component has a documentation page with all eight sections.
- You have written a short explanation, in the “This works because…” style, of how one component’s inputs, outputs, and internal state work.
Chapter 12 puts these components to work in a documentation site for a small design system, together with the rendering, async, and browser API techniques from earlier chapters.
Found an error? Report it.