DEV Community

Tim Seckinger
Tim Seckinger

Posted on • Originally published at blog.jeys.al on

CSS-only "+n items" using counters

Note: This post contains elements that require JavaScript. While I try to provide fallbacks for RSS and third-party sites within reason, the best reading experience will be on my blog website in a web browser with JavaScript enabled.

Even if you've been writing CSS frequently for many years, there's a good chance you've never heard of counters before, despite them having been implemented for the better part of the century, since IE 8 and Chrome/Firefox 1. If you have heard of them before, there's a good chance it was for numbering headings, or perhaps for numbering list items in a more complex way than list-style-type can.

In this post, we'll look at a more exotic use case for CSS counters that I came up with a while ago in 2020. We want to render a list, in this example a list of tags such as those often shown as little pills on an article. But in case there are too many tags, perhaps hundreds of tags, that would excessively clutter the screen, we want to only show the first few — for now the first five — tags and then shorten the remaining n tags to +n more tag(s).

Basic rigid JS implementation

The following renderTagList function is an example implementation of this behavior, and its output for an uncropped list example and a cropped list example is shown below the code.

const MAX_ITEMS = 5;
const renderTagList = (tags) => {
  const items = tags.slice(0, MAX_ITEMS);
  const numberOfCroppedItems = tags.length - items.length;

  const itemElements = items.map((item) =>
    Object.assign(document.createElement("li"), { textContent: item })
  );
  if (numberOfCroppedItems > 0) {
    itemElements.push(
      Object.assign(document.createElement("li"), {
        className: "shortener",
        textContent: `+${numberOfCroppedItems} more tag(s)`,
      })
    );
  }

  const list = Object.assign(document.createElement("ol"), { className: "tag-list" });
  list.append(...itemElements);
  document.currentScript.insertAdjacentElement("beforebegin", list);
};

renderTagList(["Web development", "HTML", "CSS", "JS"]);

renderTagList([
  "Coding 101",
  "Programming",
  "Technology",
  "Web design",
  "Web development",
  "HTML",
  "CSS",
  "JS",
]);
Enter fullscreen mode Exit fullscreen mode

Output on a large screen:
Four tags arranged horizontally, occupying about half the horizontal space available Five tags and a pseudo-tag reading "+3 more tag(s)" arranged horizontally, together occupying the entire horizontal space available

Output on a small screen:
Four tags arranged horizontally, occupying not quite the entire horizontal space available Five tags, each so small that everything after the first letter is cropped with an ellipsis character, and a pseudo-tag reading "+3 more tag(s)", arranged horizontally, together occupying the entire horizontal space available

In case you're curious because there's already a lot going on just to nicely show all tags on one line: The styles for this are as follows, and we'll apply them to all examples on this page:

.tag-list {
  margin-block: 16px;

  display: flex;
  gap: 8px;
}
.tag-list > li {
  max-inline-size: max-content;
  flex: 1 0;

  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;

  border: 1px solid currentcolor;
  border-radius: 100vmax;
  padding: 0.5em;
}
.tag-list > li.shortener {
  min-inline-size: max-content;
}
Enter fullscreen mode Exit fullscreen mode

The screen space problem

Now, if you are viewing this article in a large window on a large screen, the five tags plus shortener tag probably look okay to you. If the tag list, however, has little space or a sufficiently large font size, each tag will be so narrow that it is cut off after the first letter. Letting the flexbox wrap would "solve" this, but we probably don't want to sacrifice half a screen of vertical space just to display as many tags as we do on large screens where the space is not at a premium.

Instead, we can make our MAX_ITEMS flexible based on how much space is available. We can show up to two items on small screens, and up to five on big screens starting from some threshold. Short of reaching for a ResizeObserver and expensive JS re-layouting, there is a straightforward hack we could use to do this that almost always works™: Render both versions (5 tags + 3 more tag(s), and 2 tags + 6 more tag(s)) and conditionally hide the one we don't need with display: none in a media or container query.

But this is always a hack and never ideal. It causes longer script execution time and bigger DOM size. With smart use of modern CSS, I have yet to see a real-world case where things cannot be layouted properly. The case at hand seems daunting, because we need to render the +n more tag(s) text based on both total number of items and the limit of items to show for the current size, all in CSS. Yet if you manage to take this challenge and think of an old, rarely used CSS feature called counters, the pieces suddenly start falling into place.

The counter solution

The solution is to implement a CSS counter that starts at the total number of items and counts down by one for each item.

In this version, our JavaScript code actually does less. It always renders all list items, and always renders a shortener item. Note that additionally, using a CSS custom property, it lets the list know the total --number-of-items. I believe that unless something like sibling-{count,index}() is shipped, we cannot figure this out in CSS on its own.

const renderTagList = (tags) => {
  const itemElements = tags.map((item) =>
    Object.assign(document.createElement("li"), { textContent: item })
  );
  const shortenerElement =
    Object.assign(document.createElement("li"), { className: "shortener" });

  const list =
    Object.assign(document.createElement("ol"), { className: "tag-list shortened" });
  list.style.setProperty("--number-of-items", tags.length);
  list.append(...itemElements, shortenerElement);
  document.currentScript.insertAdjacentElement("beforebegin", list);
};

