HTMLGENERALDEVICE-SPECIFICSPEC-EVOLVING

Div Soup: How It Happens and What It Costs

Nobody sets out to write forty nested divs. They arrive one justified wrapper at a time, and the bill is paid in accessibility, style recalculation and JavaScript you had to write yourself.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Why does markup drift towards meaningless nesting, and what does each extra wrapper actually cost?

The user intent

A person wants to use the interface. Everything in this lesson is invisible to them right up until the keyboard does nothing, or a filter takes a second to apply.

The obvious build

A div is neutral and wrappers are free. A component library plus a utility-class stylesheet naturally produce a lot of them, and the rendered result looks exactly like the design, so the markup is fine.

Why it breaks

The accessibility tree becomes a long run of generic nodes. There is nothing to navigate by: no headings, no landmarks, no list counts, no roles — and no error anywhere to say so (Document Structure and Reading Order).

How it breaks in a real browser
  • The accessibility tree becomes a long run of generic nodes. There is nothing to navigate by: no headings, no landmarks, no list counts, no roles — and no error anywhere to say so (Document Structure and Reading Order).
  • Every element participates in selector matching, style computation, layout and the accessibility tree. Node count is a multiplier on four different passes, and it is one of the few page properties that only ever grows (Selector Matching Cost).
  • Behaviour that the platform implements has to be written in JavaScript instead, which means it is downloaded, parsed, compiled, executed and only then working — while a <button> works during parsing (What Native Elements Already Do).
  • CSS gets harder, not easier. With no element selectors to lean on, every rule is coupled to a class name, and deep nesting produces stacking contexts and overflow boundaries that were never intended (Positioning and Stacking Contexts).
  • Tests drift to querying by class or test id, because there is no role to query. The suite then passes while every control in the product is unreachable by keyboard (Component Testing).
  • Server-rendered markup carries every wrapper over the wire and reconstructs it in the DOM. Compression handles the repetition well; DOM construction and the hydration walk do not (Hydration).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Div soup is produced by composition, not by carelessness. A layout wrapper, a component-boundary wrapper, a context provider that renders a node, a styling wrapper, a conditional-rendering wrapper and a third-party widget's own root: six elements, each locally justified by a different person on a different day.
  • Nothing applies downward pressure. An extra div produces no warning, no error, no failing test and no visual difference, so the only force acting on node count is the one that increases it (Over-Componentization).
  • Depth compounds at composition points. If each component adds two wrappers and components nest five deep, the leaf sits ten levels below where it needs to be, and every ancestor is a potential stacking context or containing block.
  • Node count and depth drive different costs. Count drives style computation, memory and accessibility-tree size. Depth drives selector matching for descendant selectors, layout containment behaviour, and how far an invalidation propagates (Style Invalidation).
  • display: contents removes an element's boxes while keeping it in the DOM and the accessibility tree, which makes it the right tool for a purely structural wrapper — with the caveat that engines historically stripped semantics along with the box.

What this makes the browser do

And which of it is avoidable.

  • Style: one computed style per element, every time style is recalculated. A page with four times the necessary elements pays four times for every recalculation, not once at load (Style Calculation).
  • Layout: a box per element that generates one. Wrappers with no styles still generate boxes and still participate in flow.
  • Accessibility: a tree node per element, most of them generic, all of them maintained as the DOM changes.
  • Memory: element objects, style data, layout objects, and the retained JavaScript that references them (Detached Nodes and What Keeps Them Alive).
  • Server rendering and hydration: bytes on the wire per wrapper and a tree walk per wrapper on the client. Compression makes the bytes cheap and does nothing about the walk.
  • The avoidable portion is nearly all of it. A wrapper that carries no layout, no style and no semantics is pure cost with no corresponding benefit.

The same card, twice

This is not a contrived comparison — the left-hand version is roughly what a card looks like after a layout wrapper, a styling wrapper and a component boundary have each added one element, and after "make the whole card clickable" was implemented with a handler.

