Mayank RSS

Some use cases for display: contents

display: contents is a strange fella. Even though it was introduced more than 5 years ago, the way it has been supported across browsers is, frankly, pathetic.

We have reached a point where we cannot trust that display: contents will ever be safe to use on semantically important elements because it can completely destroy semantics and keyboard navigation. This makes certain patterns, which would otherwise have been solved by display: contents (like inline <button>s!), remain impossible to achieve to this day.

I’ve tried to reframe the problem surface that display: contents can tackle. Rather than something you would use for “preserving semantics”, it’s something you should put on a generic wrapper. As long as the user won’t miss the HTML element not being there, display: contents is fair game.

In other words, don’t use display: contents on any interactive elements, or really anything semantically important.

Pseudo sub-grid?

You might be tempted to use display: contents to avoid having to flatten your HTML. This can be a valid use if none of the child elements are semantically important (so lists are a no-go).

Code snippet
.parent {
  display: grid;

  > .child {
    display: contents;

    > * {
      /* can participate in parent grid! */
      grid-column: 1 / -1;
    }
  }
}

However, since sub-grid is now shipping in all browsers, this hack is no longer necessary. We can do it the “right” way!

Code snippet
.parent {
  display: grid;

  > .child {
    display: grid;
    grid-template-columns: subgrid;

    > * {
      /* still participates in parent grid! */
      grid-column: 1 / -1;
    }
  }
}

Forms

If you build progressively enhanced interfaces, you may end up with lots of forms that wrap a single button and no other form fields.

Code snippet
<form method="POST">
  <button>Do something</button>
</form>

This form is semantically insignificant as far as the user is concerned, and it also has little reason to participate in layout. Slap a display: contents on it 👍.

Many ways this could be done, ranging from a utility class="contents" to this clever selector (nested for clarity):

Code snippet
form {
  &:has(> button) {
    &:not(:has(> :nth-child(2 of :not([hidden], input[type="hidden"])))) {
      display: contents;
    }
  }
}

That selector will match any form that has a button and no other non-hidden elements (because sometimes you may have a hidden input for sending an extra value in the form). But like… probably don’t use it in production?

I would make a component with a simple scoped selector. For example, in Astro:

Code snippet
---
const { method = 'POST', action, ...props } = Astro.props;
---

<form method={method} action={action}>
  <button {...props}><slot /></button>
</form>

<style>
  form {
    display: contents;
  }
</style>

And then the usage becomes straightforward:

Code snippet
---
const { request } = Astro;
if (request.method === 'POST') {
  const { action } = Object.fromEntries(await request.formData());

  if (action === 'like') {
    // …
  } else if (action === 'save') {
    // …
  }
}
---

<FormButton name='action' value="like">💜</FormButton>
<FormButton name='action' value="save">📑</FormButton>

Custom elements

Unlike framework components, the web component model is HTML-first, so there needs to be a custom element present in the DOM. This is not automatically a bad thing, but it makes the layout more complicated than it needs to be, especially because custom elements default to display: inline.

A lot of my custom elements tend to be wrappers that are behavior-only and/or semantically insignificant. I like to remove them from layout using display: contents. I showed off this snippet in my <tool-tip> wrapper:

Code snippet
tool-tip {
  display: contents;
}

Astro’s islands all render a custom element to the DOM that also uses display: contents to keep it from breaking layout:

Code snippet
<style>
  astro-island {
    display: contents;
  }
</style>
<astro-island>
  <!-- content of the island -->
</astro-island>

The benefit of this technique can become more evident when nesting multiple levels of these custom elements.

Any “pointless” wrapper div

This is sort of the opposite of the last one. Sometimes it is useful to create an extra <div>, even when not defining a custom element. Some piece of JavaScript might need it for some reason, or just for convenience.

Many frameworks will freak out if you try to render something directly into <body>, because browser extensions and third-party scripts might inject unexpected foreign elements there. SvelteKit’s default template renders all of the Svelte content inside a <div> that already has display: contents set on it.

Code snippet
<body>
  <div style="display: contents">%sveltekit.body%</div>
</body>

In my post about portals, I brought up the idea of using a specific <div> element as the default portal target (for all tooltips, popovers, dialogs, etc), because portaling things into <body> can cause real performance problems. This div is otherwise pointless, which makes it a good candidate for display: contents.

Code snippet
<body>
  <!-- all regular elements go here -->

  <div style="display: contents">
    <!-- all portaled elements go here! -->
  </div>
</body>

Light DOM slots

HTML has slots! But only in shadow DOM 😐. So we must recreate slots in light DOM. Most frameworks tend to do it entirely in JavaScript-first ways (either server-side or client-side), but HTML-first slots are a totally valid approach too.

A card component might render something like this to the light DOM:

Code snippet
<the-card>
  <a href="/">
    <the-slot> <!-- comes from elsewhere --> </the-slot>
  </a>
  <img />
  <p>
    <the-slot> <!-- comes from elsewhere --> </the-slot>
  </p>
</the-card>

The slot can then use display: contents to avoid participating in layout.

Code snippet
the-slot {
  display: contents !important;
}

If we ever get CSS @scope, we’ll be able to slot the-slot into the lower boundary, making it a breeze to create donuts without hashing any strings!

Code snippet
@scope (the-card) to (the-slot) {
  /* These selectors are scoped to only the-card's own elemeents! */
  a {
    /* … */
  }
  img {
    /* … */
  }
  p {
    /* … */
  }
}

Fun fact: Shadow DOM slots also use display: contents if you look inside the UA stylesheets.

Code snippet
slot {
  display: contents;
}