CSS-only "+n items" using counters
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",
]);
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;
}
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",
]);
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)"
}
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.
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.