Count what the right-hand version supplies that the left one does not: a landmark-capable region, a heading that appears in the outline, a real link with modifier-click and copy-link behaviour, a time element machines can read, and roughly half the nodes. It also happens to be shorter.

A content card
Accumulated
<div class="card-wrap">
  <div class="card" onclick="go('/posts/12')">
    <div class="card-inner">
      <div class="card-media">
        <img src="/p12.jpg">
      </div>
      <div class="card-body">
        <div class="card-title">Shipping in Europe</div>
        <div class="card-meta">12 March 2026</div>
        <div class="card-text">How duties work…</div>
      </div>
    </div>
  </div>
</div>
Semantic
<article class="card">
  <img src="/p12.jpg" alt="" width="640" height="360">
  <h3><a href="/posts/12">Shipping in Europe</a></h3>
  <p><time datetime="2026-03-12">12 March 2026</time></p>
  <p>How duties work…</p>
</article>

The right-hand card is reachable by heading navigation, its link works with Cmd-click and "copy link address", its date is machine-readable, its image reserves space before it loads, and it is operable during parsing rather than after hydration. The left-hand card is a rectangle that responds to a mouse.

What the assistive technology hears

The most convincing evidence in any discussion about markup quality is the accessibility tree, because it strips the visual layer away entirely and shows what is left. Two components that are pixel-identical produce these two trees.

Notice that the problem in the first tree is not that it is *wrong*. Every node in it is an accurate description of a div. The problem is that it contains no information — nothing to navigate by, nothing to filter, nothing to announce as a state change.

  • A generic node is not neutral. It occupies a place in the reading order and offers nothing in return.
  • The click listener on the outer div does not appear in the tree at all — this is why "the whole card is clickable" is invisible to assistive technology.
  • The semantic tree is shorter *and* more informative, which is the shape of almost every trade in this lesson.
Accumulated card — accessibility tree

  generic
    generic  (has a click listener; not exposed as a control)
      generic
        generic
          image  "p12.jpg"          <- no alt, so the file name is read
        generic
          generic  "Shipping in Europe"
          generic  "12 March 2026"
          generic  "How duties work…"

  Navigable by: nothing. Not a heading, not a link, not a list item.
  Keyboard: no tab stop anywhere in the card.


Semantic card — accessibility tree

  article
    heading  level 3  "Shipping in Europe"
      link  "Shipping in Europe"  -> /posts/12
    paragraph  "12 March 2026"
    paragraph  "How duties work…"

  Navigable by: heading list, link list, article region.
  Keyboard: one tab stop, on the link, activated by Enter.
  Image: alt="" — correctly skipped as decorative.

When a div is exactly right

The failure mode of this lesson is over-correction: a codebase where every wrapper has become a section and every clickable region has a role. That is a worse outcome than div soup, because it makes confident false statements to users who cannot verify them.

A div is the correct element for a box that carries layout or style and no meaning. That is a large and legitimate category. The question is never "is a div allowed here" but "is there an element that already does what I am about to build".

What should this element be?

What is this element for — layout, meaning, or behaviour?

Keep the `div`

when It carries layout or style — a grid container, a flex row, a positioning context — and no meaning at all.

cost One node in every style, layout and accessibility pass. Real, small, and worth paying when the box is genuinely needed.

Delete it

when It carries no layout, no style and no meaning. Usually a leftover from a refactor or a component boundary that no longer exists.

cost None, beyond the risk that a stylesheet was quietly depending on it as a descendant selector anchor.

Use `display: contents`

when The wrapper must exist in the component model — a provider, a fragment with a key — but must not exist in the layout.

cost A rule the whole team has to know, and an engine-version check on whether semantics survive the box removal.

Promote it to a semantic element

when It already means something: a list, a heading, a region, a self-contained composition.

