Home / Articles

Who Should Own UI Behavior? Choosing Between HTML, CSS and JavaScript

A framework for deciding when native HTML and CSS features like dialog, popover, :has() and container queries should replace JavaScript, and when they should not.

Publisher: REACTAPP.TOP15 min read3416 words

This article is published in English.

Sources: Not Everything Needs JavaScript Anymore

Frontend developers reach for JavaScript by reflex. A menu needs to open, so you add state. A click needs handling, so you attach a listener. A component must adapt to its width, so you measure it; a tooltip must sit next to a button, so you read coordinates; a scroll effect is needed, so you listen to scroll and update styles. Modern frameworks make all of this easy: React has state and effects, Angular has signals, Vue has refs and reactivity, and Svelte makes updates remarkably terse. None of that is wrong, and JavaScript is a big part of why the browser became an application platform rather than a document viewer. Yet HTML and CSS keep gaining capabilities that overlap with this work. This article explains why that keeps happening, walks through the native primitives that now cover common interactions, and gives you a practical rule for deciding which layer should own a given piece of behavior.

Why the platform keeps absorbing application work

If JavaScript can already solve nearly every UI problem, why does the platform bother growing? Not because JavaScript failed. It is because the browser gradually learns to handle problems that every application used to solve on its own. Each time that happens, developers gain another option, and the more interesting question becomes which option carries the least long-term cost.

Every change competes for the next frame

It helps to recall what the browser does after something on the page changes. A simplified rendering pipeline looks like this:

JavaScript
    ↓
Style calculation
    ↓
Layout
    ↓
Paint
    ↓
Composite

Real engines are far more sophisticated. Not every update passes through every stage, some work runs off the main thread, compositing can happen separately, and certain animations avoid triggering layout or paint repeatedly. Still, one idea from this simplified model holds up: the browser has a limited time budget to produce each frame. Script execution, input handling, style calculation and rendering all have to share it. The more work you pile on, the longer the browser takes before it can respond. For a closer look at what each type of change costs, see our breakdown of reflow, repaint and composite.

This is easy to overlook on a development machine with a fast CPU, plenty of memory and a good connection. Run the same app on an older phone in battery-saver mode with several apps open, and small delays become visible: an animation drops a frame, scrolling feels sluggish, a button responds just late enough to feel broken.

Performance work is not only about making existing code faster. Often the best optimization is not creating work the browser never needed you to create.

This is about ownership, not HTML versus JavaScript

It would be easy to reduce the discussion to a slogan:

HTML + CSS = good
JavaScript   = bad

That slogan is nonsense. JavaScript solves problems HTML and CSS were never designed for. Collaborative editing depends on application logic, and live dashboards depend on keeping data in sync. Elaborate drag-and-drop, domain rules, interactive charts, state machines and multi-step workflows are not going away just because a new selector shipped.

The cases worth reconsidering are much smaller. Suppose you need to show and hide some extra information. A script can toggle it:

button.addEventListener('click', () => {
  panel.hidden = !panel.hidden;
});

That is perfectly valid code. HTML, however, can express a disclosure widget directly. Note that the stray h after the closing tag in the snippet is a typo and should be removed:

<details>
  <summary>Advanced settings</summary>
  <p>...</p>
</details>h

The real difference is not line count. It is who owns the behavior. With the script, the relationship is:

Application owns the behavior

With <details>, it becomes:

Browser owns the behavior

When the browser owns a behavior, it also owns the details you would otherwise have to remember: keyboard support, the expanded state exposed to assistive technology, and consistent behavior across pages. That distinction matters more often than it first appears.

Why HTML did not stop at paragraphs and forms

In principle, HTML could have stopped evolving long ago. Headings, paragraphs, forms, buttons, links, images and generic containers were enough to build anything, with everything more complex left to scripts.

Consider what that world looks like, though. Every application reinvents every disclosure, dialog, popover and form behavior, each slightly differently. Libraries appear to normalize those patterns, frameworks wrap the libraries, and every visitor downloads yet another implementation of something the browser could understand natively.

