The canvas
Behind every page there's a surface the browser paints first, called the canvasThe surface behind the document. It's painted before anything else and shows wherever the page doesn't cover it.. Most of the time your page covers it completely, so you never think about it.
Then someone scrolls past the top on a phone or a trackpad, the page bounces, and for a moment the canvas is all they see.
Past the edge
All three setups look the same until you drag. With the background on a wrapper <div>, the canvas never hears about it, so the bounce shows plain white. Add color-scheme: dark and the browser at least switches to its own dark gray1. Close, but it isn't your color.
Body's color climbs up
The body setup works because of an old rule. If html has no background, the browser takes the background from body and paints the whole canvas with it2. The body's own box then paints nothing.
That's why a body with margins still looks edge to edge. Below, html's background switches on and off by itself. Watch where the yellow goes, then flatten the layers to see what you'd actually get.
Once html has a background, that's what paints the canvas, and body goes back to being an ordinary box with an ordinary background, margins and all.
Paint html
Borrowing from body works, but it's a fallback, and it breaks quietly. A theme switch that only touches a wrapper, a layout that moves the background onto <main>, a reset that gives html a color of its own. Saying it on the root element is clearer3:
html {
background-color: var(--background);
color-scheme: dark;
}In a Next.js layout with Tailwind, that's a class on the root:
<html lang="en" className="bg-background">If the page switches themes, switch the background on html too, not only on the content. Otherwise one of the two themes still flashes the old color when it bounces.
Browser chrome
Some browsers use theme-color as a hint for the surrounding browser UI4. It is useful for a single theme color, but it doesn't give you separate colors for the top and bottom edges, and support varies. Safari has another behavior that helps when a fixed element meets the edge of the viewport: its solid color can extend behind the browser controls6.
Tint the edges
Put a narrow, fixed strip at each edge and give each strip the color already visible at that edge. The page can keep its own background while the status area and bottom controls pick up the matching colors. The strips need to be fixed to the viewport and clear of the page's interactive content. Use safe-area values so they cover the area around a notch and the home indicator too.

The switch compares the browser's default neutral bars with two edge colors. Move the page position to change its palette: the top and bottom colors are independent, and each strip updates with its edge. In a real page, calculate those colors from the same palette or theme state that paints the visible page.
For a plain page, keep the two colors in CSS variables and expose a fixed element at each edge:
<div class="edge-tint edge-tint--top" aria-hidden="true"></div>
<div class="edge-tint edge-tint--bottom" aria-hidden="true"></div>:root {
--edge-top-color: #0e0e0e;
--edge-bottom-color: #0e0e0e;
background: var(--edge-top-color);
}
body {
background: var(--edge-bottom-color);
}
.edge-tint {
position: fixed;
z-index: 20;
right: 0;
left: 0;
pointer-events: none;
}
.edge-tint::before {
position: absolute;
inset: 0;
content: "";
background: var(--edge-color);
}
.edge-tint--top {
top: 0;
height: max(12px, env(safe-area-inset-top));
--edge-color: var(--edge-top-color);
}
.edge-tint--bottom {
bottom: 0;
height: max(12px, env(safe-area-inset-bottom));
--edge-color: var(--edge-bottom-color);
}If those colors change while scrolling, update the CSS variables and the document backgrounds together. Keep the strips outside transformed ancestors, and make sure their stacking order doesn't cover controls. viewport-fit=cover lets a page use the full display; still keep text and touch targets inside the safe area7.
theme-color can remain as a hint for browsers that use it, but don't treat it as the source of both edge colors. In Next.js, it can be declared in the viewport export:
export const viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0e0e0e" },
],
};When an existing scroll or theme state picks new edge colors, feed both strips and the document backgrounds from that one source:
function setEdgeColors(top: string, bottom: string) {
const root = document.documentElement;
root.style.setProperty("--edge-top-color", top);
root.style.setProperty("--edge-bottom-color", bottom);
}Where it stops
This is a solid-color extension. It works when each edge meets a solid color; it can't reproduce an image or perfectly match a background that varies from left to right. A gradient can still look smooth if the edge strips use the colors at the points where the gradient meets the top and bottom. Test Safari with the actual page, because its browser chrome and safe areas vary by device and version8.
Keep the bounce
You can turn the bounce off with overscroll-behavior: none5. I wouldn't, not for the whole page. The bounce is how people feel they've reached the end, and it's how every other app on their device behaves. Paint the canvas and the bounce stops being a problem.
Where it does help is inside the page, on things that scroll on their own, like a chat panel or a modal. There, overscroll-behavior: contain stops the scroll from leaking into the page behind.
Keep in mind
- There's a canvas behind every page, and it shows whenever the page bounces.
- A background on a wrapper
<div>never reaches it. body's background reaches it only whilehtmlhas none.- Put the background on
html, and change it there when the theme changes. - Use fixed edge strips when Safari's controls need separate top and bottom colors.
- Keep
theme-coloras a browser hint, and don't expect it to tint each edge independently. - Leave the bounce alone. Fix the color instead.
Resources
- Without a background, the canvas uses the browser's default, which follows
color-scheme. MDN, color-scheme. Read more - CSS Backgrounds and Borders, the canvas background and the HTML body element. Read more
- Gustavo Fior makes the same case in HTML background. Read more
- MDN, theme-color. Read more
- MDN, overscroll-behavior. Read more
- Josh Puckett's Seamless Backgrounds on Mobile Safari demonstrates fixed edge strips, separate edge colors, and a scroll-driven palette. The 12px strip in this example follows his reported Safari sampling tests. Read more
- Apple, Design for Safari 15 explains the safe-area environment values and
viewport-fit=cover. Read more - A WebKit clarification about Safari's top-bar tint describes color extension for fixed or sticky elements that meet the viewport edge. Read more
