Fighting inter-component HTML bloat

The separation of concerns we aim for in design systems has an unwanted byproduct: bloated HTML in the space between components. What can we do as component authors to encourage good markup hygiene at the inter-component level?

When we decide what qualifies as a component in a component library or design system, we aim for a separation of concerns: we decouple something that creates whitespace from something that creates frames from something that styles type so that each can be reused in different contexts. But when each UI characteristic is represented by its own DOM tree, this decoupling can — by design — result in HTML bloat.

Let's look at where this comes from, why redundant HTML should be avoided in the first place, and how to do so without sacrificing the neat separation of concerns that helps to create the shared language between design and development.

Note: The examples in this post will be React-specific, but the core idea extends to most JS-based component libraries.1

Consider a Card component with a title and optional description:

<Card>
  <Stack spacing="medium">
    <Heading>Card title</Heading>

    {description &&
      <Text>{description}</Text>}
  </Stack>
</Card>

From the component library design point of view, this makes sense:

  • Card is responsible for visually framing the component.
  • Stack is responsible for the spacing between the heading and description.

Let's look at the HTML this produces. A simplified version might be something like this:

<style type="text/css">
  .card {
    border: 1px solid silver;
  }

  .stack {
    display: flex;
    flex-direction: column;
    gap: 1rem;
  }
</style>

<div class="card">
  <div class="stack">
    <h2>Card title</h2>
    <p>Description</p>
  </div>
</div>

Strictly speaking, you don't need two wrapper divs to achieve the intended look. If you'd never been “tainted” with the design system mindset and instead built this bit of UI using plain HTML and CSS, I'd be willing to bet you'd instinctively combine the styles for the card frame and spacing between children into a single class2. The card description could be included or omitted without any impact on the markup:

<style type="text/css">
  .card {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    border: 1px solid silver;
  }
</style>

<div class="card">
  <h2>Card title</h2>
  <p>Description</p>
</div>

<div class="card">
  <h1>Card title</h1>
</div>

We have lost the redundant wrapper. But we have also lost the neatly separated areas of responsibility for the card frame and element spacing. The importance of neither should be overlooked, but satisfying both requires us to be deliberate in how we design our component APIs.

When you're the one authoring a component, you have a level of control over the markup and styling that lets you keep bloat to a minimum. The onus cannot be on the consumer of your library to make these same optimisations at the inter-component level, because they probably won't. Why not?

  • Semantics and accessibility are usually handled for you in design system components, so it can be easy to forget that this doesn't extend to page-level (inter-component) code.
  • While minimising markup bloat isn't particularly tricky, it can result in a fair amount of boilerplate.

Creating context-aware components in a way that consumers can ignore inter-component markup altogether is unrealistic. For now, we cannot bundle good page-level markup hygiene free into our design systems, but we should at the least make working towards it convenient.

I'd like to cover a few ways that component APIs can be extended to make the underlying markup more flexible. But before that, a sidebar on why bloated HTML should sound alarm bells in the first place. Should we be worried about div soup at all in 2023?

Why avoid extra wrapper elements?

1. Bloated HTML hurts performance

Redundant wrapper elements are a form of bloated HTML, and bloated HTML can hurt performance, both during page load (resource size) and runtime (costly layout reflows and element querying).

We obsessively optimise when it comes to scripts, styles, and other resources; interestingly, that doesn't always apply to HTML. In the case of single-page apps, it may feel like you stand to gain more in performance if you focus on JavaScript. In the case of server-rendered, non-interactive sites, some people doubt that the performance impact of a large DOM is noticeable.

We also lack the required tooling: according to Jens Meiert, not enough HTML minifiers prune optional HTML tags and default attributes3. When it comes specifically to unneeded wrapper elements, tooling wouldn't help us anyway (you would need a pretty sophisticated analysis of the associated styling to determine if an element can really be omitted).

2. Redundant elements can create problems with accessibility

Our markup needs to be semantic; an extra div here and there doesn't take anything away from that. But a misplaced extra element can make your carefully selected semantic tags useless.

