D
P
0
← All articles Baca dalam Bahasa Indonesia

CSS Layout, Overflow & Cascade

Backgrounds Blurry on Large Screens? `background-size: cover` on a 3,800px Section Scaled Them 2.6x, Lossy WebP Smeared the Grain

· · 12 min read
Backgrounds Blurry on Large Screens? `background-size: cover` on a 3,800px Section Scaled Them 2.6x, Lossy WebP Smeared the Grain

The complaint in the thirteenth feedback round, dated 24 September 2026, was very specific: the backgrounds the designer had made looked quite blurry or pixelated on the site, especially when the site was viewed on a larger screen. The client added that they did not understand why, because the designer had created the backgrounds in high definition and the original HD files had already been sent over WeTransfer. The request was just as clear. Check how those files were being compressed, resized or rendered on the site, because the client wanted them to stay as sharp and detailed as the originals.

The client was right, and the cause was on my side, not in the designer's files. There were two causes, and both were found by measuring rather than by looking at the screen. The first was a CSS problem, the second an encoder problem.

cover measures the section, not the screen

The backgrounds on this site are painted directly on their sections. The base rule looks like this, with the class and custom property names disguised:

.ground {
	position: relative;
	background-color: var(--section-base);
	background-image: var(--ground);
	background-size: cover;
	background-position: center;
	background-repeat: no-repeat;
}

For a section roughly one screen tall, this rule is right. The trouble is that cover scales the image to the section's box, not to the screen, and some sections on this site are about 3,800px tall: sections on two long pages, and one section on the homepage. On a section several screens tall, the image gets enlarged by the section's height.

The scale can be computed per section. cover takes the larger factor of the two axes so the box is fully covered, and a screen with a DPR above 1 multiplies it again:

scale = max(section width / image width, section height / image height) × DPR

Every background from the designer is 1920x1440. On a 3,800px section, the height ratio is 3,800 divided by 1,440, about 2.64. The width ratio stays far below that on common desktop screens: 1,440 divided by 1,920 is 0.75, and 2,560 divided by 1,920 is only 1.33. So the height ratio always wins, and the image is drawn at 2.6x at every desktop width. On a Retina screen with a DPR of 2, that number doubles: 2.64 times 2 is 5.28, so each file pixel spreads across about 5.3 device pixels on each axis. On a phone, the measured enlargement was 13x.

Sharpness is not the only casualty. The image is stretched over the whole height of the section while the screen only ever shows one slice of that section, so what you see is always a fragment of the composition. The designer's composition never shows whole.

A ground pinned at one screen tall

The usual shortcut for this is background-attachment: fixed. A fixed background is positioned against the viewport, so cover measures the screen instead of the section. I deliberately left it out, because iOS ignores it and phones would keep their 13x. Phones are exactly where the number is worst.

The fix that keeps the design is a pinned ground: an absolutely placed wrapper spanning the section, with a position: sticky; top: 0; height: 100lvh child that carries the image at cover. The pin class went on the three tall sections, and every pinned section gets one extra empty element:

<!-- Simplified, class names disguised -->
<section class="ground ground--pinned" style="--ground: url(...)">
  <span class="ground__pin" aria-hidden="true"></span>
  <!-- section content -->
</section>

The CSS, with the same names disguised:

.ground--pinned {
	background-image: none;
}
 
.ground--pinned > .ground__pin {
	display: block;
	position: absolute;
	inset: 0;
	pointer-events: none;
}
 
.ground--pinned > .ground__pin::before {
	content: "";
	position: sticky;
	top: 0;
	display: block;
	height: 100vh;
	height: 100lvh;
	background-image: var(--ground);
	background-size: cover;
	background-position: center;
	background-repeat: no-repeat;
}

The section stops painting its own background. The wrapper fills the section through inset: 0, anchored by the position: relative from the base rule, and pointer-events: none keeps it from blocking clicks. The image is carried by the wrapper's ::before, which is one screen tall and sticky at top: 0. A sticky element cannot leave its parent's box, and the parent here is as tall as the section, so the code comment sums up the behaviour in one sentence: the layer starts flush with the section's top and lets go at its foot. Because the box that cover fills is now the size of the screen, the ground always shows at screen scale and stays in place while the content scrolls.