cost User-agent styles to reset, and the discipline to keep the element honest when the design changes underneath it.

Replace it with an interactive element

when It has a click handler. This is the case that always warrants a change (Semantics Are Behaviour).

cost Resetting the button or link appearance, and possibly restructuring so a genuinely interactive element is not wrapping block content.

Merge it with its child

when A semantic element is wrapped in a styling element, or two wrappers apply complementary styles.

cost Layout and semantics now travel together, which is one fewer node and one more coupling to unpick later.

How to build it

Most important first.

  • Choose the element by behaviour first — that is the whole of semantic-html. A div is the correct choice for a box and the wrong one for anything that is activated, navigated or announced (Semantics Are Behaviour).
  • Apply a single test to each wrapper: does this element carry layout, style or meaning? If the answer is none of the three, delete it.
  • Let one element do two jobs. A <ul> can be the grid container; a <nav> can be the flex row; an <article> can be the card. The habit of a semantic element wrapped in a styling element doubles the tree for no reason.
  • Prefer CSS that does not require wrappers: gap instead of margin-carrying spacers, grid-template-areas instead of nested row and column boxes, and subgrid where it is available.
  • Use fragments so components can return siblings, and display: contents for a wrapper that must exist in the component model but should not exist in the layout.
  • Put a floor under it in CI: an accessibility scan that fails on missing names and roles, a role-based query convention in component tests, and a DOM-size signal watched as a trend rather than a threshold (Accessibility Testing).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • This is the accessibility failure mode of the whole domain, and it fails silently. A tree of generic nodes offers no headings to jump between, no landmarks to skip to, no list counts, no roles to filter by and no states to announce.
  • Hand-rolled controls skip states first. aria-expanded on open, forgotten on close; aria-disabled on the styled state, forgotten on the logical one. Native elements cannot get out of sync because the state and the behaviour are the same object.
  • Depth is not neutral to assistive technology either: some screen readers announce group boundaries, so deeply nested generic containers produce a stream of "group, group, group" between the user and the content.
  • The cheapest possible check is to unplug the mouse. A component that cannot be reached, activated and exited with the keyboard is not finished, whatever the accessibility scan says (Keyboard Operability).
  • Recovery is incremental and pays immediately: replacing one div with a button, one wrapper with a ul, one styled span with an h2 improves navigation for real users on the day it ships.

What can go wrong

Failure modes
  • De-souping by find-and-replacing div with section and article. Unnamed sections stay generic and every article adds a mapped role, so the tree gets noisier and nothing gets more navigable.
  • Spraying role attributes onto wrappers. A wrong role is materially worse than no role: it makes a confident false statement to the one user who cannot check it against the screen.
  • display: contents on an element that carried a role, on an engine that removed the semantics with the box. The layout improved and the list stopped being a list.
  • Treating a DOM-node count as a target. Teams hit the number by deleting the semantic elements, because those are the ones a tool can prove are removable (Measure Before Optimising).
  • A large de-souping refactor landed in one change. The diff is unreviewable, the visual regressions are subtle, and the rollback takes the accessibility improvements with it (Visual Regression Testing).
  • Virtualising a list to reduce node count while leaving every row a div — a smaller DOM that is still unnavigable (List Virtualization).
Security
  • Div soup is not itself a vulnerability, but it correlates with the conditions for one. Hand-rolled controls implement disabled visually and not logically, so a control the UI presents as unavailable still fires its handler (Authorization-Aware UI).
  • A delegated click listener on a large generic container will happily activate markup that arrived from user content, because the delegation matched a class rather than an element with defined behaviour (Event Delegation).
  • Wrappers built by injecting HTML strings — the innerHTML and framework-specific dangerous-HTML escape hatches — are where markup assembly becomes a cross-site scripting sink (Cross-Site Scripting).
  • Deeply nested markup is a good hiding place. Content that is present but visually hidden is fully readable in the response and in devtools, whatever the layout suggests (Sanitization and Trusted HTML).