This happens mainly with elements that have strict rules about containment or placement. For example:

  • A legend must appear as the first child of fieldset.
  • A figcaption must appear as the first or last child of figure
  • A li must appear as a direct child of either ul, ol or menu.
  • dt and dd must both appear as direct children of dl4

Design system components with a strict separation of concerns can easily break this5:

<ul>
  <Columns>
    <Column>
      <li/>
    </Column>
    <Column>
      <li/>
    </Column>
    <Column>
      <li/>
    </Column>
  </Columns>
</ul>

Out of the top 100 most visited websites, not a single one used valid HTML in 2022. Misplaced wrappers may play only a small role in this, but if we can author design system components that make it easier to avoid, we should.

3. Redundant elements can break styling

Just as the extra element is sometimes legitimately needed for specific styling needs, in other situations it can break your layout. When using CSS flexbox and grid, each element in the flex or grid layout must be a direct child of the parent with display: grid or display: flex.

4. Deeply nested DOM trees are annoying to work with

Finally, working with deeply nested DOM trees just slows you down.

When making sense of a bit of HTML, I scan it for landmarks and other elements that hint at the structure of the content. This becomes harder to do if the number of meaningful elements is eclipsed by those that are meaningless. Debugging becomes harder, tracking down nodes in the inspector becomes more tedious, and the joy of the craft is diminished.


Making component markup flexible

Here's a non-complete list of techniques I use to make the underlying markup in my components more flexible. Many of these primarily serve some other need but come with the bonus of reducing redundant wrappers.

1. Always accept inline styles and classes

Component libraries often come with Box and other generic container components. These primitives are encouraged, among other use cases, when you need custom styling.

Why not just use a div? In some design systems, this comes down to the use of CSS-in-JS and/or theming libraries: going via Box or an equivalent primitive usually comes with direct access to the design system's tokens. If the source of truth for your tokens is in JavaScript, a div styled with plain old CSS just won't cut it. Neither will tacking a class or inline styles onto an existing component6.

Box is a great primitive to expose, but should not be seen as the sole tool for style overrides. When a component disallows style overrides or limits them to a subset of CSS properties, you end up with a tree of boxes when your styling adjustments could easily have been made on the nodes already present in the DOM.

{/* Rigid style overrides */}
<Box
  borderRadius="borderRadius20"
  borderStyle="solid"
  borderWidth="borderWidth10"
  borderColor="colorBorderPrimaryWeak"
>
  <FancyComponentWithoutABorder>
    <Heading>
      <Box fontWeight="bold">Heading</Box>
    </Heading>
  </FancyComponentWithoutABorder>
</Box>

{/* Flexible style overrides */}
<FancyComponentWithoutABorder
  borderRadius="borderRadius20"
  borderStyle="solid"
  borderWidth="borderWidth10"
  borderColor="colorBorderPrimaryWeak">
  <Heading fontWeight="bold">Heading</Heading>
</FancyComponentWithoutABorder>

So — whether you're limited in the use of vanilla CSS because of design tokens or you just want to formalise how styles are overridden, make sure you expose a way of doing so on every component. If people need to adjust component styling, they will. You may as well make targeting the right node as easy as possible.

2. Make the root node tag customisable

Overriding the root node tag of a component is usually exposed via an as prop like so: <Box as="header">.

It's uncommon for the as prop or equivalent not to be provided these days. Just to explain its importance, here's one way you end up with extra DOM nodes when you're not allowed to modify the rendered HTML element. Say you want to render a list of cards:

<Stack spacing="small">
  <ul>
    <li>
      <Card></Card>
    </li>
    <li>
      <Card></Card>
    </li>
    <li>
      <Card></Card>
    </li>
  </ul>
</Stack>

Using as, this could be rewritten as follows:

<Stack as="ul" spacing="small">
  <Card as="li" />
  <Card as="li" />
  <Card as="li" />
</Stack>

Even worse than a component that doesn't provide an as prop is one that doesn't provide an as prop and renders something other than a generic div7. A good example is something like Heading. It may be tempting to enforce one of h1h6 elements under the hood. But (as much as you discourage it in your documentation) that would be making the assumption that your Heading component will never be used to show arbitrary text that just needs to look prominent.

