DEV Community

Cover image for Two layout shifts nobody could see: a ticking clock and a growing toast
ShengXing Chi
ShengXing Chi

Posted on

Two layout shifts nobody could see: a ticking clock and a growing toast

Cá Viên Chiên is a small browser game about running a Vietnamese fried fish ball cart. During a UI audit I added a layout-shift observer to my browser walk and got a number I didn't believe: on the desktop trading screen, six seconds of play added up to a CLS of 1.47. Google calls anything above 0.25 "poor".

The strange part: nothing moved. I sampled getBoundingClientRect() of the play screen and the HUD on every animation frame for over a second and got exactly one state. Yet Chrome logged an entry of 0.0733 on div.screen-play about every 280 ms, each claiming the element had jumped from y 69 / h 659 to y 121 / h 728.

Field CLS doesn't care whether a human can see the shift. Every desktop player on the game page was reporting a terrible score.

Step 1: find the write that does it

Several things change on every game tick: the customers' patience rings (SVG attributes), a few CSS variables on the order cards, and the HUD clock text (16:02 → 16:03). I wrote a Playwright script that opens a day at 1280 × 860 and freezes one of them at a time, then counts layout-shift entries:

nothing frozen   18 shifts  CLS 1.220
rings frozen     18 shifts  CLS 1.220
card vars frozen 18 shifts  CLS 1.220
clock frozen      0 shifts  CLS 0.000
Enter fullscreen mode Exit fullscreen mode

The clock. Replacing one text node in the HUD made Chrome record a shift on the play screen below it, on every tick.

Step 2: why, and what to do about it

The layout is a flex column: the HUD on top, then .stage, which fills the rest and is a size container (container: stage / size), because the whole trading screen is sized with cqw / cqh units.

My working theory (I didn't read Blink's source to prove it): a text change in the HUD dirties layout in the shared flex container, the size container gets laid out again, and the layout-shift tracker compares the play screen against a rect from an intermediate pass. The reported "previous" rect really did look like the stage laid out without the HUD above it.

Whatever the exact mechanism, the cure is to tell the browser that the stage's insides can't affect anything outside it, and vice versa. I tried several options at 1280, 1024 and 375:

HUD position: absolute          0 shifts
clock label position: absolute  0 shifts
.stage  contain: strict         0 shifts
.screen contain: strict        18 shifts   (one level too deep)
.stage  contain: paint          0 shifts
.stage  contain: layout         0 shifts
Enter fullscreen mode Exit fullscreen mode

I picked contain: layout. paint (and so strict) would clip anything that overflows the stage, and a few decorations do. layout clips nothing. Its one side effect is that position: fixed children become fixed to the stage instead of the viewport, so I grepped the stage for fixed elements first. There were none.

.cvc .stage {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  container: stage / size;
  /* The HUD clock's text changes every tick; without layout containment Chrome
     re-laid this out and reported a layout shift on the play screen (CLS 1.47). */
  contain: layout;
}
Enter fullscreen mode Exit fullscreen mode

After that: 0 shifts at every size.

The second shift: a toast that grew upward

The ticket's acceptance criterion said "10 seconds of an open day, total 0". My walk only watched the play screen for a few seconds, so after the release I wrote a dedicated 10-second check. It found one more entry, small (0.0056), at 375 px in Vietnamese only, and not on every run.

This time I logged the entry's sources, which tell you exactly which node moved and from where:

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.hadRecentInput) continue;
    log.push({
      value: e.value,
      sources: e.sources.map((s) => ({
        node: s.node?.className,
        from: [s.previousRect.y, s.previousRect.height],
        to: [s.currentRect.y, s.currentRect.height],
      })),
    });
  }
}).observe({ type: 'layout-shift', buffered: true });
Enter fullscreen mode Exit fullscreen mode
toast show | "Khách quen mới: Cô Ba vé số — “Con ơi, c…"
from y 603 h 62  →  to y 557 h 108
Enter fullscreen mode Exit fullscreen mode

On phones the game's toast sits at the bottom of the frame (bottom: 12px). When a regular's longer greeting replaced a short message that was still showing, the toast grew from 62 to 108 px. Since it's anchored at the bottom, it grows upward, so its top edge moved, and that counts as a shift. It's an overlay and pushes nothing else around, but CLS still counts it.

I tried three ways of keeping a box glued to the bottom on a tiny test page, replacing short text with long text while it showed:

bottom: 12px                                  0.0043
wrapper with align-items: flex-end            0.0125
top: 100% + translateY(calc(-100% - 12px))    0
Enter fullscreen mode Exit fullscreen mode

📷 Image here: toast-anchoring.webp (upload it and replace this line)

Alt text: Before: a bottom-anchored toast grows upward and its top edge moves. After: its layout box stays at the frame's bottom and a transform lifts it

Layout-shift is computed from layout positions, and transforms aren't layout. So the toast's layout box now sits at the frame's bottom edge, and a transform lifts it by its own height. Its layout top never moves, however tall it gets:

@container cvc (max-width: 639px) {
  .cvc[data-screen='play'] .toast {
    top: 100%;
    bottom: auto;
    transform: translate(-50%, calc(-100% - 20px)); /* hidden: 8 px higher */
  }
  .cvc[data-screen='play'] .toast.show {
    transform: translate(-50%, calc(-100% - 12px)); /* 12 px above the bottom, as before */
  }
}
Enter fullscreen mode Exit fullscreen mode

It still lands 12 px above the bottom and still slides in. I measured that at 320, 375 and 414 before shipping.

What I'm keeping from this

  • A layout-shift entry tells you which node moved. Log sources with previousRect / currentRect before guessing.
  • "Nothing visibly moves" is not proof. Field CLS counts what the browser reports, not what you see.
  • Bisect by freezing writes, one at a time, in a real browser. It took one script run to go from "something per tick" to "the clock".
  • contain: layout on a size container is a cheap way to keep a busy neighbour from re-laying it out.
  • Anchor growing overlays with a transform. Anything that changes size while showing and is pinned by bottom or right will report a shift. Pin its layout box at the top/left and move it with translate.
  • Measure for as long as the spec says. My walk watched a few seconds. The 10-second check found the toast.
  • Check that your measurement can fail. I ran the same checks in WebKit and proudly logged "0" there too, until I noticed that Safari doesn't implement the Layout Instability API at all: PerformanceObserver.supportedEntryTypes has no layout-shift, so the observer silently records nothing. Guard on supportedEntryTypes, and in WebKit compare getBoundingClientRect() between states instead.

The game is free in the browser at cavienchien.net. The previous post in this series covers the Astro + Cloudflare setup and HTML caching.

Top comments (0)