JavaScript without the framework tax

When vanilla JS (or a tiny library) is the grown-up choice for marketing sites — and when a framework actually pays for itself. Progressive enhancement, performance and maintainability.

Not every website needs a single-page app and a multi-step build pipeline. Many business sites pay a “framework tax” in bundle size, hosting complexity, hiring requirements and cognitive load — for interactions a few well-written modules could handle. This article is about choosing the right amount of technical enough to be useful, clear enough for non-specialists to follow the trade-offs.

If you run a UK marketing site, brochure presence or content-led service business, the default should not be “spin up React because that is what tutorials do”. The default should be: make the document work, then add behaviour. Frameworks earn their keep when state and UI complexity grow past what a few modules can manage safely.

Start with progressive enhancement

Forms should work without JavaScript. Navigation should work without JavaScript. Content should be readable if a script fails or a network is flaky. Then layer behaviour: sticky CTAs, mega menus, carousels, filters, chart widgets.

That order keeps accessibility and SEO honest. Search engines and assistive tech get a real document, not an empty <div id="root"> hoping a bundle arrives. It also means a broken third-party script or a content blocker does not turn your homepage into a blank rectangle.

Practically, that looks like:

  • Server-rendered HTML for the primary content and primary calls to action
  • Forms that POST to a real endpoint and show a real success or error page
  • JavaScript that improves the experience (instant validation, smoother UI) without being the only path
  • Feature detection instead of assuming every API exists on every device
If the homepage is mostly content, serve content first. Interactivity is a garnish.

Vanilla patterns that cover most marketing sites

Modern browsers are capable. You often don’t need a library to:

  • Event delegation — listen on a stable parent instead of binding 200 click handlers
  • IntersectionObserver — scroll reveals, lazy work, “in view” analytics
  • matchMedia — respond to breakpoints without resize thrashing
  • fetch — simple API calls with clear error states
  • ES modules — split code into small files the browser can cache
  • dialog / focus management — accessible modals without a UI kit
// Example: open/close a mobile nav without a framework
const toggle = document.querySelector('[data-nav-toggle]');
const menu = document.querySelector('[data-nav-menu]');
toggle?.addEventListener('click', () => {
  const open = menu.classList.toggle('is-open');
  toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});

Prefer defer on scripts so HTML parses first. Avoid blocking the first paint with a 400KB bundle on a brochure page. If you need modules:

<script type="module" src="/assets/js/main.js"></script>

Keep entry files thin: import only what the page needs. A homepage rarely needs the same modules as a logged-in dashboard.

A small architecture for multi-page sites

Without a framework you still need structure, or you reinvent spaghetti:

  • main.js — bootstraps page modules based on data-page or presence of hooks
  • nav.js, forms.js, carousel.js — one concern each
  • Shared helpers for focus traps, debounce, money formatting
  • Optional TypeScript later if the surface area grows
// main.js — only run what the page needs
import { initNav } from './nav.js';
import { initContactForm } from './forms.js';

initNav();
if (document.querySelector('[data-contact-form]')) {
  initContactForm();
}

This is boring on purpose. Boring front-end is easy to hand over, easy to debug, and easy to extend when marketing wants “one more accordion on the services page”.

When a framework is the right call

Use React, Vue, Svelte or similar when you have real application state:

  • Dashboards with live filters and multi-step wizards
  • Collaborative tools or complex client-side routing
  • Design systems shared across a large product surface
  • Teams already standardised on that stack for hiring and tooling
  • Optimistic UI where local state and server state constantly reconcile

Don’t use a framework to animate a hero button or toggle a FAQ. The complexity cost shows up later: build steps, dependency updates, hydration issues, and “nobody wants to touch the front-end folder”.

A useful rule of thumb: if most of the value is in the document (copy, trust, SEO, conversion), favour server-rendered HTML + small JS. If most of the value is in continuous interaction (filters, canvas, multi-pane tools), a framework often pays for itself.

Performance budget (simple version)

  • Keep main-thread JS lean on public marketing pages
  • Split code so admin/app areas don’t tax the homepage
  • Measure LCP and INP on a mid-range phone, not only a MacBook on Wi‑Fi
  • Third-party scripts (chat, tags, heatmaps) count against the same budget as your code
  • Track transfer size and parse/compile time — “minified” is not free if the file is huge

A “beautiful” site that feels laggy on mobile loses trust before the copy is read. Aim for a budget you can explain: for example, under ~100–150KB of first-party JS on a typical marketing homepage unless you have a strong reason to exceed it.

// Debounce expensive work so typing/scroll stays responsive
function debounce(fn, ms = 150) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

State, DOM and maintainability

Without a framework, you still need discipline:

  • One place owns each piece of UI state (open/closed, selected tab)
  • Avoid giant app.js files — modules with clear names
  • Prefer data attributes for hooks (data-mega) over brittle CSS-selector chains
  • Write failure modes: what if the API is down? what if the node is missing?
  • Prefer progressive DOM updates over full innerHTML rewrites when user input is involved