renderTagList(["Web development", "HTML", "CSS", "JS"]);

renderTagList([
  "Coding 101",
  "Programming",
  "Technology",
  "Web design",
  "Web development",
  "HTML",
  "CSS",
  "JS",
]);
Enter fullscreen mode Exit fullscreen mode

Now, have a look at the CSS. Since it is quite dense, some explanations may be required, which follow below.

.shortened {
  container-type: inline-size;
}
.shortened > li:first-of-type {
  counter-reset: remaining-items var(--number-of-items);
}
.shortened > li:not(.shortener) {
  counter-increment: remaining-items -1;
}

.shortened > li:nth-of-type(n + 6) {
  display: none;
}
@container (max-inline-size: 700px) {
  .shortened > li:nth-of-type(n + 3) {
    display: none;
  }
}

.shortened > .shortener {
  display: none;
}
.shortened > li:nth-of-type(n + 6) ~ .shortener {
  display: unset;
}
@container (max-inline-size: 700px) {
  .shortened > li:nth-of-type(n + 3) ~ .shortener {
    display: unset;
  }
}
.shortened > .shortener::after {
  content: "+" counter(remaining-items) " more tag(s)"
}
Enter fullscreen mode Exit fullscreen mode

First, we set up the list as a container so that we can query its inline size later and adjust the number of items to show based on it. We make the first item initialize the remaining-items counter with the total --number-of-items, and make each item, including the first item, count it down by one, so that it stores the number of items remaining unrendered.

I would like to move the counter-reset to the list itself instead of the first item because it would be less clunky, but for some reason, only Firefox has implemented initializing a counter and then using it in child elements at the time of writing this post.

Second, we hide every element, including the shortener if applicable, starting from the sixth item (the first item that exceeds our limit of five). If the list has less than 700px of space, we further hide every element starting from the third item (the first item that exceeds our limit of two). This establishes our cropping if there are items with such high indices, but does not yet deal with showing a shortener if anything was cropped.

Third, we start by generally hiding the shortener. It might have already been cropped by the previous code, but if our list is shorter than our limit that has not yet happened. We then show the shortener again if it succeeds an item that is cropped, namely the sixth item in a large container or the third in a small one. If an item with such a high index exists, something is being cropped off and we need to show the shortener.

Finally, we set the shortener text using a ::after pseudo-element. One might think that the counter will always be zero at this point, because we initialize it with the total number of items and count it down by one for each item, but there is one important part of the CSS Lists and Counters spec that makes the number of remaining items actually correct and not always zero. Counter properties have no effect in elements that do not generate boxes, such as those set to display: none. This is why our cropped items will leave the remaining-items counter untouched.

The final result looks is shown below, for the same two examples of tag lists. Make sure to try it out on a small screen where up to two tags are shown, and a big screen where up to five tags are shown. Note that at the time of writing this post, Firefox has not yet implemented container queries, so it will always show you the five-tag version. If you'd like to use this in production now and support Firefox, container queries have to be replaced with less flexible media queries.

Output on a large screen:
Four tags arranged horizontally, occupying about half the horizontal space available Five tags and a pseudo-tag reading "+3 more tag(s)" arranged horizontally, together occupying the entire horizontal space available

Output on a small screen:
Two tags and a pseudo-tag reading "+2 more tag(s)" arranged horizontally, together occupying not quite the entire horizontal space available Two tags and a pseudo-tag reading "+6 more tag(s)" arranged horizontally, together occupying the entire horizontal space available

Final notes

It is arguably nonsensical to ever show +1 more tag(s), because the text is so long that one might as well show the actual one remaining tag instead. To address this, we could make the shortener text actually short (such as +n). Alternatively, it is fairly straightforward to add in nth-last-of-type selectors to disable the cropping if only one item would be cropped. This was omitted here because the CSS code already has enough going on that needs to be explained as is.

If you use counters like in this blog post, watch the progress of reversed() counters. They could further simplify things.

It is pretty amazing that CSS can do this, and not only due to recent additions — container queries can be substituted with media queries, the core counter shortening implementation remaining intact. However, this is still not how I would like the tag list component to work. In an ideal world, the number of tags to show is not decided by a map from container size ranges to tag limits, but by the length of the tags themselves. The shortener should act like text-overflow: ellipsis, jumping in when there is not enough space left to show the next tag.

We rely on display: none to disable counting down for elements that are not actually visible. It is not possible to do this relying on elements overflowing their box, because these elements still generate boxes and thus count down the counter. Furthermore, there is no way to query for elements that overflow their parent, because it would mix up cause and effect. The styles are the cause, and the layout, including which elements overflow and which don't, is the effect. Altering the styles based on an overflow would create a cycle, which CSS tries to, and has with a few exceptions managed to, avoid.

Top comments (0)