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.

JavaScriptadvanced
19 min read

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.

javascriptjavascript
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.

javascriptjavascript
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">&times;</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.

javascriptjavascript
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.

javascriptjavascript
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 },
]);
ComponentAccessibility FeaturesARIA Roles
DialogFocus trap, Escape close, screen reader announcementsdialog, aria-modal
Form FieldError announcements, required indicators, describedbyalert, aria-invalid
Data TableSort announcements, row selection, keyboard navigationgrid, columnheader, gridcell
NavigationCurrent page indicator, landmark regionsnavigation, aria-current
NotificationLive region announcements, auto-dismissalert, aria-live
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

How do I handle design token changes at runtime?

Use CSS custom properties (variables) as the token implementation. When you call `setProperty` on the document root, all elements using those variables update instantly. Dispatch a custom event (`themechange`) so JavaScript components can respond to theme switches. This avoids re-rendering components since the browser handles CSS variable cascade automatically.

How do I build accessible keyboard navigation for complex components?

Follow the WAI-ARIA Authoring Practices for the specific component type. Composite widgets like data tables use `role="grid"` with arrow key navigation between cells. Dialogs trap focus within the container. Use `roving tabindex` (tabindex="0" on the active item, tabindex="-1" on others) for toolbars and tab lists. Always provide `Escape` key handling for dismissible components.

Should I use Web Components or plain classes for enterprise UI?

Plain classes provide more flexibility and simpler debugging. Web Components add shadow DOM isolation, which prevents style leaking but complicates theming. For internal enterprise apps where you control the CSS, plain classes with BEM naming are sufficient. Use Web Components when building a shared library used across teams with different CSS strategies.

How do I test UI components without a framework?

Create the component in a test environment with jsdom or a headless browser. Assert DOM structure, ARIA attributes, and event behavior directly. For example, instantiate a Dialog, call `open()`, and verify `aria-modal` is set and focus moves to the dialog. Simulate keyboard events to test focus trapping. Test form validation by setting input values and calling `validate()`.

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.