This page was opened from file://. Chromium blocks the local CSS and JS for this site in that mode. Preview it over HTTP with pnpm dev or pnpm preview.

Skip to main content
dout.dev Frontend notes, design systems, and the sharp edges of shipping.

Article

/ Archive /

HTML Is Automation Disguised as Markup (11 Features That Deleted My JavaScript)

The markup you skip is the code you rewrite

HTML Is Automation Disguised as Markup (11 Features That Deleted My JavaScript)

Article content

The markup you skip is the code you rewrite

Sandhya Mehtele wrote a piece that should resonate with every frontend developer who has ever treated HTML as the boring part of the job. Her thesis is simple: she ignored HTML for years, reached for JavaScript and libraries to solve every problem, then discovered that the platform had already solved most of them in markup.

I am going further. HTML is not just "useful." HTML is a runtime. Every native element, every semantic tag, every built-in attribute is code the browser executes on your behalf - tested across billions of devices, maintained by browser vendors, and immune to framework churn. When you replace a native HTML feature with JavaScript, you are not adding functionality. You are paying to rewrite something the platform already shipped.

Here are the 11 features that prove it, with the architectural reasoning Sandhya's article implies but doesn't spell out.

1. Semantic HTML: the structure is the documentation

The difference between a <div> and a <nav> is not cosmetic. A <div> is a rectangle. A <nav> is a contract: it tells the browser, the screen reader, the search engine, and the next developer that this region contains navigation.

<!-- Div soup: the browser knows nothing, the screen reader knows nothing -->
<div class="header">
  <div class="logo">...</div>
  <div class="nav">
    <div class="nav-item" onclick="navigate('/home')">Home</div>
  </div>
</div>
<div class="main-content">...</div>
<div class="footer">...</div>

<!-- Semantic HTML: the structure is the documentation -->
<header>
  <a href="/" aria-label="Homepage"><img src="logo.svg" alt="Acme"></a>
  <nav aria-label="Primary">
    <ul>
      <li><a href="/home">Home</a></li>
    </ul>
  </nav>
</header>
<main>...</main>
<footer>...</footer>

Semantic HTML eliminates the need for:

  • role="navigation" on your nav - <nav> implies it
  • role="main" on your content wrapper - <main> implies it
  • role="banner" on your header - <header> implies it (when not nested in <article> or <section>)
  • role="contentinfo" on your footer - <footer> implies it
  • JavaScript onclick handlers for links - <a href> handles navigation, focus, keyboard, and context menus natively

Every ARIA role you add to a <div> is an admission that you used the wrong element. The browser already shipped <nav>, <main>, <header>, <footer>, <article>, <section>, <aside>, <details>, <summary>, <figure>, <figcaption>, <time>, <address>, and more. Each one carries implicit ARIA semantics. Each one is understood by every screen reader released in the last decade. Each one costs zero JavaScript.

The rule: if your UI only works because of CSS hacks and ARIA duct tape, your HTML structure is broken. Fix the structure first. The styles will follow.

2. Native form validation: kill 200 lines of JavaScript

Every frontend developer has written some version of this:

// The JavaScript you no longer need
function validateEmail(email) {
  const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return re.test(email);
}

function validateForm() {
  const email = document.getElementById('email').value;
  const password = document.getElementById('password').value;
  const errors = [];

  if (!email) errors.push('Email is required');
  else if (!validateEmail(email)) errors.push('Invalid email');

  if (!password) errors.push('Password is required');
  else if (password.length < 8) errors.push('Password must be at least 8 characters');

  if (errors.length) {
    errors.forEach(e => showError(e));
    return false;
  }
  return true;
}

Here is the HTML that replaces all of it:

<form>
  <label for="email">Email</label>
  <input
    type="email"
    id="email"
    name="email"
    required
    autocomplete="email"
  >

  <label for="password">Password</label>
  <input
    type="password"
    id="password"
    name="password"
    required
    minlength="8"
    autocomplete="current-password"
  >

  <button type="submit">Sign in</button>
