Writing · CSS
Separators that know where the line breaks
A mark between items, left hanging at the end of a line when the row wraps: why a breakpoint can't fix it, a script that can, and a few lines of CSS that do it better. 6 minutes, 4 demos.
Design · Motion · Typography · Layout · Accessibility · Performance · Code
DesignMotionTypographyLayoutAccessibilityPerformanceCode
On this page
01 · The idea
A separator belongs between two things
A row of short items with a mark between each, Design · Motion · Typography, is everywhere: tags, a byline, a list of skills, links in a footer. On a wide screen it’s one line and looks fine. Narrow the screen and the row wraps, and the mark that was between two items is now at the end of a line, pointing at nothing, or worse, at the start of the next one.
A separator only means something between two things. Where the line breaks, the break already separates them, so the mark should go. This piece shows why the usual fixes don’t manage that, a script that does, and a few lines of CSS that do it better.
The CSS trick is Ron’s, a web developer who showed it in a short video, and this site’s lists use it now.
02 · Where they hang
The usual ways leave one behind
There are two usual ways to write such a row, and both hang a mark somewhere. Joined into one string of text, the browser breaks the line at a space, and the mark goes with whichever word it’s next to: usually the end of the first line, sometimes the start of the second. Put each mark in its own element between the items, and it wraps like any other item, alone at either end.
Design · Motion · Typography · Layout · Accessibility · Performance · Code
1<p>2 {tags.join(" · ")}3</p>
The ember marks are the ones left hanging, found by measuring the row. Drag the window slowly and watch them move: which mark hangs depends on exactly how much fits, so it changes with the text, the font, the language and the screen.
03 · Not a breakpoint
Why a query can't do it
The first thought is a query: hide the marks below some width. But which width? The row’s items are content, and content changes. A longer word, a translation, a date or a count, and the row wraps somewhere else. Any number you pick is either a guess that breaks, or the worst case, which hides the marks long before the row wraps.
/* A guess: right for these words, wrong for the next ones */
@container (max-width: 420px) {
.tags .mark { display: none; }
}The better question is whether the row is taller than one line. A container query can ask about height, but only of a container whose height doesn’t come from its content: asking needs size containment, and with it the row’s height no longer counts its items. It collapses to nothing, so the query has nothing to read. The row’s height is exactly the thing that can’t be asked.
04 · The JavaScript way
Measure, then hide
So the usual answer is a script. Keep each mark inside the item before it, in one piece that can’t break, so a mark can only ever be at the end of a line. Then, every time the row changes size, read where each item sits: an item whose next one starts lower is the last on its line, and its mark is hidden.
Design · Motion · Typography · Layout · Accessibility · Performance · Code
1new ResizeObserver(() => {2 const items = [...list.children];3 items.forEach((item, i) => {4 const next = items[i + 1];5 // the last on its line: the next starts lower6 item.dataset.lineEnd =7 next && next.offsetTop > item.offsetTop;8 });9}).observe(list);
/* Hidden, not removed: it keeps its room, so nothing moves and the script doesn't run again */
[data-line-end="true"] .mark { visibility: hidden; }It works, and a ResizeObserver runs after layout and before paint, so done this way there’s no flash. But it has a cost every time. It reads the position of every item on every resize, and the counter shows how often that is in one drag. Until the script has loaded, the page the server sent shows the marks hanging. And it only knows to run again when the row’s size changes: swap the text without changing the size and it’s wrong until the next resize.
05 · The CSS way
Carried, then clipped
Ron’s trick turns it round. Don’t put the mark between the items: give it to the item after it, sitting in the gap just before that item. Then think about what happens at a wrap. An item that moves down to a new line starts at the row’s left edge, so its mark, just before it, is now outside the row. Clip what overflows the row, and the mark is still there but can’t be seen.
1.tags {2 display: flex;3 flex-wrap: wrap;4 column-gap: 0.75em;5 overflow-x: clip;6}7.tags > * { position: relative; }8.tags > * + *::before {9 content: "·";10 position: absolute;11 right: 100%; /* in the gap before it */12 width: 0.75em; /* as wide as the gap */13 text-align: center;14}
Switch the clip off to see it: the marks of the items that start a line stand past the row’s dashed edge, in ember. Switch it back on and they’re cut away. There’s no script, no number and no measuring: the browser’s own line breaking decides, every time, for any text.
A few details make it hold up:
overflow-x: clip, nothidden. Hidden would make the row a scroll container and clip its other axis too. Clip only cuts, and only sideways, so focus rings and descenders above and below are left alone.- The gap is the mark’s width. The mark sits
right: 100%of its item, as wide ascolumn-gap, so it’s centred in the gap. Keep both in one variable so they can’t drift apart. - No padding on the row’s sides. A row clips at its padding edge, so padding would give a mark room to show.
- A screen reader reads the items, not the dots.
content: "·" / ""gives the mark empty alt text, where a browser understands it.
.tags {
display: flex;
flex-wrap: wrap;
column-gap: var(--gap);
overflow-x: clip;
}
.tags > * + *::before {
content: "·" / "";
position: absolute;
inset-inline-end: 100%;
width: var(--gap);
text-align: center;
}The same idea works the other way round. Stack items with a line between them and give each item after the first a line sticking out of its top: when they sit side by side instead, the lines stick out above the row and are clipped the same way.
06 · Which one
What to keep
- A separator only means something between two things: where a line breaks, it should go.
- A width breakpoint can only guess where content wraps, and a height query can’t read a row whose height is its content.
- A script can measure and hide, but it reads every item on every resize, waits for JavaScript, and goes stale when the text changes without a resize.
- In CSS, give each mark to the item after it, in the gap before it, and clip the row’s overflow: an item that starts a line takes its mark past the edge.
- Use
overflow-x: clip, size the mark to the gap, and keep padding off the row’s sides.