::before can read var(--ground) because custom properties inherit from the section to its descendants, pseudo-elements included. The image is passed as an inline custom property per section on purpose, rather than written into the stylesheet. That is the only way a versioned URL can reach a stylesheet, while a path written straight into the CSS would be served stale the day the client sends a corrected texture.

The two height declarations are deliberate too. 100vh is the fallback for browsers that do not know lvh, and browsers that do know it use the second declaration. The reason for lvh is recorded in the code comment: it is the tallest viewport, so a collapsing address bar never uncovers a strip under the layer.

If you copy this pattern elsewhere, keep one general CSS rule in mind. An element with position: absolute paints above ordinary, non-positioned blocks, so the section's content needs to be positioned as well, with position: relative for example, to stay above the image layer.

Two other sections get a class variant that applies only on narrow screens. On a desktop each of them is about one screen tall, and there cover is already right. Once their columns stack they grow to two or three screens, and there cover is wrong again. So their ground is pinned only below max-width: 59.9375em, which is about 959px when the browser's default font size is 16px.

A ground that is exactly one screen tall also makes the visible part of the image computable. On a screen slimmer than the image's 4:3 ratio, cover follows the height, and the number of image columns on show equals 1,440 times the screen's aspect ratio. On a screen with an aspect of 0.864 or less, that means at most 1,440 × 0.864 = 1,244 columns. A day later, on 25 September, one of the grainy backgrounds got a portrait counterpart: the centre 1,246 of its 1,920 columns, cut from the designer's master. That crop is slightly wider than the 1,244 columns that can show, so it draws the same pixels for 549 KB less on the first screen of its page.

Lossy WebP eats the grain

The second cause was in the encoder. The client's numbered textures went in on 23 September as WebP q84, one per section. On grainy grounds, lossy WebP cannot keep the grain. Measured against the original, WebP tops out around 33 to 34 dB, even at q95. One grainy background sat at only 32.99 dB against its original before the fix, with its grain smoothed out.

Those decibel figures are PSNR, a measure of how far the encoded pixels drift from the original pixels. The scale is logarithmic, and the higher the number, the closer the result is to the source file.

My notes give the explanation in a parenthesis: chroma subsampling smears the grain, and that is exactly what reads as blur. Lossy WebP does always store colour at 4:2:0, half resolution on each axis. But that explanation has to be read together with the next result. AVIF 4:4:4 at q88, which keeps colour at full resolution, was no better, and not a single AVIF 4:4:4 file passed 45 dB. So dropping the subsampling alone was not enough to save the grain in those tests.

What kept the grain was near-lossless mode. cwebp -near_lossless 60 holds 46 to 47 dB at roughly 60 to 80 per cent of the JPEG's size. The option runs WebP's lossless encoder after a preprocessing step that nudges pixel values to make them easier to compress. A value of 100 means no preprocessing, and the smaller the number, the more aggressive the adjustment. The minimal form:

# Minimal example, neutral file names
cwebp -near_lossless 60 original.jpg -o result.webp

Smooth gradients are a different story. Lossy q92 is fine for them, at 48 to 50 dB and tens of KB. So the rule is not one format for every file but a choice per file, by PSNR against the original, with a target of at least 45 dB.

Every background was re-encoded from the designer's files, and all of them now sit at 45 dB or more against the original. Three files use near-lossless 60, including the two grainy backgrounds, at 1.5 to 2.2MB each. The gradients use lossy q92 to q97, at 14 to 298KB.

The price of the grain shows in the commit's diff. The grainy background that had measured 32.99 dB went from 446,604 bytes to 1,574,518 bytes, about 3.5 times. The second grainy background went from 858,998 bytes to 1,951,808 bytes, about 2.3 times, but that jump is not purely the encoder's doing, because in the same commit the file was also replaced with a new version from the designer that carries the grain.

