"No JavaScript" makes a good headline and a bad engineering target. A checkout still needs application logic. A live search still needs data. A drag-and-drop editor will not become simpler because someone recreated it with hidden checkboxes.
The useful question is narrower: which parts of the interface are reimplementing native browser behavior? Those parts are often smaller, easier to test, and more accessible when HTML or CSS owns the basic contract. JavaScript can stay for the parts that are actually application logic.
Use the least powerful language that fits
The W3C's Rule of Least Power says to choose the least powerful language suitable for a purpose. HTML describes meaning and built-in behavior. CSS describes presentation and visual state. JavaScript can do almost anything, which is exactly why code written in JavaScript requires more care.
When a browser owns a behavior, it can connect that behavior to the keyboard, focus model, accessibility tree,
and platform conventions. Native does not make an interface automatically accessible. Labels, focus order, and
good interaction design still matter. It does give you a stronger starting point than a generic <div>.
Replace a scripted disclosure with <details>
FAQs and simple disclosures rarely need a custom open-state class, click listener, keyboard handler, and ARIA
updates. <details> and <summary> already expose a control that opens and closes content.
Custom state
<button aria-expanded="false">
Browser support
</button>
<div hidden>...</div>
button.addEventListener("click", () => {
const open = button.ariaExpanded === "true";
button.ariaExpanded = String(!open);
panel.hidden = open;
});
Native disclosure
<details>
<summary>Browser support</summary>
<p>Check your audience before shipping.</p>
</details>
The element is
widely available.
It also supports a toggle event if analytics needs to observe the state. Keep a custom accordion when
the interaction contract is genuinely different, but do not assume a bespoke animation is enough reason.
Replace a custom modal shell with <dialog>
A proper modal needs more than position: fixed. It needs a label, initial focus, a reliable close
control, Escape behavior, focus containment, and a way to make the rest of the document inactive. Custom modal
code often gets one or two of those details wrong.
<button id="open-settings">Open settings</button>
<dialog id="settings" aria-labelledby="settings-title">
<h2 id="settings-title">Settings</h2>
<form method="dialog">
<button>Close</button>
</form>
</dialog>
<script>
const dialog = document.querySelector("#settings");
document.querySelector("#open-settings")
.addEventListener("click", () => dialog.showModal());
</script>
This example still has one click listener. That is fine. The browser now owns the modal state and focus model, while the script only requests the state change. MDN notes that native dialog behavior includes top-layer placement, Escape dismissal for modal dialogs, and inertness outside the modal. You still need to choose sensible focus placement and include a visible close control.
Use the Popover API for non-modal floating content
Dropdown panels, teaching tips, and simple tooltips often carry their own open-state class, outside-click listener, Escape listener, and z-index ladder. The Popover API puts the floating element in the top layer and can handle light dismissal without JavaScript.
<button popovertarget="account-actions">
Account
</button>
<div id="account-actions" popover>
<a href="/profile">Profile</a>
<button type="button">Sign out</button>
</div>
An automatic popover closes when the user presses Escape or clicks outside it. It does not provide menu semantics or arrow-key behavior by itself. Choose elements that match the content and add richer keyboard behavior only when the component actually needs it. The popover attribute is Baseline 2024, while newer additions to the API may have different support.
Use inert for a temporarily disabled region
Some interfaces loop through every link, button, and input in a hidden or disabled panel, save each
tabindex, then restore the values later. The inert attribute makes an entire subtree
non-interactive and removes it from sequential focus navigation.
<section id="checkout" inert>
<h2>Checkout</h2>
<button>Pay now</button>
</section>
<script>
checkout.inert = !cartIsReady;
</script>
The state may still come from JavaScript. What disappears is the fragile bookkeeping for every descendant.
Do not use inert to hide important content without explaining why it is unavailable.
Let CSS read visual state directly
If a script only copies child state onto a parent class, :has() may make the copy unnecessary. Form
validation, selected filters, and component variants are common examples.
Mirrored class
input.addEventListener("change", () => {
filters.classList.toggle(
"has-selection",
Boolean(filters.querySelector(":checked"))
);
});
Direct state
.filters:has(input:checked) .clear-button {
display: inline-flex;
}
CSS can also own smooth scrolling, scroll snap positions, entry transitions, counters, and reduced-motion fallbacks. Keep the boundary clear: CSS should react to presentational state. It should not become a hidden business-logic engine.
Keep JavaScript where it is the clearest tool
Do not replace JavaScript when the script is doing work HTML and CSS cannot explain well:
- Fetching, validating, or transforming application data
- Coordinating state across unrelated parts of an interface
- Implementing domain rules, permissions, or transactions
- Providing keyboard interactions for a complex widget that has no matching native control
- Measuring layout only when no declarative layout feature can express the result
A smaller script is often the right result. Replacing a modal framework with
<dialog> may leave one event listener. That is still a meaningful reduction.
A safe way to remove custom code
- Write down the current behavior, including keyboard input, focus, dismissal, animation, and analytics.
- Capture the HTML, CSS, and JavaScript responsible for that behavior.
- Build the smallest native alternative beside the current component.
- Check support against your browser policy and choose a fallback if the feature does not fail safely.
- Run interaction and accessibility tests before deleting the old implementation.
This evidence-first approach matters more than the number of lines removed. It prevents "modernization" from quietly changing behavior that users depend on.