3. Consider if your component needs to output its own root node

Similarly to as, I would like to see much more of asChild or an equivalent in the wild.

Popularised by Radix UI, asChild is a way to merge a single-node component's props onto its own single child node. The node itself will be omitted, but its props and functionality will be copied into its child. This works great in highly composable components, or when one component provides behaviour while the other provides styling, as illustrated by Radix UI's own examples:

import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { MyButton } from "./my-button";

export default () => {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <MyButton>Open dialog</MyButton>
      </Dialog.Trigger>
      <Dialog.Content>...</Dialog.Content>
    </Dialog.Root>
  );
};

DialogTrigger might by default return a <button>, but we want to use our own MyButton as the trigger instead. Without asChild, we may end up with <button><button></button></button>, where the extra element is both invalid and unnecessary. With asChild, the output is a single <button/> with styling from <MyButton> and dialog trigger semantics from <Dialog.Trigger>.

Support for asChild is especially critical in libraries that provide highly composable components (of which Radix UI is a great example). Compound components come with a range of benefits, but they are also more prone to outputting bloated HTML unless you specifically build in safeguards to avoid it.

If you want to build in asChild support into your own components, Radix UI provides the nifty Slot utility to handle merging of event handlers and other props for you.

In more specialised components, it may not always be appropriate to support a full merge of props à la asChild. But the basic idea still stands: just because we use a HTML-like entity as the vessel for a component doesn't mean it needs to output an actual HTML node. Some examples:

  • passHref in Link from Next.js: <Link href="/foo" passHref><Text>Link</Text></Link> outputs a single <a> tag.
  • Enhancing the behaviour of child nodes: If a component is responsible purely for attaching attributes to its child node, why introduce a wrapper? A good example is the VisuallyHidden component, which usually just adds a set of inline styles to its content. If its content consists of multiple sibling nodes, the wrapper is justified. But if it wraps a single element, the set of styles can just as well be attached to that one element, and the wrapper omitted.
  • Wrappers that operate on multiple children: If a wrapper node only has an effect when it has more than one child, why not double-check the number of children before applying that effect? Standard components such as Stack or Inline are great examples of this, because they're responsible for laying out multiple children along an axis. Single child → no gaps or axes to work with → why the wrapper? 8

4. Make your stacks smarter

This one is a bit special, as it concerns a specific component: the ubiquitous Stack (sometimes differentiated as Stack and Inline or Flex, depending on the implementation).

Let's go back to the Card example from the start of this post and extend it to support a subtitle:

<Card>
  <Stack spacing="medium">
    <Heading level={2}>Card title</Heading>
    {subtitle &&
        <Heading level={3}>{subtitle}</Heading>}

    {description &&
      <Text>{description}</Text>}
  </Stack>
</Card>

The optional subtitle happens to sit closer to the title than it does to the description, which makes the wrapper Stack with medium spacing insufficient. Along comes a second Stack, this time with small spacing:

<Card>
  <Stack spacing="medium">
    <Stack spacing="small">
      <Heading level={2}>Card title</Heading>
      {subtitle &&
          <Heading level={3}>{subtitle}</Heading>}
    </Stack>

    {description &&
      <Text>{description}</Text>}
  </Stack>
</Card>

We are well on our way into div soup territory.

Why can't stack-like components support variable spacing between children? Neither flex nor grid layouts currently support variable gaps, so at the moment the implementation for something like this would necessarily rely on margin on each child rather than gap on the wrapper.

This would violate a core tenet of design systems — that elements should never be responsible for layout outside of their own bounds.

I think this violation is justified. Unless you're working with straight-up lists of repeated elements, it's uncommon for a well-designed UI to feature more than 2–3 consequtive elements with equal spacing anyway. The result is deeply nested stacks upon stacks upon stacks when really we should be dealing with a flat list of siblings — unnecessary wrappers at their most obvious.

I encourage supporting something like <Stack spacing={["small", "small", "medium"]}> alongside the standard <Stack spacing="medium">. Because stack-like components are so ubiquitous, the impact on redundant HTML will be noticeable.

