The genuinely new additions around Element are three: Element.checkVisibility() (Baseline March 2024), the Popover API (Baseline January 2025), and Node.moveBefore() (2025, not yet in Safari).
A caveat first. You will find attachShadow() and requestFullscreen() presented as “new Element methods.” All of those have been available since the 2010s. They are not new.
This article separates what actually became available recently from what has existed for years but is frequently used incorrectly, with working code for both.
Sponsored
When each of these became available
Articles about DOM manipulation age badly, so checking when a feature landed is a habit worth having.
| Method / API | Available since | Status |
|---|---|---|
Element.attachShadow() |
around 2016 | Established |
Element.requestFullscreen() |
early 2010s (prefixed) | Established |
Element.setPointerCapture() |
around 2015 | Established |
Element.getAnimations() |
2019–2020 | Established |
Element.checkVisibility() |
Baseline March 2024 | New |
| Popover API | Baseline January 2025 | New |
Node.moveBefore() |
Chrome/Edge 133 (Feb 2025), Firefox 144. Not in Safari | Newest |
Element.checkVisibility(): is this element actually rendered?
One line replaces the old dance of reading offsetParent and calling getComputedStyle() several times.
const el = document.querySelector('#target');
// false if display:none or content-visibility:hidden
el.checkVisibility();
// also treat opacity:0 and visibility:hidden as "not visible"
el.checkVisibility({
opacityProperty: true,
visibilityProperty: true,
contentVisibilityAuto: true,
});
| Option | When true, returns false for |
|---|---|
opacityProperty |
opacity: 0 |
visibilityProperty |
visibility: hidden / collapse |
contentVisibilityAuto |
rendering skipped by content-visibility: auto |
Even with no options, display: none and content-visibility: hidden return false.
One important limitation: true does not mean the element is on screen. An element outside the viewport, or covered by another element, still returns true. What it reports is only whether CSS is rendering it.
For “has it scrolled into view”, you want the Intersection Observer API instead—that is a different question.
// avoid trying to focus something that is hidden
function focusIfVisible(el) {
if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
return false;
}
el.focus();
return true;
}
Sponsored
Popover API: tooltips and menus with no JavaScript
This is the most practically useful addition of the last few years. Two attributes give you an element in the top layer.
<button popovertarget="menu">Open menu</button>
<div id="menu" popover>
<ul>
<li><a href="#">Profile</a></li>
<li><a href="#">Settings</a></li>
</ul>
</div>
That alone gives you:
- Light dismiss—clicking outside closes it
- Escape closes it
- Top layer placement, so no
z-indexstacking problems - Only one popover open at a time (unless you use
popover="manual")
There are methods for script control too.
const menu = document.getElementById('menu');
menu.showPopover();
menu.hidePopover();
menu.togglePopover();
menu.addEventListener('toggle', (e) => {
console.log(e.oldState, '→', e.newState); // 'closed' → 'open'
});
Popover or dialog?
popover |
<dialog> + showModal() |
|
|---|---|---|
| Background interaction | Allowed | Blocked |
| Focus trapping | No | Yes |
| Outside click closes it | By default | Needs closedby="any" |
| Best for | Tooltips, menus, notifications | Confirmations, blocking input |
If you need to stop the user, use a dialog. If you are showing something supplementary, use a popover. Dialog implementation is covered in the HTML dialog element.
Node.moveBefore(): move a node without resetting it
Moving an element with appendChild() or insertBefore() removes it from the DOM first, which resets its state. Focus is lost, playing video stops, CSS animations restart.
moveBefore() moves it without detaching, so state survives.
const list = document.getElementById('list');
const item = document.getElementById('item');
const ref = document.getElementById('ref');
// old: focus and video playback are lost
list.insertBefore(item, ref);
// new: state is preserved
list.moveBefore(item, ref);
What survives:
- Focus (a text box being typed into stays active)
- Playing
<video>/<audio> - Open popovers, modals, and fullscreen
- Running CSS transitions and animations
- iframe content (no reload)
Safari does not support it as of September 2026, so feature-detect and fall back.
function moveElement(parent, node, ref) {
if (typeof parent.moveBefore === 'function') {
parent.moveBefore(node, ref);
} else {
parent.insertBefore(node, ref); // works, but loses state
}
}
This solves the classic drag-to-reorder problem where the animation on the element you are holding breaks the moment it moves.
Sponsored
attachShadow(): encapsulated components
From here on, these are the long-established APIs. attachShadow() creates a subtree that outside CSS cannot reach.
class CustomButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// adoptedStyleSheets is the modern way to pass styles
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
button {
background: #2a7fff;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 20px;
font: inherit;
cursor: pointer;
}
button:focus-visible { outline: 3px solid #ffb300; outline-offset: 2px; }
`);
shadow.adoptedStyleSheets = [sheet];
const button = document.createElement('button');
button.textContent = this.getAttribute('label') ?? 'Click Me';
shadow.appendChild(button);
}
}
customElements.define('custom-button', CustomButton);
<custom-button label="Submit"></custom-button>
- Do not push styles in as an
innerHTMLstring. WithadoptedStyleSheets, the sheet is parsed once and shared across instances mode: 'open'vs'closed': withopen, outside code can reachelement.shadowRoot.closedhides it but provides no real security, and makes testing harder.openis the usual choice
getAnimations(): grab CSS-defined animations too
getAnimations() returns animations and transitions defined in CSS as well as ones created in script. That is what makes it useful.
const box = document.querySelector('.box');
box.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }],
{ duration: 1000, iterations: Infinity }
);
document.querySelector('#stop-animation').addEventListener('click', () => {
box.getAnimations().forEach((anim) => anim.pause());
});
// wait for every animation on the page to finish
await Promise.all(
document.getAnimations().map((a) => a.finished.catch(() => {}))
);
finished rejects when an animation is cancelled, so add the catch when waiting on many at once. More on this in the Web Animations API animate() method.
requestFullscreen(): it returns a promise, so catch it
requestFullscreen() returns a promise and can fail—without user activation, or in an iframe lacking allow="fullscreen".
const content = document.querySelector('#content');
document.querySelector('#fullscreen-button').addEventListener('click', async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await content.requestFullscreen({ navigationUI: 'hide' });
}
} catch (err) {
console.warn('Fullscreen request failed:', err);
}
});
// also fires when the user leaves fullscreen with Escape
document.addEventListener('fullscreenchange', () => {
const isFull = Boolean(document.fullscreenElement);
document.body.classList.toggle('is-fullscreen', isFull);
});
Users will exit with Escape regardless of the button you provide. Track state with document.fullscreenElement and the fullscreenchange event, never with your own boolean.
setPointerCapture(): avoiding the classic drag bug
setPointerCapture() keeps sending pointer events to an element even when the pointer leaves it, so dragging does not “let go” unexpectedly.
But feeding event.clientX straight into translate() is wrong—the element jumps to the cursor the instant you grab it.
// element teleports on grab
draggable.addEventListener('pointermove', (event) => {
draggable.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
});
Keep the offset between where you pressed and where the element is, and apply only the delta. Release the capture on pointerup.
const draggable = document.querySelector('#draggable');
let dragging = false;
let startX = 0, startY = 0; // pointer position at press time
let baseX = 0, baseY = 0; // accumulated offset
draggable.addEventListener('pointerdown', (event) => {
dragging = true;
startX = event.clientX;
startY = event.clientY;
draggable.setPointerCapture(event.pointerId);
draggable.style.touchAction = 'none'; // do not fight page scrolling
});
draggable.addEventListener('pointermove', (event) => {
if (!dragging) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
draggable.style.transform = `translate(${baseX + dx}px, ${baseY + dy}px)`;
});
function endDrag(event) {
if (!dragging) return;
dragging = false;
baseX += event.clientX - startX;
baseY += event.clientY - startY;
draggable.releasePointerCapture(event.pointerId);
}
draggable.addEventListener('pointerup', endDrag);
draggable.addEventListener('pointercancel', endDrag);
- Move by delta, not by absolute coordinates
touch-action: none, or mobile page scrolling steals the gesture- Handle
pointercancel, or an interrupted drag leaves the element stuck
Summary
attachShadow(),requestFullscreen(),setPointerCapture(),getAnimations()are not 2024 additions- The actually new ones:
checkVisibility()(2024), Popover API (2025),moveBefore()(no Safari yet) checkVisibility()reports CSS rendering, not whether the element is on screen- Popovers for supplementary UI,
<dialog>when you need to block interaction moveBefore()preserves state; feature-detect and fall back toinsertBefore()- Pass Shadow DOM styles via
adoptedStyleSheets requestFullscreen()is a promise; track state withdocument.fullscreenElement- Drag by delta, set
touch-action: none, handlepointercancel
The value of a DOM article depends heavily on when it was written. Checking the MDN compatibility table before you start saves a lot of wasted effort.