DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A flex child that is allowed to shrink overflows from its centre, so the title left the top of the screen and the button left the bottom

Every practice game on CogniPrep renders inside one component. It is a header with a back button, a question counter and a clock, and below it the game. No page scroll, no site chrome, because a timed assessment that moves under you while you are answering it is a different test from the one the candidate is sitting.

The whole shell is about fifteen lines of JSX, and four class names in it were each paid for by a bug.

h-dvh, not h-screen

<div className="bg-background flex h-dvh w-full flex-col overflow-hidden">
Enter fullscreen mode Exit fullscreen mode

h-screen is 100vh. On mobile browsers 100vh is the viewport with the browser chrome hidden, which is taller than what is actually on screen while the URL bar is showing. A 100vh shell with overflow-hidden on it therefore puts its own bottom strip underneath the URL bar, and because the shell does not scroll, there is no way to reach it.

The bottom strip is where the Start and Next buttons live. So the first version of this was a game you could not start on a phone until you scrolled the page, and the page did not scroll.

h-dvh is 100dvh, which tracks the visible area as the chrome comes and goes. It is the entire fix, and it is worth knowing that the failure it prevents is not "the layout looks slightly off", it is "the primary action is unreachable".

min-h-0, so overflow-auto can do its job

<main className="flex min-h-0 flex-1 flex-col overflow-auto">
Enter fullscreen mode Exit fullscreen mode

flex-1 says take the space left over after the header. That is not enough on its own, because a flex item's default min-height is auto, meaning it refuses to be smaller than its content. A box that will not shrink below its content cannot have a scrollbar, since there is nothing to scroll: the content simply makes the box taller and the box pushes past the bottom of the shell, which is clipped by overflow-hidden.

min-h-0 lets it shrink to the space that is actually left. Then overflow-auto has something to work with.

This is the Tailwind spelling of the oldest flexbox gotcha there is, and I will keep paying for it in some new codebase about once a year.

The one that is not obvious: shrink-0 on the children

<main className="flex min-h-0 flex-1 flex-col overflow-auto [&>*]:shrink-0">
Enter fullscreen mode Exit fullscreen mode

Having just told the main box it may shrink, we tell everything inside it that it may not. That looks like undoing the previous fix. It is not, and the reason is the most interesting thing in this component.

Every game screen inside the shell is a centred box, roughly:

<div className="flex min-h-full flex-col items-center justify-center">
Enter fullscreen mode Exit fullscreen mode

That is what puts a short screen, an instruction panel or a result, in the middle of the available space instead of pinned to the top. Now let that box shrink. justify-center is still in force, so when the content is taller than the box, the overflow is distributed to both ends. The content overflows centred.

Centred overflow is the worst kind, because scrolling cannot recover it. The top of the content is above the box's top edge, which is not a position overflow-auto will scroll to, so the heading is simply gone. At the same time the primary button is below the bottom edge. One screen, two pieces of content amputated, and a scrollbar that behaves as if everything is fine.

[&>*]:shrink-0 stops the screen from being squashed below its content at all. The box grows to its content, the main element becomes genuinely scrollable, and scrollTop 0 shows the heading.

The 320px rules in the header

The header has three things that only exist from sm up, and all three are the same decision.

The back button's text label:

{/* Label drops below sm so the arrow alone remains. At 320px the
    full label plus the question counter did not fit and the two
    overlapped; aria-label carries the meaning either way. */}
<span className="hidden sm:inline">Back to Games</span>
Enter fullscreen mode Exit fullscreen mode

The arrow stays, the words go, and the button keeps its aria-label, so what a screen reader announces does not change with the viewport. A hidden label and an unlabelled icon button look identical in a screenshot and are not the same component.

The width reservation on the counter and clock:

<div className="text-muted-foreground text-right text-xs tabular-nums sm:min-w-[120px] sm:text-sm">
Enter fullscreen mode Exit fullscreen mode

Reserved width is there so the header does not twitch as the counter and the clock change, which is worth having and is also 120 pixels you do not own on a 320 pixel screen. tabular-nums does most of the same work for free, so the reservation starts at sm and the small screen keeps its space for the label beside it.

And the divider between the button and the game name is hidden sm:block, because a separator between two things when one of them is not rendered is a line in the middle of nothing.

The general shape of it

A full screen app layout is four claims, and every one of them is a bug if you leave it out:

  1. The shell is exactly as tall as the visible viewport. h-dvh.
  2. The shell does not scroll. overflow-hidden.
  3. The scrolling region may be shorter than its content. min-h-0 plus overflow-auto.
  4. The content inside that region may not be shorter than itself. shrink-0.

Three and four sound contradictory, and that is exactly why the combination takes an afternoon to find.

The shell wraps every game in the library, so the quickest way to see it is to start one: the Arctic Shores games include free games you can try with a free account, and the provider list covers the other 55. The same layout problem in a three pane form, which is a longer story, is behind the assessment centre exercises.

Top comments (0)