A 1px underline gave our tab bar a vertical scrollbar, and a grid with one column was 40px too wide
Nakodo looked fine on a phone right up until the data arrived. Then pages started scrolling sideways, and the two causes were both things I had written deliberately and would have defended in a review. If you want to ch
Nakodo looked fine on a phone right up until the data arrived. Then pages started scrolling sideways, and the two causes were both things I had written deliberately and would have defended in a review.
If you want to check the result before reading the diagnosis, open the site at 320 pixels wide. Any of the home page, pricing, how it works, a guide or the engagement rate calculator. In Chrome: device toolbar, set the width to 320, and watch document.documentElement.scrollWidth. It is 320 on every page, which is the only number that is ever acceptable there.
A grid with one column is not one column
This is the line that broke the conversations page:
<div className="grid items-start gap-6 lg:grid-cols-12">
Twelve columns on a large screen, and on a phone just a grid with no column definition. Which looks like saying "one column, full width". It is not. It is saying nothing about columns at all, so the grid gets one implicit column, and an implicit track is sized auto.
auto means "as wide as the content wants". The content was conversation names, some of which are a YouTube channel title with no spaces in it. One unbreakable 40 character string, one implicit auto track, and the grid is 360 pixels wide inside a 320 pixel screen. The page scrolls sideways and nothing on it looks like the culprit, because the element that is too wide is a div with no visible edges.
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-12">
grid-cols-1 is grid-template-columns: repeat(1, minmax(0, 1fr)), and the minmax(0, 1fr) is the whole fix. A 1fr track has an automatic minimum size of auto unless you say otherwise, which is the same trap one level down. minmax(0, 1fr) says the track may shrink to nothing, so the content has to wrap instead of the track having to grow.
The same rule applies to children of a flex or grid parent, which is why min-w-0 appears in several places in the same commit:
<div className={cn("min-w-0 space-y-4 lg:order-1 lg:col-span-8", ...)}>
The heuristic I now use: any time a container holds text that might not contain a space, either the track can shrink to zero or the container can. If neither can, the layout is one long word away from being wrong.
The underline that made a horizontal scroller scroll vertically
Our tab bars scroll horizontally on narrow screens, and the active tab has a 2px rule under it sitting on a 1px border under the row. The original, which is the pattern you will find in a hundred tutorials:
<nav className="-mx-4 flex gap-6 overflow-x-auto border-b px-4 sm:mx-0 sm:px-0">
<span className="absolute inset-x-0 -bottom-px h-0.5" />
-bottom-px lifts the underline to cover the row's border-b, so the active marker sits over the rule instead of above it. On a desktop it is pixel perfect.
The problem is that -bottom-px puts one pixel of that element outside the scroll container. overflow-x: auto sets overflow-y to auto as well, not visible, because the two axes cannot be independently visible and auto in CSS: specify one as a scrolling value and visible on the other computes to auto. So the container had one pixel of vertical overflow and became vertically scrollable too. On a phone that is a tab bar that wobbles up and down a pixel while you swipe it sideways, which feels broken without ever looking broken in a screenshot.
The fix was to stop having a border for the underline to cover:
<nav className="-mx-4 flex gap-6 overflow-x-auto overflow-y-hidden px-4 shadow-[inset_0_-1px_0_var(--border)] sm:mx-0 sm:px-0">
<span className="absolute inset-x-0 bottom-0 h-0.5" />
Three changes doing one job. The rule is an inset box shadow, which paints inside the element and takes part in no layout. The underline is at bottom-0, entirely within the container. And overflow-y-hidden is stated rather than left to the browser, so a future element with a stray negative offset cannot reintroduce the scrollbar.
That shadow trick has become my default for any rule under a scrolling row. A border participates in the box model; an inset shadow does not. When the only job is to draw a line, draw a line.
Scrolling versus wrapping is a content question
The same commit moved some rows the other way. The conversation state filters were in a horizontal scroller, and on a 320 pixel screen the "All" filter was off the right edge:
- <nav className="-mx-1 flex gap-1 overflow-x-auto px-1">
+ <nav className="-mx-1 flex flex-wrap gap-1 px-1">
The rule I settled on: scroll when the row is long and order carries meaning, so the user can be trusted to swipe along a sequence. Wrap when the row is short and the items are peers, where an item off screen is an item nobody knows exists. Five filter chips are peers. The same applies to the campaign action buttons, which now flex-wrap, and to a wizard footer that keeps Back and Continue on one row because those two really are a sequence.
Popovers have an edge to hit
Floating elements had their own version of the bug, because a popover is positioned relative to its trigger and does not know about your screen until it is already hanging off it:
collisionPadding = 8,
max-w-(--radix-dropdown-menu-content-available-width)
Two halves of one fix. The collision padding keeps 8 pixels between the menu and the viewport edge, and the max width ties the menu to the space actually available, which the positioning library exposes as a CSS variable. Without the second, a menu wider than the screen is simply nudged until it overflows the other side.
Dialogs got the plainest version of the same idea:
- grid w-full ... data-[size=default]:max-w-xs
+ grid w-[calc(100%-2rem)] ... data-[size=default]:max-w-xs
w-full on a centred fixed element means "exactly the viewport", so the dialog's border touches both edges of the screen and looks like a broken full-screen takeover. calc(100% - 2rem) keeps a 1rem margin either side, and the max width still wins on anything larger than a phone.
One line for the whole class of problem
body {
/* A long word (an email, a handle, a URL) breaks rather than spilling off a phone screen. */
overflow-wrap: break-word;
}
Nakodo displays email addresses, social handles and URLs, none of which contain spaces. overflow-wrap: break-word on the body is the backstop for every one of them, and it is the first thing I would add to any app that shows user-supplied identifiers.
It is overflow-wrap, not word-break: break-all. break-all breaks ordinary prose mid-word at the end of every line, which makes paragraphs look like a ransom note. overflow-wrap: break-word only breaks a word that would overflow on its own.
The one honest exception
A wide data table cannot wrap. The guide pages have comparison tables with four columns of numbers, and squeezing those into 320 pixels makes them unreadable rather than narrow. So they keep a minimum width, inside their own scroller:
<div className="-mx-5 overflow-x-auto px-5 sm:mx-0 sm:px-0">
<table className="w-full min-w-[520px] text-left text-sm">
That is the distinction that matters. At 320 pixels, this guide contains a table whose right edge sits about 220 pixels past the screen, and the page still does not scroll sideways, because the overflow is owned by a container that scrolls. Something wider than the viewport is fine. The document being wider than the viewport is not.
The negative margins with matching padding are there so the scrollable strip runs edge to edge on a phone rather than being inset inside the article's gutters, and the sm: variants undo both once there is room.
How to find these
None of this showed up in a screenshot, which is why it survived so long. What does work:
- Set the viewport to 320 and compare
document.documentElement.scrollWidthwithclientWidth. Equal or it is a bug. - When they differ, find the element rather than guessing. Walk
document.querySelectorAll("body *"), take eachgetBoundingClientRect(), and report every element whoserightexceeds the document'sclientWidth. The widest offender is usually the cause and the rest are its children. - Then ask whether that element is allowed to be wide. If it is a table in a scroller, you are done. If it is a layout
div, something in it cannot shrink.
Running that over Nakodo's public pages today returns no offenders except the guide tables, which is the state I wanted: one number to check, and a short list of things permitted to break it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.