Once a pattern shows up everywhere, a built-in primitive starts to pay for itself. Seen this way, modern HTML is not turning into an application framework; it is gradually encoding the interactions that nearly every app keeps rebuilding.

Popovers without application state

Take a small settings panel. A component version usually starts with a state primitive, such as a signal in this example or its equivalent in another framework:

const open = signal(false);

Then the requirements accumulate:

button click
    ↓
change state
    ↓
show overlay
    ↓
handle outside click
    ↓
handle Escape
    ↓
manage stacking

Sometimes the product genuinely needs all of that. For simpler cases, HTML now offers the Popover API. A button points at an element with the popover attribute through popovertarget, and the browser handles toggling:

<button popovertarget="settings">
  Settings
</button>

<div id="settings" popover>
  ...
</div>

With the default popover (auto) behavior, the browser also takes care of light dismiss, closing the popover when the user clicks outside it or presses Escape, and renders it in the top layer, so you do not have to fight z-index stacking. Behavior that used to belong entirely to application code now has a native starting point.

That does not mean you should never build a custom dropdown. An autocomplete with remote search, virtualization, multi-select and custom keyboard handling is a different problem entirely. The point is only that a simple popover no longer has to begin its life as a piece of application state.

Dialogs that start from a real element

Modals are a good illustration because they seem trivial until you build one properly. The first version is a single flag:

open = true;

And then the real questions arrive:

  • What happens to the content behind the modal?
  • Where does focus go when it opens?
  • Can the keyboard close it?
  • What happens, and where does focus return, when it closes?
  • Does it render above everything else, in the top layer?
  • What should the backdrop look like and do?

For years, a lot of implementations began with a generic <div> and built all of this by hand. Today HTML provides a dedicated element:

<dialog>
  ...
</dialog>

One practical detail is worth knowing: the modal behavior comes from opening the element with showModal(). That call places the dialog in the top layer, makes the rest of the page inert, enables closing with Escape and exposes a ::backdrop pseudo-element for styling. Opening it with show() or the open attribute produces a non-modal dialog instead.

The native element does not solve every modal requirement, and real applications may still add logic around it. But the starting point is different: you are no longer inventing the concept of a dialog, because the browser already knows what one is. That captures a pattern across the modern platform: native primitives do not remove application logic; they reduce how much of it you must own.

CSS that reacts to DOM state

HTML is not the only layer taking on work. CSS can now express relationships that used to require scripts.

Consider a form field whose wrapper should be styled when its input is invalid. A script could inspect the input and keep a class on the parent in sync. Modern CSS can often state the relationship directly:

.field:has(input:invalid) {
  /* invalid state */
}

:has() changes what CSS can know about the document. It was long requested as the missing parent selector, but the architectural consequence is more important than the name: some state synchronization no longer has to pass through your JavaScript at all. The DOM already holds the information, and CSS can respond to it. There is no class to forget to update and no listener to clean up.

Components that respond to their own space

Responsive design has traditionally relied on media queries:

@media (max-width: 700px) {
  ...
}

That works well when the viewport is what matters. Components, however, often care about something else. Picture the same card rendered in two places:

Main content
┌───────────────────────────────┐
│            Card               │
└───────────────────────────────┘

Sidebar
┌─────────────┐
│    Card     │
└─────────────┘

The viewport is identical in both cases; the space available to the card is not. Reusable component systems historically solved this with measurement code: observe the element, read its width, add a class, and repeat whenever the layout changes.

Container queries let CSS answer the real question, namely how much room this component actually has. You mark an ancestor as a container and write rules against its size:

.card-container {
  container-type: inline-size;
}
@container (width < 400px) {
  .card {
    /* compact layout */
  }
}

This is more than new responsive syntax; it moves responsibility. The browser has already computed the layout, and CSS can use that result without application code measuring it a second time. Our comparison of container queries and media queries discusses when each belongs in a design system.

Positioning shows how the platform grows