When you do need client state that grows, consider a tiny library (Alpine, petite-vue, htmx-style patterns) before jumping to a full SPA. The goal is leverage, not ideology.

Accessibility is not a phase

Keyboard focus, aria-expanded, escape-to-close, and reduced-motion preferences are part of the product. If your fancy menu traps focus or your carousel auto-plays without a pause control, you’ve made the site worse for real users.

// Respect reduced motion
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
  // run decorative animation
}

Checklist for interactive chrome:

  • Focusable controls are real <button> / <a> elements, not clickable divs
  • Modals restore focus to the opener on close
  • Errors in forms are linked with aria-describedby or clear text next to fields
  • Colour is not the only signal for state

Forms: the make-or-break interaction

Most business sites exist to collect a lead. Treat the contact or quote form as a product:

  • Validate on the server even if you validate in the browser
  • Show field-level errors without wiping the user’s input
  • Disable double-submit carefully (re-enable on error)
  • Keep the success state calm and clear — what happens next?
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const btn = form.querySelector('[type="submit"]');
  btn.disabled = true;
  try {
    const res = await fetch(form.action, {
      method: 'POST',
      body: new FormData(form),
      headers: { 'Accept': 'application/json' },
    });
    if (!res.ok) throw new Error('Request failed');
    form.reset();
    // show success region
  } catch {
    // show recoverable error; re-enable button
  } finally {
    btn.disabled = false;
  }
});

Tooling without ceremony

You can still use TypeScript, ESLint and a small bundler when the project earns it. The point is optional complexity: start with readable modules, add tooling when files multiply or the team grows — not because a starter kit insisted.

Good reasons to add a build step:

  • You need TypeScript for a growing module graph
  • You must transpile for a legacy browser policy (rare on modern marketing sites)
  • You want tree-shaking for a larger shared kit

Weak reasons: “every repo has Vite” or “the template came with Redux”.

Third-party scripts and the framework illusion

Sometimes the slow part is not your code — it is Tag Manager, chat widgets, A/B tools and embeds. Before you rewrite the front end in a framework to “feel modern”, measure where the main thread actually spends time. Replacing a 30KB nav script with a 250KB SPA while leaving four third-party tags untouched is not a performance strategy.

Decision cheat-sheet

  • Marketing site / brochure / blog → progressive enhancement + small modules
  • Shop with light UI chrome → server-rendered pages + minimal JS
  • Client portal with complex UI → framework or carefully structured modules; pick based on team
  • Already drowning in plugins → reduce, don’t add another SPA layer
  • Hiring constraint → choose the stack your maintainers can support, not the trendiest one

Worked examples (vanilla JS)

Small, copyable patterns for marketing sites and light portals — no build step required.

1) Accessible mobile nav

const btn = document.querySelector('[data-nav-toggle]');
const menu = document.querySelector('[data-nav-menu]');

btn?.addEventListener('click', () => {
  const open = !menu.classList.contains('is-open');
  menu.classList.toggle('is-open', open);
  btn.setAttribute('aria-expanded', open ? 'true' : 'false');
  document.body.classList.toggle('nav-open', open);
});

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && menu?.classList.contains('is-open')) {
    menu.classList.remove('is-open');
    btn?.setAttribute('aria-expanded', 'false');
    document.body.classList.remove('nav-open');
  }
});

2) Fetch with clear loading / error states

async function sendLead(form) {
  const status = form.querySelector('[data-status]');
  const btn = form.querySelector('[type="submit"]');
  status.textContent = 'Sending…';
  btn.disabled = true;
  try {
    const res = await fetch('/api/lead', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(Object.fromEntries(new FormData(form))),
    });
    const data = await res.json().catch(() => ({}));
    if (!res.ok) throw new Error(data.error || 'Could not send');
    status.textContent = 'Thanks — we will reply shortly.';
    form.reset();
  } catch (err) {
    status.textContent = err.message || 'Something went wrong';
  } finally {
    btn.disabled = false;
  }
}

document.querySelector('#lead-form')?.addEventListener('submit', (e) => {
  e.preventDefault();
  sendLead(e.currentTarget);
});

3) Reveal on scroll (IntersectionObserver)

const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const els = document.querySelectorAll('.reveal');

if (reduced || !('IntersectionObserver' in window)) {
  els.forEach((el) => el.classList.add('is-visible'));
} else {
  const io = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-visible');
        io.unobserve(entry.target);
      }
    });
  }, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' });
  els.forEach((el) => io.observe(el));
}

4) Debounced resize / search input

function debounce(fn, ms = 200) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

const => {
  // filter list / call API
  console.log('search', q);
}, 250);

document.querySelector('#q')?.addEventListener('input', (e) => {
  onSearch(e.target.value.trim());
});

Need front-end that feels premium without a 400KB bundle? Let’s talk →