</form>

What the browser gives you for free:

  • type="email": validates email format, triggers the @ key on mobile keyboards
  • required: blocks submission, shows a localized error message
  • minlength="8": blocks submission if the password is too short
  • autocomplete: lets the browser prefill saved credentials, no JavaScript
  • :invalid and :valid pseudo-classes: style validation states with zero JavaScript
input:invalid {
  border-color: var(--color-error);
}

input:valid {
  border-color: var(--color-success);
}

This is not "nice to have." This is 200 lines of JavaScript deleted and replaced by four HTML attributes. The validation is more robust than your hand-rolled regex (the browser's email validator handles internationalized domain names, quoted local parts, and edge cases you forgot). It works offline. It is accessible by default. It ships in every browser.

The only legitimate reason to write custom form validation in 2026 is when the constraint is genuinely custom - two fields must match, or a value depends on an API response. Even then, use setCustomValidity() on the native constraint validation API instead of building your own error system. Work with the platform. Don't pave over it.

3. The pattern attribute: regex validation without JavaScript

<!-- Username: 3-20 alphanumeric characters, must start with a letter -->
<input
  type="text"
  pattern="[A-Za-z][A-Za-z0-9]{2,19}"
  title="Username must be 3-20 characters, starting with a letter"
  required
>

<!-- Italian VAT number: 11 digits -->
<input
  type="text"
  pattern="[0-9]{11}"
  title="Enter an 11-digit VAT number"
  inputmode="numeric"
>

<!-- Hex color -->
<input
  type="text"
  pattern="#[0-9A-Fa-f]{6}"
  title="Enter a hex color like #FF6600"
>

The pattern attribute runs a regex against the input value on form submission. If it fails, the form does not submit and the title text appears as the error message. No JavaScript. No library. No custom validation function. Just a regex and a human-readable description.

This also reduces bad data at the backend. If the pattern blocks "not-a-color" before it reaches your server, you have fewer validation edge cases to handle, fewer error responses to render, and a smaller attack surface. The frontend validation is not a replacement for backend validation - it is a free pre-filter that catches honest mistakes before they cost a round-trip.

4. The download attribute: file downloads without a backend

<!-- A PDF that downloads instead of opening in the browser -->
<a href="/reports/q4-2026.pdf" download>
  Download Q4 Report (PDF)
</a>

<!-- Download with a custom filename -->
<a href="/generated/invoice-83721.pdf" download="Acme-Invoice-83721.pdf">
  Download Invoice
</a>

<!-- A CSV export generated client-side from a blob -->
<a id="export-link" download="data.csv">Export Data</a>
<script>
  const csv = 'Name,Email\nAlice,alice@example.com\n';
  const blob = new Blob([csv], { type: 'text/csv' });
  document.getElementById('export-link').href = URL.createObjectURL(blob);
</script>

Before the download attribute, triggering a file download required a Content-Disposition header on the server, or a server-side proxy, or a form POST with the right encoding. Now a single attribute on an <a> tag solves it. Combine it with URL.createObjectURL() for client-side generated files, and you have a fully client-side export system with zero backend involvement.

5. contenteditable: rich text editing without a library

<div contenteditable="true" role="textbox" aria-multiline="true">
  Start typing here. <strong>Bold</strong>, <em>italic</em>, whatever you want.
</div>

For internal tools - admin panels, CMS backends, dashboards - contenteditable replaces entire rich text libraries. The browser handles cursor position, text selection, undo/redo, spell check, and keyboard navigation. You get document.execCommand() for bold, italic, lists, links. Or better, use the modern Input Events API for structured editing.

// Listen for changes without polling
const editor = document.querySelector('[contenteditable]');
editor.addEventListener('input', (e) => {
  console.log('Content changed:', e.target.innerHTML);
});

Is this a replacement for a full collaborative editor like ProseMirror or Slate? No. Those libraries exist for a reason - real-time collaboration, structured document models, version history. But for an admin panel where someone needs to edit a product description, contenteditable with ten lines of JavaScript handles the entire use case. No 200 KB editor bundle. No dependency tree. No framework-specific wrapper.

6. autofocus: one attribute, one click deleted

<!-- The search field is ready before the user's finger touches the keyboard -->
<input type="search" name="q" autofocus placeholder="Search articles...">

<!-- Login form: focus on the email field -->
<input type="email" name="email" autofocus autocomplete="email">

Every login page, every search bar, every form where the first action is obvious - the user should not have to click the first field. autofocus places the cursor automatically. One attribute. Zero JavaScript. No document.getElementById('email').focus(). No useEffect(() => ref.current?.focus(), []). No timing bugs where the focus fires before the DOM is ready.

The browser handles edge cases: autofocus respects prefers-reduced-motion, it does not steal focus from a different tab, and it only fires once per page load. Your JavaScript implementation probably missed at least one of these.

7. The hidden attribute: state management without CSS

<!-- Hidden until JavaScript toggles it -->
<div id="error-message" hidden>
  Something went wrong. Please try again.
</div>

<script>
  // Show the error
  document.getElementById('error-message').hidden = false;

  // Hide it again
  document.getElementById('error-message').hidden = true;
</script>

Before hidden, toggling visibility required:

  • element.style.display = 'none' - overwrites any existing display value, breaks flex/grid items when restored
  • element.classList.toggle('hidden') - requires a CSS rule .hidden { display: none !important }, adds a specificity landmine
  • A framework-specific conditional render - {error && <ErrorMessage />}, which unmounts the element entirely and resets its state

The hidden attribute is none of these. It is a native HTML boolean attribute with a single, predictable behavior: the element is removed from the accessibility tree and hidden from view. You toggle element.hidden = true/false. No CSS conflict. No specificity war. No lost state. The platform does exactly what you mean.

/* Style hidden elements differently when they become visible */
#error-message:not([hidden]) {
  display: flex;
  gap: 0.5rem;
  padding: 1rem;
  background: var(--color-error-bg);
  border-radius: var(--radius-md);
}