Choosing per file sometimes means not converting at all. For the 9:16 opening photograph the client sent a day later, the client's JPEG is used as sent, because the only WebP that kept its grain, the near-lossless one, came out at twice its size.

The page got heavier, on purpose

Files that stay faithful to their originals are also heavier. The increase was deliberate, because the client asked for results as sharp as the original files. The grounds on the homepage now total about 4.3MB, two of them at 2.2MB and 1.9MB, up from about 1MB.

That weight has a side effect on slow connections. A CSS background starts downloading as soon as its section is styled, on screen or not. With the connection throttled to a slow phone speed, one background below the fold arrived before the hero photograph and held it back by about ten seconds.

The fix holds the grounds back. A script marks the document with a holding class, then releases each section with a second class in two steps. A ground that is actually on screen goes at once. "Actually" here means at least one pixel of overlap, because the section under the hero touches the fold, and IntersectionObserver counts an edge that merely touches as intersecting. The look-ahead of one screen starts only after the window's load event, once the hero photograph is in.

The holding rule removes the background from both the section and the pin's ::before until the release class is on:

/* Class names disguised */
html.ground-lazy .ground:not(.is-ground-in),
html.ground-lazy .ground:not(.is-ground-in) > .ground__pin::before {
	background-image: none;
}

While a ground is held, the section's base colour shows. The result was measured at 1.6 Mbps: the hero photograph finished in 15.4 seconds, down from 25.2. The difference is 9.8 seconds, in line with the roughly ten seconds the below-the-fold background had been taking from it. The hold also fails safely. If the observer is missing, every ground is released, and without script nothing is held.

A bigger export does not always help

Before promising the client sharpness, one more thing needs checking, which is what the source files really are. Opening the designer's artboard PSD, all 188MB of it, showed that several grounds were built from small pieces. The vector colour fills and a 3600px logo could indeed be exported larger. But one texture inside is an AI image of 1122x1402 stretched to 1920x2399, a photo in two other backgrounds is only 960x1200, and the grunge in one of the grainy backgrounds is only 1920.

The arithmetic is simple. 1,920 divided by 1,122 is about 1.71, and 2,399 divided by 1,402 is also about 1.71, so that texture had already been enlarged about 1.71 times inside the PSD, before any CSS touched it. In parts like that, no export can add detail.

The same limit applies to the file dimensions. An older version of one background used to be 2544x1904, but the new version that matches the mockup is only 1920, so on a 2560 screen it comes out slightly softer than the old one. On 2560 and Retina screens, a 3840px export from the designer would only help the gradient and lettering backgrounds.

The explanation for the client, and what is still unanswered

The pinned grounds and the re-encode went into the repository on 24 September 2026 at 12:33:19. Before the commit, the fix was built and verified locally, then production was re-uploaded. Because the final tree matched what had been verified in production, production equals the repository.

That afternoon I sent the client an update, opening with the news that the new version was live. It said the client was right and that the cause was on our side, not in the designer's files. I explained the first issue without CSS terms: on the longer pages, each background had been stretched to the full height of the page, about two and a half times its real size and even more on phones, and now appears at screen size and stays in place while the content scrolls over it. The second issue was that the compression we used was smoothing out the grain in the textured backgrounds, and all backgrounds are now re-encoded from the original files at near-original quality.

The same message said the background files are 1920px wide and offered a 3840px export from the designer. That export would help most for the gradient and logo backgrounds, while pieces that were small to begin with would only benefit if the designer has higher-resolution originals. The offer is recorded without an answer.

What is verified by numbers is the encode: every background at 45 dB or more against its original. For the pinned grounds, my notes keep no scale figures after the fix. What they record is only the intended result, a ground that always shows at screen scale. There is also no confirmation from the client yet that the backgrounds now look sharp.

The "blurry" complaint turned out to hold three things, each of them measurable: the scale per section, the PSNR per file, and the resolution of the pieces inside the PSD. The first two were on my side, and I could fix them. The third can only be answered by larger files from the designer, if such files exist.