5. Raise awareness in documentation

Markup manipulation should be normalised in your documentation.

As mentioned, the as prop is quite a common feature in component libraries, but it's fair to assume it's not used as often as it could be9. Could insufficient documentation and awareness be one of the causes?

I'd argue that it's rare to achieve great markup hygiene at the page level without frequent use of one of the described techniques (style overrides and the as prop being the most critical). As such, their use should be illustrated in all documented code examples to make them more realistic.

Instead of illustrating the use of the Columns component with a bare-bones example like this:

<Columns>
  <Column>A column</Column>
  <Column>A column</Column>
</Columns>

…consider adding (at least somewhat) realistic semantics into your code snippets:

<Columns style={ { height: "100vh" } }>
  <Column as="nav">Navigation</Column>
  <Column as="main">Main content</Column>
</Columns>

It also helps to mention the default root HTML element that a component outputs in its description (someone consuming your docs will be forced to take the extra second to consider whether that element needs overriding).

Finally, it goes without saying that it helps to frequently look at and analyse the HTML your components produce. Storybook addons like HTML preview and Accessibility are as much (or more) about raising awareness as they are about functionality.


When viewed in the context of a single component, redundant HTML can seem inconsequential. And given the current zeitgeist around AI, obsessing about an extra div may feel a bit 🤡

But when viewed collectively and across products, the importance of lean markup is compounded. In her article, Building conscious design systems, Amy Hupe wrote:

If we can use our design systems to speed up meaningful work, standardise things to a high quality, and scale the things we actually want to reproduce — then the reverse is also true. It means that we can also use our design systems to speed up problematic work, standardise things to a poor quality, and scale things we don't want to reproduce.

Bloat scales. Let's make it easier for component consumers to avoid it.

  1. In JavaScript-based component libraries, the “vessel” for a single component is the HTML node: <MyComponent> in libraries like React or Vue, <my-component> in native web components. Contrast this to CSS-based libraries such as Salesforce's Lightning where the consumer is responsible for attaching predefined classes to their own HTML structure. HTML bloat as a biproduct occurs mostly in the former. 

  2. How few HTML elements can you get away with while keeping the intended styling? If you're into code golfing, you could look at the number of characters of readable text to characters of code as a success metric. For inspiration, Tim Berners-Lee's World Wide Web Summary from 1991 boasts a ratio of around 0.9 (less than 4200 characters of readable text for less than 4600 characters of code). 

  3. Note that “optional” here refers not to extra wrappers, but to tags that are optional syntactically

  4. Thank you @sebdedeyne for pointing out that you're actually allowed to wrap <dt></dt><dd></dd> inside a <div>

  5. Speaking specifically of React, sometimes these situations arise because a component rendering multiple children must wrap them in a single parent node; fragments were added precisely to keep HTML valid when this happens. 

  6. See, for example, the css prop in Stitches, the sx prop in Theme UI, a more constrained style prop in Nulogy. Other design systems expose an individual prop per styling property, for example, Paste or Braid

  7. Of course, highly specific exceptions apply, such as a Link having to render <a> in all instances. 

  8. When you allow any component to receive inline styles or classes, having that component's root node be omitted when it has only a single child may come as a surprise to people who expected to see other, arbitrary attributes attached to the wrapper. For this reason, I sometimes use a boolean flag such as omitForSingleChild so that consumers can explicitly omit the wrapper node depending on context (avoiding the boilerplate involved with doing the conditional rendering inline). 

  9. Based on the analysis of 7.5 million webpages in 2020, element diversity was poor: out of the 112 available elements in HTML, the 90th percentile website only used 42. The most popular HTML element in 2020 continued to be div

About Elise Hein

I’m a design system engineer at Griffin. Previously, I helped build products for healthcare research at Ctrl Group, worked on personal digital branding at MOO, and researched development practices at UCL for my master’s degree in HCI.

I live in Tallinn, Estonia, where I’m raising a preschooler and a toddler with @karlsutt.
Find me on GitHub and LinkedIn. Follow me on Twitter (but know that I’m still building up to my first Tweet). Email me at elise@elisehe.in.