DEV Community

Isaiah Kim
Isaiah Kim

Posted on

Custom element FOUC: the rule that hid nothing

I shipped a CSS rule that could not possibly do its job, looked at the page, saw the flash still happening, and spent longer than I want to admit tuning the wrong thing.

The product is Tearline, a small zero-dependency web component I'm building under Kynth. You wrap any HTML in one tag and it renders as a thermal receipt you can export as a PNG:

<tear-line barcode="047320260726">
  <h1>MERIDIAN</h1>
  <p>Coffee &amp; Provisions</p>
</tear-line>
Enter fullscreen mode Exit fullscreen mode

Custom elements have a well-known ugly window. Until customElements.define runs, those children are just ordinary HTML sitting in the page: a bare <h1> and some <p>s in whatever the host's body font is, at a completely different height to the receipt that eventually replaces them. On a cold load that's visible long enough to read, and then the layout jumps.

The standard fix is :not(:defined). :defined only starts matching once the element upgrades, so hiding on :not(:defined) is exactly the window you want to cover.

Where I put it, and why that was wrong

I didn't want every consumer of the component to have to remember a stylesheet rule. So I had tearline.js inject it:

// src/tearline.js — what I shipped first
if (typeof document !== 'undefined' && !document.getElementById('tear-line-fouc')) {
  const s = document.createElement('style');
  s.id = 'tear-line-fouc';
  s.textContent = 'tear-line:not(:defined){visibility:hidden}';
  document.head.append(s);
}

customElements.define('tear-line', TearLine);
Enter fullscreen mode Exit fullscreen mode

There was a second reason I liked this, and it's a real one: if the script never loads at all, the rule never lands either, so the content stays visible instead of being hidden forever by a stylesheet with nothing left to reveal it. Failure mode handled. Nice.

Except the whole thing is inert. That script running is the exact moment :not(:defined) stops matching. The rule and the definition arrive in the same tick, so the rule only ever exists after the window it was written to cover. It hid nothing during the only period that mattered.

I found this by throttling tearline.js to a 1.2s delay in devtools, which made the unstyled window long enough to actually stare at. Before that I'd been reading the rule, agreeing with the rule, and assuming the flash was coming from somewhere else.

Custom element FOUC: the rule that hid nothing — code

The rule has to be present on the first frame

Which means it has to be in the page's stylesheet, not in the component. It's now in src/app/template.css:

.js tear-line:not(:defined) {
  visibility: hidden;
}
Enter fullscreen mode Exit fullscreen mode

The .js prefix is how I keep the property I liked about self-injection. An inline script in <head> (src/app/layout.tsx:70) does one thing:

document.documentElement.classList.add('js')
Enter fullscreen mode Exit fullscreen mode

It's inline and synchronous, so the class is on <html> before anything can match against it. If scripting is off or the bundle dies, the class is never stamped, the rule never applies, and you get the raw markup rather than a permanently blank box. Same guarantee, moved to a place that's actually there on the first paint.

This was the second time on this project I'd hit the same shape. The landing page's scroll reveals had it too: ScrollReveals was setting each element's hidden opacity from a useEffect, which runs after hydration, which is after the browser has already painted the server HTML fully visible. Every load was doing paint the whole page, blank the parts that reveal, fade them back in. No amount of tuning the IntersectionObserver fixes that, because the wrong frame has already been shown by the time the JS exists. Hidden state belongs in CSS; the runtime should only ever move things toward visible, which is a direction it can't be late for.

Hiding is only half of the flash

Once nothing was visible during the upgrade, the page still moved on arrival, because reserving zero space and then dropping in a full receipt is its own layout shift. Two more pieces:

.panel-result,
.tl-stage {
  min-height: 260px;
}

tear-line[data-ready] {
  animation: tl-arrive 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
Enter fullscreen mode Exit fullscreen mode

260px is a guess. Being roughly right and stable beats being exactly right one frame late.

The data-ready attribute took one more correction. My first version set it at the top of the render path, and the receipt faded in half-drawn: paper present, tear and barcode not yet. The clip path and the barcode are both built from the same seeded stream and get applied later in the same method, so "ready" had to mean after all three:

// The host page fades in on [data-ready]. Set on the next frame, after the
// paper, the tear and the barcode are all in place.
if (!this.hasAttribute('data-ready')) {
  requestAnimationFrame(() => this.setAttribute('data-ready', ''));
}
Enter fullscreen mode Exit fullscreen mode

The attribute is only set once, so re-renders (changing seed, changing width) don't re-trigger the entrance animation. And the whole thing is dropped under prefers-reduced-motion: reduce.

Verified the same way I found it: throttle the script to 1.2s, watch the whole pre-upgrade window. Hidden throughout, no unstyled text at any point, no shift when it arrives.

The part I'd keep from this: a rule whose job is to cover the window before a script runs cannot be delivered by that script. That sounds obvious written down. It did not look obvious in the diff, because the code was correct, well-commented, and answering a question about a completely different failure than the one I had.

Top comments (0)