8. Native progress bars: visual feedback without a library

<!-- Determinate: value is known -->
<label for="upload-progress">Uploading files...</label>
<progress id="upload-progress" value="65" max="100">65%</progress>

<!-- Indeterminate: value is unknown, bar animates automatically -->
<progress id="loading">Loading...</progress>

<script>
  const bar = document.getElementById('upload-progress');
  let percent = 0;
  const interval = setInterval(() => {
    percent += 5;
    bar.value = percent;
    if (percent >= 100) clearInterval(interval);
  }, 200);
</script>

The <progress> element renders a native progress bar. Determinate mode (value + max) shows a filled bar. Indeterminate mode (no value) shows an animated striped bar - the browser handles the animation, no CSS @keyframes required.

Use cases: file uploads, background job status, multi-step form progress, data sync indicators. Every one of these was previously implemented with a <div> and a width percentage in JavaScript, or worse, an entire progress bar library. The platform ships one. Use it.

9. <meter>: visual indicators that feel professional

<!-- Disk usage: 68% -->
<label for="disk-usage">Disk Usage</label>
<meter id="disk-usage" value="0.68" min="0" max="1">68%</meter>

<!-- Risk score with thresholds -->
<meter value="75" min="0" max="100" low="33" high="66" optimum="0">
  Risk: 75/100
</meter>

<!-- CPU usage: gauge-style -->
<label for="cpu">CPU</label>
<meter id="cpu" value="0.42" min="0" max="1" low="0.5" high="0.8" optimum="0.2">42%</meter>

<meter> is the semantic sibling of <progress>, designed for scalar measurements within a known range - disk usage, risk scores, quotas, ratings. It has built-in threshold styling: values below low get one color, between low and high another, above high a third. The browser handles the color transitions. You get a professional-grade gauge with one HTML element.

This is the kind of element developers rebuild in JavaScript because they do not know it exists. Stop rebuilding platform features. Learn them instead.