Attaching a tooltip to a button looks like a trivial job until you actually try it. Where exactly is the button? How big is the tooltip? Is there room below, or should it flip above? What happens on scroll, on viewport resize, or when some other part of the layout shifts the button? A simple tooltip quickly turns into a geometry problem.

That is why dedicated floating-UI libraries exist, and they solve a genuinely hard problem well. CSS is now gaining Anchor Positioning, which lets an element be positioned relative to another element declaratively. Browser support was still uneven at the time of writing, so check current compatibility data and keep a fallback if you adopt it.

What matters most is not whether CSS replaces every positioning library overnight. It is that the platform recognized positioning as common enough to deserve lower-level primitives. That is the platform's usual growth cycle: problems start in applications, the patterns repeat, libraries emerge, and eventually the platform learns from them.

Animation and the value of declarative APIs

Animation is where the performance conversation gets nuanced. A popular shorthand says CSS animations run on the GPU while JavaScript animations run on the CPU. Reality is messier. CSS animations still cost work, JavaScript can drive very smooth animations, and CSS properties differ widely in cost. Animating dimensions can trigger layout, other properties require repaint, while transform and opacity are usually much friendlier to the compositor.

The useful distinction is therefore not CSS versus JavaScript. It is how much work each change causes and how much freedom you leave the browser to schedule that work.

Scroll-driven animations illustrate the shift. A scroll effect traditionally looked like this loop:

scroll event
    ↓
JavaScript calculation
    ↓
update styles
    ↓
repeat

Some of these relationships can now be declared to the rendering engine directly. Instead of repeatedly telling the browser to compute a value, apply it and do it again, you describe that an animation progresses along a scroll timeline. This is a different category of API altogether, one in which the browser owns more of the mechanism and can optimize it. As with anchor positioning, verify support in the browsers you target.

Removing JavaScript removes more than bytes

Reducing JavaScript is usually discussed in kilobytes, and that matters, because every script has to go through this pipeline before it does anything useful:

downloaded
    ↓
parsed
    ↓
compiled
    ↓
executed

But the lifecycle of application code continues long after it runs for the first time. Behavior implemented in JavaScript tends to bring along:

state
event listeners
synchronization
cleanup
framework lifecycle
hydration
tests

Now imagine a native primitive lets you delete this combination:

state
+
outside-click listener
+
Escape handler
+
DOM synchronization

The code removed might weigh only a few hundred bytes, yet the architectural change is real. Your application no longer maintains that behavior. There is one less state variable that can go stale, one less listener someone can forget to remove, one less DOM relationship that must stay in sync with state, and one less interaction to rebuild from scratch, often with worse accessibility than the native version. Frequently the biggest benefit of a platform feature is not speed but the responsibility you hand back to the browser.

The platform has no final version

A persistent misconception helps explain why developers miss these changes: talking about "HTML5" as though HTML reached version five and then mostly stopped. It did not. There is no HTML6 in development and no HTML7 to wait for. HTML is maintained as a Living Standard, and the specification evolves continuously.

CSS follows a related model. Many developers learned "CSS3", but CSS no longer ships as one large versioned bundle. Separate modules progress at their own pace, which is why you encounter names like these without any overarching "CSS6" existing:

Selectors Level 4
CSS Color Level 5
CSS Cascade Level

The last entry is truncated in the snippet; the Cascading and Inheritance module also carries its own level number. Because modules are independent, grid can evolve without waiting for selectors, color work proceeds separately from fonts, and container queries shipped without any imagined CSS4 milestone. At the scale of the web, incremental evolution is the only sensible approach.

JavaScript keeps moving too

The same holds for the language. The standardized specification is ECMAScript; JavaScript is the name developers use for the language that engines implement according to that specification. That is why progress is described in ECMAScript editions rather than numbered "JavaScript" releases, and new capabilities keep arriving.

So the platform is not frozen as this combination:

HTML5
+
CSS3
+
JavaScript

