Creating and Modifying Elements
Building the Page with JavaScript
Selecting elements is only half the story. The real power of the DOM comes from creating, modifying, and removing elements dynamically. Every interactive feature you've ever used — a new chat message appearing, a todo item being added to a list, a modal popping open — involves DOM manipulation.
The key question is: what's the right way to do it? Because there are multiple approaches, and some are dramatically better than others depending on what you're doing.
Think of DOM manipulation like editing a document in Google Docs. You can add new paragraphs (createElement), copy-paste sections (cloneNode), delete paragraphs (removeChild/remove), or select-all and replace everything at once (innerHTML). The select-all approach is fast to type but destructive — it wipes out everything and rebuilds from scratch, destroying any existing state like cursor position or event listeners. The surgical approach takes more code but preserves what's already there.
Creating Elements
createElement — The Standard Way
document.createElement creates a new element node in memory. It doesn't appear on the page until you insert it into the DOM tree.
const button = document.createElement('button');
button.textContent = 'Click me';
button.className = 'btn-primary';
// Still invisible — exists only in memory
// Now insert it into the page
document.body.appendChild(button);
This two-step process (create, then insert) is intentional. It lets you fully configure an element before it touches the DOM, which avoids unnecessary reflows.
Building Complex Structures
For elements with children, you build them piece by piece:
const card = document.createElement('div');
card.className = 'card';
const title = document.createElement('h2');
title.textContent = 'Card Title';
const body = document.createElement('p');
body.textContent = 'Card description goes here.';
card.appendChild(title);
card.appendChild(body);
document.querySelector('.container').appendChild(card);
DocumentFragment — Batch Your Inserts
When you need to add many elements, inserting them one by one triggers a layout recalculation each time. A DocumentFragment is a lightweight container that lives entirely in memory. You build your structure inside it, then insert the entire fragment in a single operation.
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const item = document.createElement('li');
item.textContent = `Item ${i + 1}`;
fragment.appendChild(item);
}
// One DOM insertion instead of 100
document.querySelector('ul').appendChild(fragment);
Modern rendering engines batch DOM mutations that happen synchronously. So 100 consecutive appendChild calls in the same synchronous block often perform similarly to a DocumentFragment. But using fragments is still good practice — it makes your intent clear and guarantees batching in every engine.
Inserting Elements
append and prepend (modern)
const container = document.querySelector('.list');
// append — adds to the end (can take multiple arguments, including strings)
container.append(newElement);
container.append('Some text', anotherElement); // accepts strings too
// prepend — adds to the beginning
container.prepend(newElement);
appendChild (classic)
// appendChild — returns the appended node
const appended = container.appendChild(newElement);
The difference: append accepts strings and multiple arguments. appendChild only accepts a single node but returns it (useful for chaining).
insertBefore
Inserts a node before a specific reference node:
const list = document.querySelector('ul');
const referenceItem = list.children[2]; // the 3rd item
const newItem = document.createElement('li');
newItem.textContent = 'Inserted before item 3';
list.insertBefore(newItem, referenceItem);
before, after, and replaceWith (modern)
These methods are called on the target element itself:
const target = document.querySelector('.target');
// Insert a sibling before
target.before(newElement);
// Insert a sibling after
target.after(newElement);
// Replace the element entirely
target.replaceWith(newElement);
Removing Elements
remove (modern)
The simplest way to remove an element from the DOM:
const element = document.querySelector('.old-item');
element.remove();
removeChild (classic)
The older approach requires calling removeChild on the parent:
const parent = document.querySelector('.list');
const child = parent.children[0];
parent.removeChild(child);
Cloning Elements
cloneNode creates a copy of an element. Pass true for a deep clone (includes all descendants), or false for a shallow clone (just the element itself, no children).
const original = document.querySelector('.template');
// Shallow clone — just the outer element
const shallow = original.cloneNode(false);
// Deep clone — the element and ALL its descendants
const deep = original.cloneNode(true);
document.body.appendChild(deep);
cloneNode copies the element's attributes and structure, but it does not copy event listeners added with addEventListener. If you clone a button that has a click handler, the clone won't have that handler. You need to re-attach listeners to cloned elements.
Modifying Content: innerHTML vs textContent vs innerText
These three properties all deal with an element's content, but they behave very differently.
textContent — Fast and Safe
Returns or sets the raw text content of an element and all its descendants. No HTML parsing.
const el = document.querySelector('.message');
// Reading — gets all text, ignores tags
el.textContent; // "Hello World" (even if "World" is inside a <strong>)
// Writing — replaces everything with plain text
el.textContent = '<b>Not bold</b>'; // renders as literal text: <b>Not bold</b>
innerText — The "Visual" Text
Similar to textContent but respects CSS visibility. It returns only the text that's actually visible on screen, and it triggers a reflow to compute visibility.
const el = document.querySelector('.content');
// innerText hides what CSS hides
// If a child has display:none, its text is excluded
el.innerText; // only visible text
// textContent returns everything regardless of CSS
el.textContent; // all text, including hidden elements
innerHTML — Parses HTML
Reads or sets the HTML markup inside an element. When you set it, the browser parses the string as HTML and builds DOM nodes.
const container = document.querySelector('.container');
// Reading — get HTML as a string
container.innerHTML; // "<h1>Title</h1><p>Content</p>"
// Writing — replaces all content with parsed HTML
container.innerHTML = '<h2>New Title</h2><p>New content</p>';
Never use innerHTML with user-supplied data. It parses HTML, which means an attacker can inject scripts. If you're inserting text that came from a user, always use textContent instead.
// DANGEROUS — user input could contain script tags
element.innerHTML = userInput;
// SAFE — treated as plain text, no HTML parsing
element.textContent = userInput;
classList — Managing CSS Classes
The classList API is the modern way to add, remove, and toggle CSS classes.
const element = document.querySelector('.card');
element.classList.add('active'); // add a class
element.classList.remove('loading'); // remove a class
element.classList.toggle('expanded'); // add if missing, remove if present
element.classList.contains('active'); // check if class exists → true/false
element.classList.replace('old', 'new'); // swap one class for another
// Add multiple classes at once
element.classList.add('highlight', 'animate', 'visible');
toggle also accepts a second argument — a boolean that forces the class on or off:
// Force add (true) or force remove (false)
element.classList.toggle('dark', isDarkMode);
// Equivalent to: if (isDarkMode) add('dark') else remove('dark')
dataset — Custom Data Attributes
HTML data-* attributes are accessible in JavaScript through the dataset property. The attribute name is converted from kebab-case to camelCase.
// HTML: <div data-user-id="42" data-active="true" data-role="admin">
const el = document.querySelector('[data-user-id]');
el.dataset.userId; // "42" (always a string)
el.dataset.active; // "true" (string, not boolean!)
el.dataset.role; // "admin"
// Set data attributes
el.dataset.score = '100'; // adds data-score="100" to the HTML
// Remove a data attribute
delete el.dataset.score;
dataset values are always strings. data-active="true" gives you the string "true", not the boolean true. You need to convert: el.dataset.active === 'true' or Number(el.dataset.count).
setAttribute and getAttribute
For attributes that aren't covered by specific properties, use the generic attribute methods:
const link = document.querySelector('a');
link.getAttribute('href'); // read
link.setAttribute('href', '/new'); // write
link.removeAttribute('target'); // remove
link.hasAttribute('rel'); // check → true/false
DOM elements have both properties (JavaScript object properties) and attributes (HTML markup attributes). They usually sync, but not always. For example, input.value (property) reflects the current value, while input.getAttribute('value') returns the initial value from the HTML. When in doubt, use properties for reading current state and setAttribute for setting HTML attributes.
Styling Elements
Inline Styles
const box = document.querySelector('.box');
// Set individual styles
box.style.backgroundColor = 'blue';
box.style.fontSize = '16px';
box.style.marginTop = '20px';
// Note: CSS properties use camelCase in JavaScript
// background-color → backgroundColor
// font-size → fontSize
Reading Computed Styles
element.style only reads inline styles. To get the actual computed value (including CSS from stylesheets), use getComputedStyle:
const el = document.querySelector('.box');
// Only reads inline styles
el.style.color; // "" (empty if set via stylesheet)
// Reads the actual computed value
getComputedStyle(el).color; // "rgb(0, 0, 0)"
getComputedStyle(el).fontSize; // "16px"
- 1Create elements with createElement, configure them in memory, then insert once
- 2Use DocumentFragment when inserting many elements to batch DOM updates
- 3textContent is safe for user input; innerHTML parses HTML and is an XSS risk with untrusted data
- 4classList.toggle is your best friend for toggling UI states
- 5dataset values are always strings — convert them manually for numbers and booleans
| What developers do | What they should do |
|---|---|
| Using innerHTML to insert user-generated text innerHTML parses HTML, allowing script injection (XSS attacks). textContent treats input as plain text with no parsing | Using textContent for any user-supplied data |
| Expecting cloneNode to copy event listeners cloneNode copies structure and attributes but not addEventListener handlers. If you need listeners on clones, add them after cloning | Re-attaching event listeners to cloned elements manually |
| Setting styles with style.background-color (kebab-case) JavaScript property access doesn't support hyphens in property names. CSS property names must be converted to camelCase when used with element.style | Using camelCase: style.backgroundColor |