Misreads
  • "DOM size is the metric." Depth, churn and semantic density all matter more. A virtualised list has a small DOM and can still be completely unnavigable.
  • "Utility-class CSS causes div soup." Utility classes add attributes, not elements. Wrapper habits and component boundaries add elements; the stylesheet is a bystander.
  • "Semantic HTML is slower because of user-agent styles." It is fewer elements doing more work in C++ instead of more elements doing less work in JavaScript. The comparison is not close.
  • "We will add the ARIA later." ARIA added later describes a structure that was built without semantics, which is why retrofitted ARIA so often makes a confident wrong statement (Semantics Before ARIA).
  • "Our component library handles accessibility." It handles the accessibility of its own components. The wrappers you put around them, and the divs between them, are yours (Design Systems).

Measuring it, and what changes in the field

How you would see this
  • Node count and maximum depth, tracked as a trend across releases rather than compared against a fixed number. The direction is the signal.
  • The Performance panel's style-recalculation entries report how many elements were affected. Watching that number move when you toggle one class is the most direct evidence of what the tree costs (Debugging Rendering and Jank).
  • Count the non-generic nodes in the accessibility tree. Twelve meaningful nodes in a page of three thousand elements is the measurement that actually describes the problem.
  • Try to complete a task with the keyboard alone, then with a screen reader. Twenty minutes of this finds more than any scanner.
  • Component tests that query by role make semantics a build-breaking contract, which is the only mechanism that reliably stops the drift (Component Testing).
Slow device, slow network, large data, old tab
  • On a slow device the style and layout multiplier is felt directly: a filter that recalculates style across an oversized tree is a visible pause rather than a measurement (Interaction Responsiveness).
  • On a large list or a data grid, node count per row is multiplied by the row count, and the difference between four elements per row and twelve is the difference between usable and not (List Virtualization).
  • On a long-lived tab, retained detached subtrees accumulate, and a heavier tree per view makes each leak larger (Memory Leaks).
  • Before hydration, every hand-built control is inert while every native one works — the gap widens exactly when the device is slowest (Hydration).
What this costs
  • Semantic elements bring behaviour and appearance you must sometimes reset: ul has list styling, table has table layout, fieldset has a border and historically resisted being a flex container. Cheap to reset, unpleasant to discover in a design review.
  • One element doing two jobs couples layout to semantics. When the design changes from a grid to a carousel, the <ul> that was the grid container needs its layout untangled from its meaning.
  • De-souping an existing application is a large, risky diff for a benefit no stakeholder can see in a screenshot. Do it at component boundaries, one component at a time, with visual regression coverage.
  • display: contents solves the structural-wrapper problem and adds a rule most of the team will not know, which shows up as confusion when a wrapper mysteriously has no box.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThat node count multiplies style, layout, accessibility-tree and memory cost is true of every engine, because all of them compute per-element state. The magnitude differs; the direction does not.
  • DEVICE-SPECIFICOn a fast desktop an oversized tree is invisible; on a mid-range phone the same style recalculation is a felt pause. The cost is real everywhere and only perceptible on the devices that most of your users have.
  • SPEC-EVOLVINGdisplay: contents originally removed an element from the accessibility tree along with its box in several engines. That has largely been fixed, but the fix landed at different times per engine, so advice from a few years ago and advice from today genuinely disagree — verify against the accessibility tree in the browsers you support.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Concurrencyui-concurrency
Domains that do not exist yet
  • Software Design — div soup is the markup instance of a general problem: a structure with no forcing function degrades towards whatever each individual change finds locally convenient. The remedy is the same here as there, a rule applied at a boundary rather than judgement applied per commit.
  • Testing & Reliability Engineering — why a role-based query is a contract test for accessibility, and why a suite built on test ids can be entirely green over a product nobody can operate without a mouse.