All three layers continue to evolve. ECMAScript gains better primitives, CSS takes on more layout, styling and interaction relationships, and HTML gains semantics and behaviors that applications once implemented themselves. The boundary between them shifts over time, which is why what you learned about the platform years ago needs regular refreshing.

Platform knowledge outlives framework choices

These features sit underneath any framework. A container query works the same whether the component was built with Angular, React, Vue, Svelte or plain DOM code. :has() does not care how the DOM was produced. A native <dialog> is still a dialog inside a framework, and a popover is a browser feature regardless of what rendered it.

That makes platform knowledge unusually durable. Framework expertise transfers to projects that use that framework; platform expertise transfers to nearly every web project. This does not diminish frameworks, which solve large architectural and developer-experience problems. Understanding the layer beneath them gives you an extra option when an abstraction is unnecessary and, just as importantly, shows you what the abstraction is doing for you. If you want a catalog of specific replacements, our overview of native HTML features that replace common UI libraries goes feature by feature.

When native is the wrong choice

Before getting carried away, a caution: being able to build something in HTML or CSS does not mean you should. Consider a dropdown that needs:

  • asynchronous search;
  • hundreds of virtualized options;
  • multi-select;
  • sophisticated keyboard navigation;
  • bespoke filtering rules;
  • options loaded from the server;
  • selections kept in sync with app state.

The existence of a simple popover does nothing to solve that problem, and contorting the design to avoid JavaScript would make the architecture worse, not better.

CSS deserves the same realism. It performs real work: layout and paint are not free, a huge DOM stays expensive, and badly chosen animations can still perform poorly.

So the goal is not to use HTML and CSS whenever it is technically possible. A far more useful rule is this:

Use the lowest layer that cleanly solves the actual requirement.

Sometimes that is HTML, sometimes CSS, sometimes JavaScript, and most good applications combine all three. A quick way to apply the rule is to ask, in order: does a semantic element or attribute express this behavior? If not, can CSS derive it from state the DOM already holds? Only when both answers are no, or when the requirement exceeds what the native version supports, should the behavior move into application code.

The simplest cases deserve the simplest solutions

Complex features will keep requiring JavaScript, and that is fine. But around those features, most applications contain hundreds of minor interactions: expandable sections, lightweight overlays, cards that adapt to their slot, invalid-field styling, simple transitions, one element tracking another. Whenever a native element, an attribute or a short CSS rule covers one of them, your application may not need to own it at all.

That JavaScript can solve the problem is not remarkable; it can solve almost anything. What is valuable is having another option.

Updating the mental model

Many developers learned the web through a tidy division of labor:

HTML → structure
CSS → presentation
JavaScript → behavior

It is a good model for learning, but today's browser is more capable than it suggests. HTML now carries behavior, CSS responds to complex relationships in the DOM and layout, and the language itself keeps gaining features. The platform kept changing even when habits stayed put.

Many of those habits were learned as facts: overlays need application state, positioning needs scripts, components can only respond to viewport breakpoints, scroll animations need listeners. They may all have been true once. Some are no longer always true.

That is why keeping up with HTML and CSS is not about collecting clever syntax. A new browser capability can eliminate a dependency, an event listener, a piece of state, a ResizeObserver, or any code whose only job was to reproduce something the browser now understands natively.

Key takeaways

  • The browser keeps adopting behaviors that applications repeatedly reimplemented; each adoption gives you a lower-cost option.
  • Frame time is a shared budget, so avoiding unnecessary work is often the most effective optimization.
  • Think in terms of ownership: <details>, the Popover API and <dialog> with showModal() let the browser own behavior, including accessibility details you would otherwise maintain.
  • :has() and container queries let CSS react to DOM state and component size without synchronization or measurement code.
  • Anchor positioning and scroll-driven animations point in the same direction, but check current browser support before relying on them.
  • Native is not automatically better: complex widgets still belong in JavaScript, and CSS work has real costs too.
  • Before adding another state variable, hook, signal, directive, listener or dependency, ask whether the platform can already do it, and use the lowest layer that meets the requirement cleanly.