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;
max-width: none;
width: 100%;
/* svh (small viewport height): a fixed, stable length - computed
once, never recalculated mid-scroll as mobile Safari's address
bar hides/shows, unlike plain vh/dvh on some engines. Pinned to
the *smallest* possible viewport (toolbar visible) so the box
never needs to grow into space the toolbar might reclaim.
yes.js's canvas measures this box's own rendered rect (not
window.innerWidth/innerHeight) via ResizeObserver, so the canvas
always matches whatever height this resolves to - the two can't
disagree, which is what was actually causing the content below to
visibly jump.
/* svh here is only the pre-JS/no-JS fallback (and first paint before
yes.js's constructor runs). Once yes.js mounts, it overwrites this
with an inline `height: <px>` frozen from a single measurement -
see setupCanvas()'s comment in yes.js for why: on real mobile
Safari, content bottom-aligned inside this box kept sliding down
as the address bar collapsed even with a spec'd-stable viewport
unit here, so the box's actual height can't be trusted to stay
put on that unit alone. An inline style set from JS always wins
the cascade over this rule, so that frozen number is what
actually governs once the page is interactive.
Plain-vh fallback declared first - an engine without svh support
ignores the invalid second line rather than falling through to
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.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.setupResizeObserver();
this.setupResizeHandler();
this.setupMouseTracking();
this.setupClickHandler();
this.rasterizeText();
@@ -52,24 +57,34 @@ export class RasterizedYES {
stop() {
this.destroyed = true;
if (this._resizeObserver) {
this._resizeObserver.disconnect();
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
}
}
// Measures the canvases' own containing box (.hero-canvas, which
// tracks .hero-yes via inset:0) rather than window.innerWidth/
// innerHeight. window.innerHeight is the live viewport - it moves
// as mobile Safari's address bar hides/shows on scroll, which
// doesn't itself match whatever fixed value .hero-yes's CSS height
// is pinned to. Sizing the canvas off the box's own rect instead
// means the two can never disagree, whichever viewport unit ends
// up governing that box.
// Reading .hero-canvas's rendered rect (a prior attempt at this)
// only helps if that rect actually stays put - and on real mobile
// Safari it didn't: .hero-copy (bottom-aligned via flexbox inside
// .hero-yes) kept sliding down as the address bar collapsed, even
// with a spec'd-stable viewport unit (svh, then lvh) driving the
// box's height. Either the browser isn't holding up its end, or
// something in the cascade is still landing on a live value - not
// 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() {
const pixelRatio = window.devicePixelRatio || 1;
const rect = this.rasterCanvas.parentElement.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const width = window.innerWidth;
const height = window.innerHeight;
if (this.heroEl) {
this.heroEl.style.height = height + 'px';
}
this.rasterCanvas.style.width = width + 'px';
this.rasterCanvas.style.height = height + 'px';
@@ -89,19 +104,19 @@ export class RasterizedYES {
this.pixelRatio = pixelRatio;
}
// ResizeObserver reacts to the container's box actually changing
// size - a fixed-height box (svh, lvh, or plain px) never fires
// this at all during the address-bar animation, so there's no
// need for the old "only redraw if width changed" heuristic that
// guessed at which resize events were real.
setupResizeObserver() {
this._resizeObserver = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width === this.displayWidth && height === this.displayHeight) return;
setupResizeHandler() {
// Gate on width, not height: mobile Safari's address-bar
// animation changes window.innerHeight continuously with no
// real layout change to react to (that's the live value this
// whole method exists to stop trusting). A genuine resize -
// orientation change, desktop window drag - always changes
// width too, so that's the real signal to re-freeze on.
this._resizeHandler = () => {
if (window.innerWidth === this.displayWidth) return;
this.setupCanvas();
this.rasterizeText();
});
this._resizeObserver.observe(this.rasterCanvas.parentElement);
};
window.addEventListener('resize', this._resizeHandler);
}
setupMouseTracking() {