yes.js: freeze .hero-yes's height via inline style, don't trust svh alone
Deploy / deploy (push) Successful in 34s

The container-rect approach (reading .hero-canvas's rendered rect via
ResizeObserver) still jumped on the real device: .hero-copy kept
sliding down as the address bar collapsed even with svh (then lvh)
driving .hero-yes's height, meaning the box's actual rendered height
wasn't holding still the way the "stable" viewport units are spec'd
to. Reading a rect only helps if what it reads is actually fixed - it
wasn't, so no amount of matching the canvas to it would help.

yes.js now writes the fix instead of reading around it: it measures
window.innerWidth/innerHeight once and sets that literal px height as
an inline style on the hero element. Inline style beats the
stylesheet's `height: 100svh` in the cascade, so the box's rendered
height becomes a fixed number in the DOM rather than something
recomputed from a viewport unit on every layout - nothing the browser
does with svh afterward can move it. The old width-gated resize
listener comes back to re-freeze on a genuine resize (orientation
change), since that's still the correct signal for "the address-bar
animation is not what's happening right now."
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-06 21:38:58 +02:00
parent d7f280e681
commit 2422f5fb49
2 changed files with 50 additions and 35 deletions
+10 -10
View File
@@ -153,16 +153,16 @@ main.loading, main.not-found {
position: relative; position: relative;
max-width: none; max-width: none;
width: 100%; width: 100%;
/* svh (small viewport height): a fixed, stable length - computed /* svh here is only the pre-JS/no-JS fallback (and first paint before
once, never recalculated mid-scroll as mobile Safari's address yes.js's constructor runs). Once yes.js mounts, it overwrites this
bar hides/shows, unlike plain vh/dvh on some engines. Pinned to with an inline `height: <px>` frozen from a single measurement -
the *smallest* possible viewport (toolbar visible) so the box see setupCanvas()'s comment in yes.js for why: on real mobile
never needs to grow into space the toolbar might reclaim. Safari, content bottom-aligned inside this box kept sliding down
yes.js's canvas measures this box's own rendered rect (not as the address bar collapsed even with a spec'd-stable viewport
window.innerWidth/innerHeight) via ResizeObserver, so the canvas unit here, so the box's actual height can't be trusted to stay
always matches whatever height this resolves to - the two can't put on that unit alone. An inline style set from JS always wins
disagree, which is what was actually causing the content below to the cascade over this rule, so that frozen number is what
visibly jump. actually governs once the page is interactive.
Plain-vh fallback declared first - an engine without svh support Plain-vh fallback declared first - an engine without svh support
ignores the invalid second line rather than falling through to ignores the invalid second line rather than falling through to
auto height, which would collapse this to the height of its auto height, which would collapse this to the height of its
+40 -25
View File
@@ -41,8 +41,13 @@ export class RasterizedYES {
this.containmentStrength = 0.5; this.containmentStrength = 0.5;
this.wiggleAmount = 0.5; this.wiggleAmount = 0.5;
// .hero-canvas is inset:0 inside this - freezing an inline
// height here (below) is what actually locks the box, not
// just reading its rect.
this.heroEl = this.rasterCanvas.closest('.hero');
this.setupCanvas(); this.setupCanvas();
this.setupResizeObserver(); this.setupResizeHandler();
this.setupMouseTracking(); this.setupMouseTracking();
this.setupClickHandler(); this.setupClickHandler();
this.rasterizeText(); this.rasterizeText();
@@ -52,24 +57,34 @@ export class RasterizedYES {
stop() { stop() {
this.destroyed = true; this.destroyed = true;
if (this._resizeObserver) { if (this._resizeHandler) {
this._resizeObserver.disconnect(); window.removeEventListener('resize', this._resizeHandler);
} }
} }
// Measures the canvases' own containing box (.hero-canvas, which // Reading .hero-canvas's rendered rect (a prior attempt at this)
// tracks .hero-yes via inset:0) rather than window.innerWidth/ // only helps if that rect actually stays put - and on real mobile
// innerHeight. window.innerHeight is the live viewport - it moves // Safari it didn't: .hero-copy (bottom-aligned via flexbox inside
// as mobile Safari's address bar hides/shows on scroll, which // .hero-yes) kept sliding down as the address bar collapsed, even
// doesn't itself match whatever fixed value .hero-yes's CSS height // with a spec'd-stable viewport unit (svh, then lvh) driving the
// is pinned to. Sizing the canvas off the box's own rect instead // box's height. Either the browser isn't holding up its end, or
// means the two can never disagree, whichever viewport unit ends // something in the cascade is still landing on a live value - not
// up governing that box. // provable from here without the device. Rather than keep
// guessing at which CSS viewport unit actually holds still, yes.js
// becomes the source of truth instead: it freezes .hero-yes's
// rendered height to a literal inline px value once, up front. An
// inline style always wins the cascade over the stylesheet's
// `height: 100svh`, so once this runs, nothing the browser does
// with that unit afterward can move the box - the number is fixed
// in the DOM, not recomputed from a unit at all.
setupCanvas() { setupCanvas() {
const pixelRatio = window.devicePixelRatio || 1; const pixelRatio = window.devicePixelRatio || 1;
const rect = this.rasterCanvas.parentElement.getBoundingClientRect(); const width = window.innerWidth;
const width = rect.width; const height = window.innerHeight;
const height = rect.height;
if (this.heroEl) {
this.heroEl.style.height = height + 'px';
}
this.rasterCanvas.style.width = width + 'px'; this.rasterCanvas.style.width = width + 'px';
this.rasterCanvas.style.height = height + 'px'; this.rasterCanvas.style.height = height + 'px';
@@ -89,19 +104,19 @@ export class RasterizedYES {
this.pixelRatio = pixelRatio; this.pixelRatio = pixelRatio;
} }
// ResizeObserver reacts to the container's box actually changing setupResizeHandler() {
// size - a fixed-height box (svh, lvh, or plain px) never fires // Gate on width, not height: mobile Safari's address-bar
// this at all during the address-bar animation, so there's no // animation changes window.innerHeight continuously with no
// need for the old "only redraw if width changed" heuristic that // real layout change to react to (that's the live value this
// guessed at which resize events were real. // whole method exists to stop trusting). A genuine resize -
setupResizeObserver() { // orientation change, desktop window drag - always changes
this._resizeObserver = new ResizeObserver((entries) => { // width too, so that's the real signal to re-freeze on.
const { width, height } = entries[0].contentRect; this._resizeHandler = () => {
if (width === this.displayWidth && height === this.displayHeight) return; if (window.innerWidth === this.displayWidth) return;
this.setupCanvas(); this.setupCanvas();
this.rasterizeText(); this.rasterizeText();
}); };
this._resizeObserver.observe(this.rasterCanvas.parentElement); window.addEventListener('resize', this._resizeHandler);
} }
setupMouseTracking() { setupMouseTracking() {