Building Enterprise UI Systems in Vanilla JS
Build enterprise-grade UI systems in vanilla JavaScript. Covers design token systems, accessible component libraries, complex form architectures, data table engines, theming, and component composition patterns for large-scale applications.
Enterprise applications demand UI systems that scale across teams. This guide covers design tokens, accessible components, complex forms, data tables, and theming built entirely in vanilla JavaScript without framework dependencies.
For state management patterns that power these systems, see Vanilla JS State Management for Advanced Apps.
Design Token System
Design tokens are the single source of truth for colors, spacing, fonts, and other visual values across your whole UI. This class stores tokens as CSS custom properties on the document root, so any element using var(--color-primary) updates instantly when you switch themes. Themes are just sets of overrides applied on top of the base tokens.
class DesignTokens {
#tokens = new Map();
#themes = new Map();
#activeTheme = null;
#root;
constructor() {
this.#root = document.documentElement;
}
define(category, tokens) {
for (const [name, value] of Object.entries(tokens)) {
const tokenKey = `--${category}-${name}`;
this.#tokens.set(tokenKey, value);
}
return this;
}
createTheme(name, overrides) {
this.#themes.set(name, overrides);
return this;
}
applyTheme(name) {
// Reset to base tokens
for (const [key, value] of this.#tokens) {
this.#root.style.setProperty(key, value);
}
// Apply theme overrides
const theme = this.#themes.get(name);
if (theme) {
for (const [key, value] of Object.entries(theme)) {
this.#root.style.setProperty(key, value);
}
}
this.#activeTheme = name;
document.dispatchEvent(
new CustomEvent("themechange", { detail: { theme: name } })
);
}
getToken(key) {
return getComputedStyle(this.#root).getPropertyValue(key).trim();
}
getActiveTheme() {
return this.#activeTheme;
}
}
// Define the system
const tokens = new DesignTokens();
tokens
.define("color", {
primary: "#2563eb",
"primary-hover": "#1d4ed8",
secondary: "#64748b",
success: "#16a34a",
error: "#dc2626",
warning: "#d97706",
surface: "#ffffff",
"surface-elevated": "#f8fafc",
text: "#0f172a",
"text-muted": "#64748b",
border: "#e2e8f0",
})
.define("spacing", {
xs: "0.25rem",
sm: "0.5rem",
md: "1rem",
lg: "1.5rem",
xl: "2rem",
"2xl": "3rem",
})
.define("font", {
"size-sm": "0.875rem",
"size-base": "1rem",
"size-lg": "1.125rem",
"size-xl": "1.25rem",
"size-2xl": "1.5rem",
"weight-normal": "400",
"weight-medium": "500",
"weight-bold": "700",
})
.define("radius", {
sm: "0.25rem",
md: "0.375rem",
lg: "0.5rem",
full: "9999px",
});
// Dark theme
tokens.createTheme("dark", {
"--color-surface": "#0f172a",
"--color-surface-elevated": "#1e293b",
"--color-text": "#f1f5f9",
"--color-text-muted": "#94a3b8",
"--color-border": "#334155",
"--color-primary": "#3b82f6",
});
tokens.applyTheme("light");Accessible Component Base
Every UI component in an enterprise system needs a shared foundation for accessibility. The UIComponent base class here auto-generates unique IDs, sets ARIA roles, creates a shared live region for screen reader announcements, and provides focus trapping for modal-like components. The Dialog subclass shows how to build on top of that base with proper aria-modal, Escape key handling, and focus restoration when the dialog closes.
class UIComponent {
#element;
#id;
#announcer;
static #counter = 0;
static #liveRegion = null;
constructor(tag, role) {
this.#id = `ui-${++UIComponent.#counter}`;
this.#element = document.createElement(tag);
this.#element.id = this.#id;
if (role) this.#element.setAttribute("role", role);
if (!UIComponent.#liveRegion) {
UIComponent.#liveRegion = document.createElement("div");
UIComponent.#liveRegion.setAttribute("aria-live", "polite");
UIComponent.#liveRegion.setAttribute("aria-atomic", "true");
UIComponent.#liveRegion.className = "sr-only";
document.body.appendChild(UIComponent.#liveRegion);
}
}
get element() {
return this.#element;
}
get id() {
return this.#id;
}
announce(message) {
UIComponent.#liveRegion.textContent = "";
requestAnimationFrame(() => {
UIComponent.#liveRegion.textContent = message;
});
}
setAriaLabel(label) {
this.#element.setAttribute("aria-label", label);
return this;
}
setAriaDescribedBy(id) {
this.#element.setAttribute("aria-describedby", id);
return this;
}
trapFocus() {
const focusable = this.#element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
const handler = (e) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
this.#element.addEventListener("keydown", handler);
first?.focus();
return () => this.#element.removeEventListener("keydown", handler);
}
mount(container) {
container.appendChild(this.#element);
return this;
}
unmount() {
this.#element.remove();
}
}
// Accessible Dialog
class Dialog extends UIComponent {
#overlay;
#releaseFocus;
#previousFocus;
#onClose;
constructor(title, content, onClose) {
super("div", "dialog");
this.#onClose = onClose;
this.element.setAttribute("aria-modal", "true");
this.element.setAttribute("aria-labelledby", `${this.id}-title`);
this.element.className = "dialog";
this.element.innerHTML = `
<div class="dialog-header">
<h2 id="${this.id}-title">${title}</h2>
<button class="dialog-close" aria-label="Close dialog">×</button>
</div>
<div class="dialog-body">${content}</div>
<div class="dialog-footer">
<button class="btn btn-secondary" data-action="cancel">Cancel</button>
<button class="btn btn-primary" data-action="confirm">Confirm</button>
</div>
`;
this.element.querySelector(".dialog-close").addEventListener("click", () => this.close());
this.element.addEventListener("keydown", (e) => {
if (e.key === "Escape") this.close();
});
}
open() {
this.#previousFocus = document.activeElement;
this.#overlay = document.createElement("div");
this.#overlay.className = "dialog-overlay";
this.#overlay.addEventListener("click", () => this.close());
document.body.appendChild(this.#overlay);
this.mount(document.body);
this.#releaseFocus = this.trapFocus();
this.announce("Dialog opened");
}
close() {
this.#releaseFocus?.();
this.#overlay?.remove();
this.unmount();
this.#previousFocus?.focus();
this.announce("Dialog closed");
this.#onClose?.();
}
bindConfirm(handler) {
this.element.querySelector('[data-action="confirm"]')
.addEventListener("click", () => {
handler();
this.close();
});
}
}Complex Form Architecture
Enterprise forms need more than just inputs. Each field tracks whether the user has interacted with it (touched), whether the value changed (dirty), and runs validation rules on blur and subsequent input. This gives you fine-grained control over when and how errors show up, instead of blasting the user with red text before they even start typing.
class FormField {
#config;
#element;
#input;
#errorEl;
#value;
#touched = false;
#dirty = false;
constructor(config) {
this.#config = config;
this.#value = config.defaultValue ?? "";
this.#element = document.createElement("div");
this.#element.className = "form-field";
this.#render();
}
#render() {
const { name, label, type, required, help } = this.#config;
this.#element.innerHTML = `
<label for="field-${name}">
${label}${required ? ' <span class="required" aria-hidden="true">*</span>' : ""}
</label>
${this.#renderInput()}
${help ? `<div class="field-help" id="help-${name}">${help}</div>` : ""}
<div class="field-error" id="error-${name}" role="alert"></div>
`;
this.#input = this.#element.querySelector(`#field-${name}`);
this.#errorEl = this.#element.querySelector(`#error-${name}`);
if (help) {
this.#input.setAttribute("aria-describedby", `help-${name}`);
}
this.#input.addEventListener("blur", () => {
this.#touched = true;
this.validate();
});
this.#input.addEventListener("input", () => {
this.#dirty = true;
this.#value = this.#input.value;
if (this.#touched) this.validate();
});
}
#renderInput() {
const { name, type, placeholder, options } = this.#config;
switch (type) {
case "textarea":
return `<textarea id="field-${name}" name="${name}" placeholder="${placeholder || ""}">${this.#value}</textarea>`;
case "select":
return `<select id="field-${name}" name="${name}">
<option value="">Select...</option>
${(options || []).map((o) => `<option value="${o.value}" ${o.value === this.#value ? "selected" : ""}>${o.label}</option>`).join("")}
</select>`;
case "checkbox":
return `<input type="checkbox" id="field-${name}" name="${name}" ${this.#value ? "checked" : ""}>`;
default:
return `<input type="${type || "text"}" id="field-${name}" name="${name}" placeholder="${placeholder || ""}" value="${this.#value}">`;
}
}
validate() {
const rules = this.#config.rules || [];
const errors = [];
for (const rule of rules) {
const error = rule(this.#value);
if (error) errors.push(error);
}
if (errors.length > 0) {
this.#errorEl.textContent = errors[0];
this.#element.classList.add("has-error");
this.#input.setAttribute("aria-invalid", "true");
this.#input.setAttribute("aria-errormessage", `error-${this.#config.name}`);
} else {
this.#errorEl.textContent = "";
this.#element.classList.remove("has-error");
this.#input.removeAttribute("aria-invalid");
this.#input.removeAttribute("aria-errormessage");
}
return errors;
}
getValue() { return this.#value; }
getElement() { return this.#element; }
isTouched() { return this.#touched; }
isDirty() { return this.#dirty; }
reset() {
this.#value = this.#config.defaultValue ?? "";
this.#touched = false;
this.#dirty = false;
this.#render();
}
}
// Validation rules
const rules = {
required: (msg = "Required") => (v) => (!v || !v.trim() ? msg : null),
minLength: (min) => (v) => (v && v.length < min ? `At least ${min} characters` : null),
maxLength: (max) => (v) => (v && v.length > max ? `At most ${max} characters` : null),
email: () => (v) => (v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? "Invalid email" : null),
pattern: (re, msg) => (v) => (v && !re.test(v) ? msg : null),
};Data Table Engine
Data tables are one of the most common components in enterprise apps, and they get complicated fast. This implementation handles column sorting, pagination, row selection with checkboxes, and pluggable filter functions, all while keeping proper ARIA roles on every cell. The internal pipeline first applies filters, then sorts, then paginates, so the user always sees a consistent slice of the data.
class DataTable {
#container;
#columns;
#data = [];
#sortColumn = null;
#sortDirection = "asc";
#currentPage = 1;
#pageSize = 10;
#selectedRows = new Set();
#filters = {};
constructor(container, columns) {
this.#container = container;
this.#columns = columns;
this.#container.setAttribute("role", "grid");
this.#container.setAttribute("aria-label", "Data table");
}
setData(data) {
this.#data = [...data];
this.#currentPage = 1;
this.render();
return this;
}
#getProcessedData() {
let result = [...this.#data];
// Apply filters
for (const [key, filterFn] of Object.entries(this.#filters)) {
result = result.filter(filterFn);
}
// Apply sort
if (this.#sortColumn) {
const col = this.#columns.find((c) => c.key === this.#sortColumn);
const compare = col?.sortFn || ((a, b) => {
const va = a[this.#sortColumn];
const vb = b[this.#sortColumn];
if (va < vb) return -1;
if (va > vb) return 1;
return 0;
});
result.sort((a, b) => {
const order = compare(a, b);
return this.#sortDirection === "asc" ? order : -order;
});
}
return result;
}
#getPageData() {
const processed = this.#getProcessedData();
const start = (this.#currentPage - 1) * this.#pageSize;
return {
rows: processed.slice(start, start + this.#pageSize),
total: processed.length,
totalPages: Math.ceil(processed.length / this.#pageSize),
};
}
render() {
const { rows, total, totalPages } = this.#getPageData();
this.#container.innerHTML = `
<table class="data-table">
<thead>
<tr role="row">
<th role="columnheader">
<input type="checkbox" class="select-all"
aria-label="Select all rows"
${this.#selectedRows.size === rows.length ? "checked" : ""}>
</th>
${this.#columns.map((col) => `
<th role="columnheader"
class="sortable ${this.#sortColumn === col.key ? `sorted-${this.#sortDirection}` : ""}"
data-column="${col.key}"
aria-sort="${this.#sortColumn === col.key ? this.#sortDirection + "ending" : "none"}">
${col.label}
<span class="sort-icon" aria-hidden="true"></span>
</th>
`).join("")}
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr role="row" data-id="${row.id}"
class="${this.#selectedRows.has(row.id) ? "selected" : ""}">
<td role="gridcell">
<input type="checkbox" class="select-row"
data-id="${row.id}"
${this.#selectedRows.has(row.id) ? "checked" : ""}
aria-label="Select row">
</td>
${this.#columns.map((col) => `
<td role="gridcell">${col.render ? col.render(row[col.key], row) : row[col.key]}</td>
`).join("")}
</tr>
`).join("")}
</tbody>
</table>
<div class="table-footer">
<span>${total} results</span>
<div class="pagination">
<button class="prev" ${this.#currentPage <= 1 ? "disabled" : ""} aria-label="Previous page">Prev</button>
<span>Page ${this.#currentPage} of ${totalPages}</span>
<button class="next" ${this.#currentPage >= totalPages ? "disabled" : ""} aria-label="Next page">Next</button>
</div>
</div>
`;
this.#bindEvents();
}
#bindEvents() {
// Sort
this.#container.querySelectorAll(".sortable").forEach((th) => {
th.addEventListener("click", () => {
const col = th.dataset.column;
if (this.#sortColumn === col) {
this.#sortDirection = this.#sortDirection === "asc" ? "desc" : "asc";
} else {
this.#sortColumn = col;
this.#sortDirection = "asc";
}
this.render();
});
});
// Pagination
this.#container.querySelector(".prev")?.addEventListener("click", () => {
this.#currentPage--;
this.render();
});
this.#container.querySelector(".next")?.addEventListener("click", () => {
this.#currentPage++;
this.render();
});
// Row selection
this.#container.querySelector(".select-all")?.addEventListener("change", (e) => {
const rows = this.#getPageData().rows;
if (e.target.checked) {
rows.forEach((r) => this.#selectedRows.add(r.id));
} else {
rows.forEach((r) => this.#selectedRows.delete(r.id));
}
this.render();
});
this.#container.querySelectorAll(".select-row").forEach((cb) => {
cb.addEventListener("change", (e) => {
const id = parseInt(e.target.dataset.id, 10);
if (e.target.checked) this.#selectedRows.add(id);
else this.#selectedRows.delete(id);
this.render();
});
});
}
addFilter(name, filterFn) {
this.#filters[name] = filterFn;
this.#currentPage = 1;
this.render();
}
removeFilter(name) {
delete this.#filters[name];
this.render();
}
getSelected() {
return [...this.#selectedRows];
}
}
// Usage
const table = new DataTable(document.querySelector("#table"), [
{ key: "name", label: "Name" },
{ key: "email", label: "Email" },
{ key: "role", label: "Role", render: (v) => `<span class="badge badge-${v}">${v}</span>` },
{ key: "status", label: "Status", render: (v) => v ? "Active" : "Inactive" },
]);
table.setData([
{ id: 1, name: "Alice", email: "alice@example.com", role: "admin", status: true },
{ id: 2, name: "Bob", email: "bob@example.com", role: "user", status: true },
]);| Component | Accessibility Features | ARIA Roles |
|---|---|---|
| Dialog | Focus trap, Escape close, screen reader announcements | dialog, aria-modal |
| Form Field | Error announcements, required indicators, describedby | alert, aria-invalid |
| Data Table | Sort announcements, row selection, keyboard navigation | grid, columnheader, gridcell |
| Navigation | Current page indicator, landmark regions | navigation, aria-current |
| Notification | Live region announcements, auto-dismiss | alert, aria-live |
Rune AI
Key Insights
- Design token systems use CSS custom properties for runtime theming: Define tokens as variables on the document root and swap themes by updating those properties without re-rendering components
- Accessible components require focus management, ARIA attributes, and live regions: Every interactive component needs keyboard support, screen reader announcements, and proper semantic roles
- Complex form fields track touched, dirty, and validation state independently: Each field validates on blur and subsequent input, displaying inline errors with ARIA error associations
- Data tables combine sorting, pagination, filtering, and row selection: A processed data pipeline applies filters, then sorts, then paginates, re-rendering the table on each interaction
- Component composition through mounting and unmounting manages lifecycle: Parent components add and remove children with proper cleanup, preventing memory leaks and stale event listeners
Frequently Asked Questions
How do I handle design token changes at runtime?
How do I build accessible keyboard navigation for complex components?
Should I use Web Components or plain classes for enterprise UI?
How do I test UI components without a framework?
Conclusion
Enterprise UI systems in vanilla JavaScript require design tokens for consistent styling, accessible component bases for inclusive experiences, complex form architectures for data collection, and data table engines for structured information display. For the state management patterns that power these systems, see Vanilla JS State Management for Advanced Apps. For architectural patterns behind component communication, explore Building an Event Bus with JS Pub/Sub Pattern.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.