10. Email and URL inputs: stop writing sanitizers

<!-- Email: validates format, triggers email keyboard on mobile -->
<input type="email" name="email" autocomplete="email" required>

<!-- URL: validates protocol, triggers URL keyboard on mobile -->
<input type="url" name="website" autocomplete="url" placeholder="https://example.com">

<!-- Tel: triggers phone keypad, no format validation -->
<input type="tel" name="phone" autocomplete="tel" inputmode="tel">

<!-- Number: numeric keyboard, built-in min/max/step -->
<input type="number" name="quantity" min="1" max="99" step="1" value="1">

<!-- Date: native date picker, localized format -->
<input type="date" name="birthday" min="1920-01-01" max="2026-12-31">

<!-- Search: clear button, search keyboard on mobile -->
<input type="search" name="q" autocomplete="search" aria-label="Search articles">

Every one of these input types:

  • Validates input before the form submits (type="email" rejects "not-an-email")
  • Shows the correct mobile keyboard (type="number" shows numeric keypad, type="tel" shows phone keypad)
  • Provides a native UI (type="date" shows a date picker, type="search" shows a clear button)
  • Works offline (validation runs in the browser, no round-trip)
  • Is accessible by default (screen readers announce the input type)

If you are using <input type="text"> with a custom JavaScript validator for email, URL, number, date, or search, you are rewriting platform code. Stop it. The browser already ships the validator, the keyboard, the picker, and the accessibility tree. Your job is to use them, not to replace them.

11. <mark> and <time>: semantic power moves

<!-- Highlight search matches -->
<p>
  Results for "<mark>CSS Grid</mark>": found in 3 articles.
  The <mark>CSS Grid</mark> specification defines a two-dimensional layout system...
</p>

<!-- Machine-readable time with human-readable display -->
<p>
  Published on
  <time datetime="2026-08-09T10:00:00+02:00">August 9, 2026</time>
</p>

<!-- Duration -->
<p>
  Estimated reading time:
  <time datetime="PT7M">7 minutes</time>
</p>

<mark> semantically indicates highlighted text - typically for search results or passages of interest. Screen readers may announce it differently. Search engines can extract it as relevant context. It communicates intent in a way <span class="highlight"> does not.

<time> makes dates and durations machine-readable via the datetime attribute while displaying human-friendly text. Browsers can offer "Add to Calendar" for events. Search engines extract structured data without JSON-LD. Assistive technology can convert relative times ("3 days ago") to absolute dates.

These are not flashy features. They are quiet, structural improvements that compound across a project: better SEO, better accessibility, better interoperability. The kind of thing JavaScript cannot fix retroactively because by the time the script runs, the parser has already decided what the document means.

The meta-lesson: the platform is the framework

Sandhya closes with a line worth engraving: "HTML5 is automation disguised as markup." She is right, and the implication is bigger than she states.

Every native HTML feature you use is code you do not write, test, debug, bundle, update, document, or maintain. The browser vendor does all of that for you, for free, across every device their engine runs on. When you replace a native feature with a JavaScript implementation, you are not adding capability. You are moving code from the platform (where it is free, fast, and maintained by someone else) to your bundle (where it costs you everything).

The 11 features above are the tip of the iceberg. There are more: <datalist> for autocomplete, <details>/<summary> for collapsible sections, <dialog> for modals, <output> for calculation results, <template> for reusable fragments, <slot> for content projection, <picture> for responsive images, <map> for image maps, <abbr> for abbreviations, <dfn> for definitions, <kbd> for keyboard input, <samp> for sample output, <del> and <ins> for document changes.

Every one replaces JavaScript you were about to write.

The developers who ship fastest are not the ones who know the most frameworks. They are the ones who know what the platform already does, and only write code for what it does not.

Stop treating HTML as a wrapper. It is a runtime. Learn it like one.

References


This article was written with AI support and reviewed by the author.

Discussion

Comments live in GitHub Discussions

Each thread is keyed to the source markdown entry for this post.