Building Reusable UI Components with Pure JavaScript
Reviewed & published by Brayan K
Master the foundations of component architecture without frameworks. Learn Custom Elements, Shadow DOM, slots, state management, and patterns used by React, Vue, and Svelte.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Custom Elements and Shadow DOM work in all modern browsers. To test these components:
- Create an HTML file and add the JavaScript code in a <script> tag
- Open the file in Chrome, Firefox, Safari, or Edge
- Use your browser's DevTools (F12) to inspect Shadow DOM elements
- Visit caniuse.com for browser support details
What You'll Learn
- Factory function components
- Shadow DOM encapsulation
- Custom Elements lifecycle
- Slots for content injection
- Global state management
- Event bus communication
What Are Reusable UI Components?
A reusable component is a self-contained piece of UI with its own markup, styles, logic, and state. It can be instantiated multiple times, accepts inputs (props), emits events, and manages its own lifecycle.
Real-World Component Examples
💡 Why Pure JS? Zero dependencies, faster than framework components, works anywhere, teaches real DOM mastery, and you can port logic to React/Vue easily.
Factory Function Pattern
The simplest pattern for creating reusable components. A function returns an object with the DOM element and a public API for external control.
Before the DOM version, read this one. It is the same pattern with the page stripped out, so the only thing left is the shape of a component: a private variable, one render step, and the small set of methods you hand back. Every non-obvious line is commented with what it prints.
// Factory Function Pattern — the component contract, without the DOM
// A component is really just three things: private state, a render step,
// and a public API. This version prints instead of touching the page, so
// you can watch every state change in the output panel.
function createCounter({ start = 0, label = "Counter" } = {}) {
// 1) STATE — "count" lives inside the function, so nothing outside can reach it.
// That is a closure: the inner functions keep access to "count" even after
// createCounter has finished running.
let count = start;
// 2) RENDER — the single place that turns state into visible output.
// In a real DOM component this one line would be: valueEl.textContent = count;
function render() {
console.log(label + ": " + count);
}
render(); // draw once, straight away -> "Basket: 3"
// 3) PUBLIC API — the only handles the outside world gets.
return {
increment() {
count++; // change the state...
render(); // ...then re-render. Always that order.
},
decrement() {
count--;
render();
},
getValue() { // a getter: read the state without exposing it
return count;
}
};
}
// Two instances from the same factory — each gets its own private "count".
const basket = createCounter({ start: 3, label: "Basket" }); // "Basket: 3"
const likes = createCounter({ label: "Likes" }); // start defaults to 0 -> "Likes: 0"
basket.increment(); // "Basket: 4"
basket.increment(); // "Basket: 5"
likes.decrement(); // "Likes: -1"
console.log("basket =", basket.getValue(), "| likes =", likes.getValue());
// The state really is private — there is no "basket.count" to poke at.
console.log("basket.count is", String(basket.count));
// ✅ Expected output:
// Basket: 3
// Likes: 0
// Basket: 4
// Basket: 5
// Likes: -1
// basket = 5 | likes = -1
// basket.count is undefinedNow the same idea driving real elements. createCounter below builds a <div>, wires up two buttons, and returns an object whose methods let outside code change the counter without ever touching its internals.
// Factory Function Pattern - Simple Counter Component
function createCounter({ start = 0 }) {
// Internal state
let count = start;
// Create DOM structure
const root = document.createElement("div");
root.className = "counter";
root.innerHTML = `
<button class="dec">-</button>
<span class="value">${count}</span>
<button class="inc">+</button>
`;
const valueEl = root.querySelector(".value");
// Update function - re-renders when state changes
function update() {
valueEl.textContent = count;
}
// Event listeners
root.querySelector(".inc").addEventListener("click", () => {
count++;
update();
});
root.querySelector(".dec").addEventListener("click", () => {
count--;
update();
});
// Public API - what external code can use
return {
element: root,
setValue(newVal) {
count = newVal;
update();
},
getValue() {
return count;
},
destroy() {
root.remove();
}
};
}
// Usage - create multiple independent instances
const counter1 = createCounter({ start: 5 });
document.body.appendChild(counter1.element);
const counter2 = createCounter({ start: 100 });
document.body.appendChild(counter2.element);
// External control via public API
counter1.setValue(50);
console.log(counter1.getValue()); // 50Component Anatomy
- State — Internal variables (count, open, selected)
- DOM — The element structure created by the component
- Update — Function that syncs DOM with state
- Events — Internal event listeners
- Public API — Methods exposed for external control
Your turn. The factory below is written for you apart from the three parts you just read about. Fill in each ___ and check your output against the expected output at the bottom of the file.
// 🎯 YOUR TURN — finish the factory function
// Everything is written for you except the three parts that turn a plain
// function into a component: private state, the render step, and the public API.
// Replace each ___ then press Run.
function createLightSwitch({ room = "Room", on = false } = {}) {
// 1) STATE — one private variable holding whether the light is on
let isOn = ___; // 👉 start it from the "on" option
// 2) RENDER — the one place that turns state into output
function render() {
console.log(room + " light: " + (___ ? "ON" : "OFF")); // 👉 which variable decides?
}
render(); // draw the starting state
// 3) PUBLIC API — what outside code is allowed to call
return {
toggle() {
isOn = !isOn; // flip the state (this line is done for you)
___; // 👉 call the render step so the change actually shows
},
getState() {
return ___; // 👉 hand back the current state
}
};
}
const kitchen = createLightSwitch({ room: "Kitchen", on: true });
const garage = createLightSwitch({ room: "Garage" });
kitchen.toggle();
garage.toggle();
garage.toggle();
console.log("kitchen on?", kitchen.getState(), "| garage on?", garage.getState());
// ✅ Expected output:
// Kitchen light: ON
// Garage light: OFF
// Kitchen light: OFF
// Garage light: ON
// Garage light: OFF
// kitchen on? false | garage on? falseTemplate Clone Pattern
For HTML-driven components, use the <template> element. Templates are not rendered but can be cloned efficiently, making this pattern very fast.
// Template Clone Pattern - HTML-driven components
// HTML template (would be in your HTML file):
// <template id="card-template">
// <div class="card">
// <h3 class="title"></h3>
// <p class="content"></p>
// </div>
// </template>
function createCard({ title, content, onClick }) {
// Clone the template
const template = document.querySelector("#card-template");
const root = template.content.cloneNode(true);
// Fill in data
root.querySelector(".title").textContent = title;
root.querySelector(".content").textContent = content;
// Wrap in container for return
const wrapper = document.createElement("div");
wrapper.appendChild(root);
// Add events
if (onClick) {
wrapper.addEventListener("click", onClick);
}
return {
element: wrapper,
update({ title: newTitle, content: newContent }) {
wrapper.querySelector(".title").textContent = newTitle;
wrapper.querySelector(".content").textContent = newContent;
}
};
}
// Usage
const card = createCard({
title: "Hello World",
content: "This is a reusable card component",
onClick: () => console.log("Card clicked!")
});
document.body.appendChild(card.element);Component Communication with Custom Events
Components emit custom events to communicate with parent code. This keeps components loosely coupled and reusable across different contexts.
// Component Communication with Custom Events
function createNotificationBanner() {
const root = document.createElement("div");
root.className = "notification-banner";
root.innerHTML = `
<span class="message"></span>
<button class="close">×</button>
`;
const messageEl = root.querySelector(".message");
const closeBtn = root.querySelector(".close");
// Emit custom event when closed
closeBtn.addEventListener("click", () => {
// Dispatch a custom event that parents can listen to
root.dispatchEvent(new CustomEvent("dismiss", {
bubbles: true,
detail: { timestamp: Date.now() }
}));
root.remove();
});
return {
element: root,
show(message, type = "info") {
messageEl.textContent = message;
root.className = "notification-banner " + type;
root.hidden = false;
// Emit show event
root.dispatchEvent(new CustomEvent("show", {
bubbles: true,
detail: { message, type }
}));
},
hide() {
root.hidden = true;
}
};
}
// Parent listens to component events
const notification = createNotificationBanner();
document.body.appendChild(notification.element);
notification.element.addEventListener("dismiss", (e) => {
console.log("Notification dismissed at:", e.detail.timestamp);
});
notification.element.addEventListener("show", (e) => {
console.log("Showing:", e.detail.message);
});
notification.show("Welcome back!", "success");Shadow DOM — Full Encapsulation
Shadow DOM creates a private DOM tree inside your element. Styles are completely isolated — no leakage to or from global CSS. This is how browsers implement <video>, <input>, and <select>.
// Shadow DOM - Full UI Encapsulation
class MyCard extends HTMLElement {
constructor() {
super();
// Attach shadow DOM (private DOM tree)
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
/* These styles are COMPLETELY isolated */
.card {
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.card h3 {
margin: 0 0 8px 0;
color: #1a1a1a;
}
.card p {
margin: 0;
color: #666;
}
</style>
<div class="card">
<h3><slot name="title">Default Title</slot></h3>
<p><slot>Default content goes here</slot></p>
</div>
`;
}
}
// Register the custom element
customElements.define("my-card", MyCard);
// Usage in HTML:
// <my-card>
// <span slot="title">Custom Title</span>
// This is the card content!
// </my-card>✅ Shadow DOM Benefits: Style encapsulation, no naming conflicts, framework-level component scoping, and markup isolation.
Custom Elements with Lifecycle
Custom Elements are the browser's native component system. They have lifecycle callbacks similar to React, can observe attribute changes, and are fully reusable.
// Full Custom Element with Lifecycle
class ToggleSwitch extends HTMLElement {
// Declare which attributes to watch
static get observedAttributes() {
return ["checked", "disabled"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
.switch {
width: 50px;
height: 26px;
background: #ccc;
border-radius: 26px;
position: relative;
cursor: pointer;
transition: background 0.3s;
}
.knob {
width: 22px;
height: 22px;
background: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.3s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
:host([checked]) .switch {
background: #4ade80;
}
:host([checked]) .knob {
transform: translateX(24px);
}
</style>
<div class="switch" role="switch" tabindex="0">
<div class="knob"></div>
</div>
`;
// Event handling
const switchEl = this.shadowRoot.querySelector(".switch");
switchEl.addEventListener("click", () => this.toggle());
switchEl.addEventListener("keydown", (e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
this.toggle();
}
});
}
// Lifecycle: called when added to DOM
connectedCallback() {
console.log("Toggle switch connected");
this.updateAriaState();
}
// Lifecycle: called when removed from DOM
disconnectedCallback() {
console.log("Toggle switch disconnected");
}
// Lifecycle: called when observed attribute changes
attributeChangedCallback(name, oldValue, newValue) {
if (name === "checked") {
this.updateAriaState();
}
}
updateAriaState() {
const switchEl = this.shadowRoot.querySelector(".switch");
switchEl.setAttribute("aria-checked", this.checked);
}
// Public methods
toggle() {
if (this.disabled) return;
this.checked = !this.checked;
this.dispatchEvent(new CustomEvent("change", {
bubbles: true,
detail: { checked: this.checked }
}));
}
// Property getters/setters
get checked() {
return this.hasAttribute("checked");
}
set checked(value) {
if (value) {
this.setAttribute("checked", "");
} else {
this.removeAttribute("checked");
}
}
get disabled() {
return this.hasAttribute("disabled");
}
set disabled(value) {
if (value) {
this.setAttribute("disabled", "");
} else {
this.removeAttribute("disabled");
}
}
}
customElements.define("toggle-switch", ToggleSwitch);
// Usage:
// <toggle-switch></toggle-switch>
// <toggle-switch checked></toggle-switch>
// <toggle-switch disabled></toggle-switch>Lifecycle Callbacks
- constructor() — Called when element is created
- connectedCallback() — Called when added to DOM
- disconnectedCallback() — Called when removed from DOM
- attributeChangedCallback() — Called when observed attribute changes
Named Slots for Flexible Composition
Slots let users inject content into specific areas of your component — similar to React's children prop but with named targets.
// Named Slots for Flexible Component Composition
class ModalDialog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host {
display: none;
}
:host([open]) {
display: block;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: white;
border-radius: 12px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: auto;
}
.header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.body {
padding: 20px;
}
.footer {
padding: 16px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
<div class="overlay">
<div class="dialog" role="dialog" aria-modal="true">
<div class="header">
<slot name="header">Modal Title</slot>
</div>
<div class="body">
<slot>Modal content goes here</slot>
</div>
<div class="footer">
<slot name="footer">
<button>Close</button>
</slot>
</div>
</div>
</div>
`;
// Close on overlay click
this.shadowRoot.querySelector(".overlay").addEventListener("click", (e) => {
if (e.target === e.currentTarget) {
this.close();
}
});
}
connectedCallback() {
// Close on Escape key
this._escHandler = (e) => {
if (e.key === "Escape" && this.open) {
this.close();
}
};
document.addEventListener("keydown", this._escHandler);
}
disconnectedCallback() {
document.removeEventListener("keydown", this._escHandler);
}
get open() {
return this.hasAttribute("open");
}
set open(value) {
if (value) {
this.setAttribute("open", "");
} else {
this.removeAttribute("open");
}
}
show() {
this.open = true;
this.dispatchEvent(new CustomEvent("open"));
}
close() {
this.open = false;
this.dispatchEvent(new CustomEvent("close"));
}
}
customElements.define("modal-dialog", ModalDialog);
// Usage:
// <modal-dialog id="myModal">
// <h2 slot="header">Welcome!</h2>
// <p>This is the modal content.</p>
// <div slot="footer">
// <button onclick="myModal.close()">Cancel</button>
// <button onclick="handleSubmit()">Submit</button>
// </div>
// </modal-dialog>Component Factory Pattern
Generate multiple components from a single factory function. This pattern lets you create consistent UI libraries with minimal code duplication.
// Component Factory - Generate Components from Config
function createComponent({ name, template, style, methods = {} }) {
class GeneratedComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>${style}</style>
${template}
`;
// Call init if provided
if (methods.init) {
methods.init.call(this);
}
}
connectedCallback() {
methods.connected?.call(this);
}
disconnectedCallback() {
methods.disconnected?.call(this);
}
}
// Add public methods to prototype
Object.entries(methods).forEach(([key, fn]) => {
if (!["init", "connected", "disconnected"].includes(key)) {
GeneratedComponent.prototype[key] = fn;
}
});
customElements.define(name, GeneratedComponent);
return GeneratedComponent;
}
// Create a button component
createComponent({
name: "ui-button",
template: `<button><slot></slot></button>`,
style: `
button {
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #2563eb;
}
:host([variant="secondary"]) button {
background: #6b7280;
}
:host([variant="danger"]) button {
background: #ef4444;
}
`,
methods: {
init() {
this.shadowRoot.querySelector("button").addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("click", { bubbles: true }));
});
}
}
});
// Create a loader component
createComponent({
name: "ui-loader",
template: `<div class="spinner"></div>`,
style: `
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`
});
// Usage:
// <ui-button>Click Me</ui-button>
// <ui-button variant="danger">Delete</ui-button>
// <ui-loader></ui-loader>Global State Management
For shared state across components (theme, user session, notifications), implement a simple store with subscribers — the same pattern used by Redux and Vuex.
// Global State Store (Redux/Vuex Pattern)
const Store = {
state: {
theme: "light",
user: null,
notifications: []
},
listeners: new Set(),
// Get current state
getState() {
return { ...this.state };
},
// Update state
set(key, value) {
this.state[key] = value;
this.notify();
},
// Subscribe to changes
subscribe(callback) {
this.listeners.add(callback);
// Return unsubscribe function
return () => this.listeners.delete(callback);
},
// Notify all subscribers
notify() {
const state = this.getState();
this.listeners.forEach(fn => fn(state));
}
};
// Theme Toggle Component using global state
class ThemeToggle extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
button {
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
background: #f3f4f6;
}
</style>
<button>Toggle Theme</button>
`;
this.shadowRoot.querySelector("button").addEventListener("click", () => {
const current = Store.getState().theme;
Store.set("theme", current === "light" ? "dark" : "light");
});
}
connectedCallback() {
// Subscribe to store changes
this.unsubscribe = Store.subscribe((state) => {
this.updateTheme(state.theme);
});
// Initial render
this.updateTheme(Store.getState().theme);
}
disconnectedCallback() {
// Clean up subscription
this.unsubscribe?.();
}
updateTheme(theme) {
const btn = this.shadowRoot.querySelector("button");
btn.textContent = theme === "light" ? "🌙 Dark Mode" : "☀️ Light Mode";
}
}
customElements.define("theme-toggle", ThemeToggle);
// Any component can subscribe to state changes
Store.subscribe((state) => {
document.body.className = state.theme;
});Event Bus for Cross-Component Communication
An event bus allows any component to emit or listen to events without direct references. This enables loose coupling between unrelated components.
Async Components
Components that load data before rendering are essential for real applications. Handle loading states, errors, and dynamic attribute changes.
Accessibility Patterns
Professional components must be accessible. Implement ARIA roles, keyboard navigation, focus management, and screen reader support.
💡 Accessibility Checklist: ARIA roles, keyboard navigation (Arrow keys, Home, End), focus states, aria-selected/aria-hidden, proper tabindex, and screen reader labels.
High-Performance Patterns
For large-scale applications, optimize with template caching, batched updates, object pooling, lazy initialization, and event delegation.
🎯 Mini-Challenge: A Component With Subscribers
This one combines two ideas from this lesson: the factory pattern from Section 2 and the subscribe/notify idea behind the store and event bus. Build an inbox component that keeps its messages private and tells anyone who has subscribed whenever the count changes — exactly how a notification badge stays in sync.
There is no filled-in logic this time, only an outline and a fixed test drive at the bottom. If your output matches the expected output, your component works.
// 🎯 MINI-CHALLENGE: createInbox — a component other code can subscribe to
//
// Write a factory called createInbox() from scratch. Only the outline is here.
//
// 1. Private state: an array "messages" (starts empty) and an array "listeners".
// 2. An internal helper notify() that calls every listener, passing messages.length.
// 3. Return a public API with exactly four methods:
// add(text) -> push text onto messages, then call notify()
// onChange(fn) -> push fn onto listeners
// count() -> return how many messages there are
// latest() -> return the last message, or the string "none" if empty
//
// Keep "messages" and "listeners" private — no one outside should be able to
// reach them except through those four methods.
// your code here
// --- Do not change the code below: this is the test drive ---
const inbox = createInbox();
inbox.onChange((n) => console.log("Badge shows " + n));
inbox.add("Server is down");
inbox.add("Deploy finished");
console.log("count:", inbox.count(), "| latest:", inbox.latest());
// ✅ Expected output:
// Badge shows 1
// Badge shows 2
// count: 2 | latest: Deploy finishedKey Takeaways
Component Patterns
- ✓ Factory functions for simple components
- ✓ Custom Elements for browser-native components
- ✓ Shadow DOM for style encapsulation
- ✓ Slots for flexible content injection
- ✓ Template cloning for fast rendering
Communication & State
- ✓ Custom events for parent communication
- ✓ Event bus for cross-component messaging
- ✓ Global store for shared state
- ✓ One-way data flow (data down, events up)
- ✓ Attribute observers for reactivity
Practice quiz
What is a reusable UI component?
- A single global variable
- A CSS file
- A self-contained piece of UI with its own markup, styles, logic, and state
- A server endpoint
Answer: A self-contained piece of UI with its own markup, styles, logic, and state. A reusable component is self-contained — it has its own markup, styles, logic, and state, and can be instantiated multiple times.
In the factory function pattern, what does the function return?
- An object exposing the DOM element and a public API for external control
- A string of HTML only
- Nothing
- A CSS class
Answer: An object exposing the DOM element and a public API for external control. The factory returns an object with the DOM element plus a public API (methods like setValue, getValue, destroy).
Why is the <template> element useful for components?
- It renders immediately
- It runs JavaScript
- It styles the page globally
- It is not rendered but can be cloned efficiently
Answer: It is not rendered but can be cloned efficiently. Template elements are not rendered, but their content can be cloned efficiently, making the pattern fast.
How does a component notify parent code of something happening?
- By mutating a global variable
- By dispatching a CustomEvent that parents can listen to
- By calling document.write
- By reloading the page
Answer: By dispatching a CustomEvent that parents can listen to. Components emit CustomEvents (via dispatchEvent) so parent code can listen and stay loosely coupled.
What does Shadow DOM provide?
- A private DOM tree with completely isolated styles
- Faster network requests
- Automatic routing
- Server-side rendering
Answer: A private DOM tree with completely isolated styles. Shadow DOM creates a private DOM tree inside the element with fully isolated styles — no leakage to or from global CSS.
Which lifecycle callback runs when a custom element is added to the DOM?
- constructor()
- disconnectedCallback()
- connectedCallback()
- attributeChangedCallback()
Answer: connectedCallback(). connectedCallback() is called when the element is inserted into the DOM.
What must a custom element declare to be notified of attribute changes?
- A CSS variable
- A static observedAttributes getter listing the attributes
- A render() method
- A shadow root only
Answer: A static observedAttributes getter listing the attributes. static get observedAttributes() returns the attribute names to watch; attributeChangedCallback then fires for them.
What are slots used for?
- Network caching
- Encrypting markup
- Sorting lists
- Letting users inject content into specific areas of a component
Answer: Letting users inject content into specific areas of a component. Slots let users inject content into named areas of a component, similar to React's children but with named targets.
In the global store (Redux/Vuex pattern), how do components react to state changes?
- They poll the DOM
- They subscribe with a callback that runs when notify() is called
- They reload the page
- They use setInterval
Answer: They subscribe with a callback that runs when notify() is called. Components call store.subscribe(callback); on set(), notify() runs every subscriber with the new state.
What does an event bus enable?
- Direct parent-only communication
- Faster CSS
- Any component to emit or listen to events without direct references (loose coupling)
- Automatic persistence
Answer: Any component to emit or listen to events without direct references (loose coupling). An event bus lets unrelated components emit and listen to events without holding direct references to each other.
Continue this course
- Previous: JavaScript Performance Optimization Techniques
- Next: Module Bundlers: Webpack, Parcel, Vite (Overview) — Understand how bundlers work and configure them for your project
- Quick reference: JavaScript cheat sheet
Frequently asked questions
What is a reusable UI component in JavaScript?
A reusable UI component is a self-contained piece of interface — like a button, modal or card — packaged with its own markup, styling and behaviour so you can use it many times across a project without rewriting code. Reusable components make apps consistent and far easier to maintain.
How do you create reusable components in vanilla JavaScript?
You can build reusable components in plain JavaScript using functions or classes that generate and return DOM elements, Web Components (custom elements), or template literals combined with event delegation. This lesson walks through practical patterns with working examples.
Do I need a framework like React to build UI components?
No. Frameworks like React make components easier to manage at scale, but you can build reusable, dynamic components with vanilla JavaScript — and understanding how to do so makes you a stronger developer and helps you grasp what frameworks do under the hood.