D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

Canvas Hero Pixelated on Real Phones? The Tier Picker Reads `clientWidth`, Not Device Pixels

· · 6 min read
Canvas Hero Pixelated on Real Phones? The Tier Picker Reads `clientWidth`, Not Device Pixels

The hero on that page is not a <video> element. What you actually see is 193 WebP frames drawn one at a time onto a <canvas>, with scroll position deciding which frame shows. On real phones it looked badly pixelated. Not the faint kind you only notice when you go looking for it, but the kind that reads immediately as a small image forced to be large, and once measured it turned out portrait phones really were being served the 720 tier, stretched roughly four times over.

The frames themselves exist in three sizes: tier 720, tier 1280, and tier 1920. So the bigger tiers were sitting on the server, they were simply never chosen. The function doing the choosing read exactly one number, clientWidth.

// The old version in essence: one axis, measured in CSS pixels.
const tier = tiers.find((t) => t.w >= canvas.clientWidth) ?? tiers.at(-1);

That one number misses two things at once

clientWidth is reported in CSS pixels, and a portrait phone has a small CSS width. Two things never make it into that number. First, its device pixel ratio is 2 to 3, so every CSS pixel is filled by two or three device pixels. Second, the canvas cover-fills the viewport, which means the frame has to cover the vertical axis too, and on a portrait phone the vertical axis is the long side. Put together, the actual device-pixel demand on both axes is far higher than clientWidth suggests.

Which side actually binds is visible in the frame geometry. The new frames were derived from a video clip measuring 1764x1176. Divide 1764 by 1176 and you get exactly 1.5, so the ratio is 3:2. If that ratio is preserved when the three tiers are generated, each tier's height is its width divided by 1.5: tier 720 is 480 tall, tier 1280 is roughly 853, and tier 1920 is 1280.

Height is what usually falls short first on a portrait phone, and the missing check was not only the DPR multiplier but the second axis that was never asked about. A tier can be wide enough and still be too short, and clientWidth has no way to tell you that.

The rule that replaced it

The replacement computes the demand first and only then looks for a tier that satisfies it. neededW is the viewport width times min(DPR, 2), neededH is the viewport height times min(DPR, 2), and the pick is the smallest tier whose width is greater than or equal to neededW AND whose height is greater than or equal to neededH. If nothing satisfies both, it falls back to tier 1920.

function pickTier(viewportW, viewportH, tiers) {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const neededW = viewportW * dpr;
  const neededH = viewportH * dpr;
 
  // tiers is already ordered smallest first
  const match = tiers.find((t) => t.w >= neededW && t.h >= neededH);
  return match ?? tiers.at(-1); // tier 1920
}

Two decisions inside it are worth spelling out. The first is that DPR is capped at 2. Since the range in play is 2 to 3, a DPR 3 device gets treated as if it were DPR 2, and the demand computed for it comes out at two thirds of the real thing. That is a compromise that was chosen, not an oversight left behind.

The second is that the fallback is the largest tier rather than the smallest. If no tier is sufficient on both axes, the sensible thing to serve is the one closest to sufficient, not the cheapest one.

What happened once the rule was in place

The effect is blunt. Real phones at DPR 2 or higher now fetch tier 1920. Checking the live site showed every frame fetch coming back as tier 1920, on desktop as well as on DPR-2 mobile, with the preloader climbing from 0 to 100 smoothly.

So the three-rung ladder effectively collapses to its top rung for anything with a retina-class screen. I do not read that as responsive design failing. It is an honest answer to the right question. Once the question changes from "how wide is this box" to "how many device pixels do I have to fill, on two axes", nearly every modern device really is asking for the top tier, and the old version simply never asked.

Why shipping tier 1920 to a phone still makes sense

The new frame set is 193 frames times three tiers, WebP at q80-82 with lanczos scaling, 38MB in total. The old set it replaced held 386 frames and came to 386MB.

The arithmetic is yours to repeat. 193 times 3 is 579 files, which is exactly the number of frame files that went in when the change was committed. 38MB divided by 579 files works out to about 0.066MB per file on average. In the old set, 386MB divided by 386 frames is exactly 1MB per frame. And 386 divided by 38 is about 10.2, so the whole new set is roughly a tenth of the old one, with exactly half the frame count, since 386 divided by 193 is 2.

At file sizes like that, serving the top tier to a phone stops being a decision that needs negotiating. What made the tier fix cheap was not the cleverness of the rule, it was the fact that the assets it picks from had already gotten far lighter first.

Verification, and the cleanup I deliberately held back

I checked the change on a local WordPress Studio instance first. The commit that carried it added 579 new frames and touched 65 files.

One thing I held until later. That same commit also removed the old set's folder from the repo, but the copy on the server was still sitting there, unreferenced by anything, and I noted it as a deletion candidate for after the live verification rather than before it. Once the live checks passed, I removed that folder from the server, which now returns 404 for the old path. That order was deliberate on the server side: the old copy gets to disappear from there after the new assets are proven to be served correctly, not at the moment it looks unused.

What I took away from this one

clientWidth answers the question "how wide is this box in CSS pixels". What a cover-filling canvas is actually asking is "how many device pixels do I have to fill, on two axes". The two happen to produce similar numbers on a DPR 1 desktop monitor, and they start diverging badly on precisely the class of device most visitors are holding.

Since then, whenever I write an image size picker, I make myself write the demand in device pixels on both axes first and only then go looking for an asset that satisfies it. If the guard is comparing one number against one number, there is a good chance it is answering a smaller question than the one being asked.