For a decade, starting a new frontend project meant picking a framework first and asking questions later. In 2026 that reflex deserves a second look. Browsers now ship native modal dialogs, popovers, ES modules, import maps, view transitions, container queries and the :has() selector — features that used to justify pulling in a library. For a large share of websites, especially content sites, marketing pages and stores, plain “vanilla” JavaScript layered on server-rendered HTML is faster to load, simpler to maintain and easier to make accessible. This article shows what the platform gives you, the patterns we use to keep vanilla code organized, and an honest framework for deciding when a framework really is the better tool.
The real cost of defaulting to a framework#
Frameworks solve real problems: predictable rendering, component composition and state management at scale. But every one of them has costs that are easy to ignore at the start of a project:
- JavaScript is the most expensive byte on the web. Unlike an image, script must be downloaded, parsed, compiled and executed on the main thread. On mid-range phones, that execution time is what users feel.
- Hydration doubles the work. Server-rendered framework pages typically send HTML and the code to recreate it, then re-run components in the browser to attach behaviour. That burst of work lands exactly when users begin to interact, which is bad news for Interaction to Next Paint.
- Dependency churn. Major versions, router rewrites and ecosystem shifts create maintenance work that has nothing to do with your product.
- Accessibility is not automatic. Component libraries often reimplement native controls, and each reimplementation is a chance to get keyboard handling and semantics wrong.
None of this means frameworks are bad. It means the decision should be made deliberately, based on how interactive the interface genuinely is.
What the platform gives you now#
The single biggest change of recent years is how much UI behaviour has moved into HTML and CSS. MDN tracks cross-browser readiness using Baseline: “newly available” means a feature works in the latest versions of all major browsers, and “widely available” means it has done so for at least two and a half years.
| You need | Native solution | Library it often replaces |
|---|---|---|
| Modal window | <dialog> with showModal() |
Modal/lightbox plugins |
| Menus, tooltips, toasts | popover attribute |
Dropdown and tooltip libraries |
| Accordion / FAQ | <details> and <summary>, with name for exclusive groups |
Accordion scripts |
| Animated state changes | View Transition API | Page transition and animation libraries |
| HTTP requests | fetch() with AbortController |
AJAX helper libraries |
| Dates, numbers, plurals | Intl formatters |
Date and number formatting libraries |
| Query strings | URL and URLSearchParams |
Query-string parsers |
| Deep copies | structuredClone() |
Utility-library clone helpers |
| Scroll-triggered behaviour | IntersectionObserver |
Scroll-spy and lazy-load scripts |
| Form validation | Constraint Validation API | Validation plugins |
| Parent and component-aware styling | CSS :has() and container queries |
JavaScript that toggles layout classes |
| Modular code | ES modules and import maps | Module loaders and some build steps |
The Popover API is a good example of how much this matters. A dropdown menu needs top-layer rendering, light dismiss (closing on outside click or Escape) and correct focus behaviour. With popover and popovertarget, you get all of that from two attributes and no script.
<button popovertarget="account-menu">Account</button>
<div id="account-menu" popover>
<ul>
<li><a href="/account/orders/">Orders</a></li>
<li><a href="/account/settings/">Settings</a></li>
</ul>
</div>
Modal dialogs without a library#
Modals are the classic example of a component that is deceptively hard to build well: focus must move into the dialog, the rest of the page must become inert, Escape must close it and focus must return to the trigger afterwards. The native <dialog> element handles all of this when opened with showModal(). The only script you write is the part that is specific to your product:
const dialog = document.getElementById('newsletter-dialog');
document.querySelector('[data-open-newsletter]')
.addEventListener('click', () => dialog.showModal());
// A <form method="dialog"> inside the dialog closes it on submit;
// returnValue tells you which button was used.
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'subscribe') trackSignup();
});
Native elements also bring correct semantics for free, which is why we consider semantic HTML the first layer of any interface.
Start with HTML, then enhance#
The architecture that makes vanilla JavaScript scale is progressive enhancement: the server renders complete, working HTML, and scripts add behaviour on top. A filter form still submits and reloads without JavaScript; with JavaScript, it updates results in place. Links still navigate; with JavaScript, they might prefetch or animate.
In practice we wire behaviour with data- attributes and a single delegated listener, so new markup inserted later works automatically and there is no per-element setup to forget:
// One listener handles every [data-action] element on the page,
// including elements added after load.
const actions = {
'copy-code': (el) => navigator.clipboard.writeText(
document.getElementById(el.dataset.target).textContent
),
'toggle-theme': () => document.documentElement.classList.toggle('theme-dark'),
};
document.addEventListener('click', (event) => {
const trigger = event.target.closest('[data-action]');
if (!trigger) return;
const handler = actions[trigger.dataset.action];
if (handler) {
event.preventDefault();
handler(trigger, event);
}
});
This pattern keeps HTML as the source of truth, keeps behaviour discoverable (search the codebase for data-action="copy-code") and works well in CMS environments like WordPress, where markup comes from many templates and plugins.
Managing state without a framework#
“State management” sounds like it requires a library, but most UI state on content sites is small: a cart count, a selected filter, an open panel. For shared state, a tiny store built on the native EventTarget class goes a long way:
// store.js — a minimal observable store
export class Store extends EventTarget {
#state;
constructor(initial) {
super();
this.#state = structuredClone(initial);
}
get state() {
return this.#state;
}
update(patch) {
this.#state = { ...this.#state, ...patch };
this.dispatchEvent(new CustomEvent('change', { detail: this.#state }));
}
}
// cart.js
import { Store } from './store.js';
export const cart = new Store({ count: 0 });
// header-badge.js
import { cart } from './cart.js';
const badge = document.querySelector('[data-cart-count]');
cart.addEventListener('change', (e) => {
badge.textContent = e.detail.count;
});
Components subscribe only to what they need and update only their own DOM. If you find yourself building derived state, undo stacks and complex synchronization on top of this, that is a strong signal you have outgrown vanilla — more on that below.
Rendering and DOM updates, safely#
The two classic pitfalls of hand-written rendering are security and performance. Building HTML strings from data and assigning them to innerHTML invites cross-site scripting if any value comes from users or an API. The <template> element plus textContent avoids both string concatenation and injection:
<template id="result-item">
<li class="result">
<a class="result__link"></a>
<p class="result__meta"></p>
</li>
</template>
const tpl = document.getElementById('result-item');
const list = document.querySelector('#results');
const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
function renderResults(items) {
const fragment = document.createDocumentFragment();
for (const item of items) {
const node = tpl.content.cloneNode(true);
const link = node.querySelector('.result__link');
link.href = item.url;
link.textContent = item.title; // text, never parsed as HTML
node.querySelector('.result__meta').textContent = price.format(item.price);
fragment.append(node);
}
list.replaceChildren(fragment); // one DOM write
}
- Never pass untrusted data to
innerHTML,insertAdjacentHTML()ordocument.write(). - Set URLs from data carefully: validate that links use
https:or a relative path so ajavascript:URL cannot slip through. - A Content Security Policy is a valuable second line of defence, and vanilla code without inline handlers makes a strict policy much easier to adopt.
Batching into a DocumentFragment and swapping with replaceChildren() means one layout instead of one per item. For long-running work, yield to the main thread between chunks so the page stays responsive.
Animate state changes with view transitions#
The View Transition API lets the browser animate between two DOM states. Wrap your update in document.startViewTransition() when it exists, and fall back to an instant update when it does not — a textbook progressive enhancement.
function update(items) {
if (!document.startViewTransition) return renderResults(items);
document.startViewTransition(() => renderResults(items));
}
Web components for reusable widgets#
When a piece of behaviour needs to be reused across pages, sites or even different stacks, custom elements are a good fit. They are native, framework-agnostic and survive a WordPress theme change. A useful mindset is “HTML web components”: the element wraps ordinary server-rendered markup and enhances it, rather than rendering everything from JavaScript.
// <copy-button><button type="button">Copy</button></copy-button>
class CopyButton extends HTMLElement {
#controller;
connectedCallback() {
this.#controller = new AbortController();
const button = this.querySelector('button');
button.addEventListener('click', async () => {
const text = document.getElementById(this.getAttribute('for'))?.textContent ?? '';
await navigator.clipboard.writeText(text);
button.textContent = 'Copied';
}, { signal: this.#controller.signal });
}
disconnectedCallback() {
this.#controller.abort(); // removes listeners when the element leaves the DOM
}
}
customElements.define('copy-button', CopyButton);
Note the AbortController: passing its signal to addEventListener lets you remove every listener with one call, which prevents the memory leaks that plague long-lived vanilla code. Shadow DOM is optional; for components that should inherit your site’s styles, light DOM is often simpler.
Modules, import maps and build steps#
Native JavaScript modules work in every modern browser, and import maps let you use bare specifiers like import { cart } from 'cart' without a bundler. Modules are deferred by default, run in strict mode and are scoped, so you get organization without global variables.
<script type="importmap">
{
"imports": {
"store": "/assets/js/store.js",
"cart": "/assets/js/cart.js"
}
}
</script>
<link rel="modulepreload" href="/assets/js/cart.js">
<script type="module" src="/assets/js/main.js"></script>
“No build step” is a great default for small projects, but it is not a principle to defend at all costs. A light build (esbuild, Rollup or Vite) is worth it once you want TypeScript, minification, content-hashed filenames for long-term caching, or when a deep import graph would create request waterfalls. The goal is shipping less JavaScript, not avoiding tools.
Performance hygiene for vanilla code#
Dropping a framework removes a fixed cost, but it does not make code fast automatically. Vanilla JavaScript can still block the main thread, thrash layout and leak memory. These habits keep it lean.
Load scripts at the right time#
Classic scripts in the <head> without defer block parsing. Use type="module" (deferred by default) or defer for everything that touches the DOM, and load truly optional features — a chat widget, a video player, a map — only when they are needed. Dynamic import() makes that easy:
// Load the map module only when its container scrolls into view
const mapEl = document.querySelector('[data-map]');
if (mapEl) {
const io = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
const { initMap } = await import('/assets/js/map.js');
initMap(mapEl);
}, { rootMargin: '200px' });
io.observe(mapEl);
}
Avoid layout thrashing#
Reading a geometry property such as offsetHeight or calling getBoundingClientRect() right after changing styles forces the browser to compute layout synchronously. In a loop, that can turn a 5 ms update into a 200 ms one. Read everything you need first, then write; or schedule writes in requestAnimationFrame(). Prefer toggling a class or a data attribute over setting many inline styles.
Listen efficiently#
- Use event delegation instead of attaching hundreds of identical listeners.
- Mark
touchstartandwheellisteners as{ passive: true }when you do not callpreventDefault(), so scrolling is never blocked. - Debounce input-driven work such as live search, and cancel stale requests with
AbortControllerso out-of-order responses never overwrite newer results. - Prefer
IntersectionObserverandResizeObserverover scroll and resize handlers that poll layout.
Measure, don’t assume#
Record an interaction in the DevTools Performance panel with CPU throttling enabled, and look for long tasks over 50 ms. That single habit catches most regressions before your users do — and before they show up in your field data.
When a framework is the right call#
We build with frameworks too. The honest test is how much of the interface is driven by client-side state that changes frequently and affects many parts of the screen at once.
| Project type | Typical recommendation | Why |
|---|---|---|
| Marketing site, blog, landing page | Server-rendered HTML + vanilla JS | Mostly static content; interactions are isolated |
| WordPress or WooCommerce site | Theme templates + vanilla JS / web components | Works with the CMS rendering model and plugins |
| Content site with a few rich widgets | Islands: vanilla by default, a framework only inside the widget | Pay for the framework only where it earns its keep |
| Dashboard, editor, collaborative app | Component framework | Dense, shared, rapidly changing client state |
| Large team, long-lived product | Framework with strong conventions | Shared patterns and tooling reduce coordination costs |
Data-heavy interfaces are a common grey area. A single interactive chart rarely needs a framework, as we discuss in SVG vs Canvas vs WebGL; a full analytics dashboard with linked filters probably does. Likewise, a decorative animation like the one in our Canvas particle tutorial is a perfect vanilla project, and even on-device inference can be wired up with a few modules, as shown in Practical Machine Learning in the Browser.
Two warning signs suggest you have outgrown vanilla: you keep writing code to keep several views in sync with the same data, and new team members struggle to find where a piece of UI is updated. At that point a framework’s conventions are worth their weight, and you can often adopt one incrementally — inside a single island first — rather than rewriting everything.
- Native HTML features —
dialog,popover,details, view transitions — replace many UI libraries outright. - Render on the server, enhance with delegated listeners and
data-attributes. - Use
<template>andtextContentfor safe, fast rendering; batch DOM writes. - Custom elements with
AbortControllercleanup give you reusable, leak-free widgets. - Choose a framework when client-side state is dense and shared — not by default.
Conclusion: ship less, deliberately#
The web platform has quietly absorbed much of what frameworks were invented to provide. That does not make frameworks obsolete, but it does flip the default: start with HTML, add the minimum JavaScript that delivers the experience, and adopt a framework when the interface truly demands it. The result is usually a faster site, a smaller maintenance burden and better accessibility.
If you are planning an interactive product and want an honest recommendation on architecture, our Custom Web Application Development service starts with exactly that conversation — vanilla, framework or a mix of both. For simpler, conversion-focused pages, see our Landing Page Development service, or contact us to talk through your project.
Frequently asked questions
Is vanilla JavaScript still relevant in 2026?
Very much so. Browsers now provide native dialogs, popovers, ES modules, import maps, view transitions and rich formatting APIs, so many interfaces that once needed a library can be built with plain JavaScript. Frameworks remain valuable for large, highly interactive applications.
Are web components a replacement for React or Vue?
Not exactly. Custom elements are excellent for self-contained, reusable widgets that work in any page or CMS, but they do not provide a reactive rendering model or state management by themselves. Many teams use them alongside server-rendered HTML or even inside framework apps.
Do I need a bundler for a vanilla JavaScript project?
Not necessarily. Native ES modules and import maps let you ship modular code without a build step. A bundler or build tool still helps for large codebases, minification, TypeScript and when you have many small modules that would otherwise create long request chains.
Will avoiding a framework improve my Core Web Vitals?
Often, yes, especially Interaction to Next Paint and Largest Contentful Paint on content sites, because less JavaScript means fewer long tasks and no hydration cost. But badly written vanilla code can be slow too; what matters is how much work runs on the main thread.
