High-DPI CSS Rendering: A clip-path Animation Bug
A Chromium clip-path bug observed at device pixel ratio 2: how CSS pixels, mask coordinates, and compositing interact, and how percentage geometry keeps playback consistent.
High-DPI rendering adds a boundary between CSS geometry and the pixels used to paint it. In a Chromium clip-path animation observed at device pixel ratio 2, that boundary exposed a rendering-path discrepancy: rounded corners became smaller during playback and returned to the expected shape when paused.
Understanding this class of bug requires three coordinate systems to agree: the element's local geometry, its transformed geometry, and the coordinates used to paint its clipping mask. A radius can be correct in one space and wrong in another.
Pixel and percentage lengths can describe the same curve, but the browser resolves them differently. Transforms add another scale, and composited animations can use a different rendering path from static styles. Understanding these boundaries is useful when building image reveals, expanding cards, shared-element transitions, or any interface that animates clip-path.
This case study explains those mechanics, derives the coordinate conversions, and implements a reusable rounded-clip serializer. The confirmed reproduction is at DPR 2; it does not establish that every high-DPI device or browser has the same bug.
Computed styles and composited rendering
An animation's computed CSS value is an input to rendering, not a measurement of the pixels already painted. Chromium has separate machinery for ordinary clipping and eligible composited clip animations.
In the ordinary clip implementation, PathBasedClipInternal obtains a geometric path from the computed shape and reference box. PathBasedClip declines that route when a composited clip animation is active.
The composited implementation instead extracts shapes from keyframes, constructs Skia paths, interpolates them at the animation's progress, and paints a mask through a paint worklet. These are different implementations of the same intended geometry:
Ordinary clip:
computed shape + reference box → geometric clip
Composited animation:
keyframes → shapes → paths → interpolated path → painted clip mask
These pinned sources establish the architecture. The existence of separate paths does not imply that every clip animation renders incorrectly; their outputs should agree for equivalent geometry.
A high-DPI Chromium edge case
This boundary became visible while testing SSGOI's Airbnb demo: a rounded clip looked correct when paused but less rounded during playback. An isolated reproduction confirmed the discrepancy on Chromium 152 on macOS at device pixel ratio 2, without needing the application or its transition engine.
To isolate the path difference, the fixture adds will-change: contents to one copy of the pixel-based animation. Chromium's clip fallback check explicitly excludes that case from composited clipping. The copy keeps playing, with the same scale and radius, but its corners become correct.
| Comparison | Observed corners |
|---|---|
| Static pixel clip | Correct |
| Paused pixel animation | Correct |
| Running pixel animation | Smaller |
| Running pixel animation with the fallback | Correct |
| Running percentage animation | Correct |
This explains why inspecting only paused frames missed the bug. It also separates the issue from the radius interpolation: changing the rendering route fixed the shape without changing the requested radius.
The rendering-path discrepancy is confirmed by this experiment. A mismatch in the conversion of absolute lengths into mask coordinates is consistent with the results, but the exact faulty Chromium operation and full affected version range remain unidentified. The coordinate model below explains the class of failure; it does not establish that a particular internal operation divides by DPR twice.
The coordinate-system problem
There are three quantities to keep separate:
- Local CSS geometry: the element's dimensions, insets, and radius before its transform.
- Visible CSS geometry: what remains after
scaleXandscaleYshrink or enlarge that element. - Mask raster coordinates: the coordinates used to paint the clipping mask, which must stay consistent with the box it clips.
Suppose a page is scaled to 0.4 and the visible corner should be 16px. Its local clip radius must be 40px:
local radius × element scale = visible radius
40px × 0.4 = 16px
Now consider a simplified rasterization model in which the mask uses two physical pixels per CSS pixel. Its box and its absolute radius both need the same conversion. A local 40px radius would become 80 mask pixels before the element's scale is applied.
If the box gets that conversion but the radius remains numerically 40, the radius becomes too small relative to the box. Mapping it back and applying the transform would produce:
Correct: 80 mask pixels ÷ 2 × 0.4 = 16 CSS pixels
Mismatch: 40 mask pixels ÷ 2 × 0.4 = 8 CSS pixels
That illustrates how a coordinate mismatch can produce the observed symptom; it is not a proven account of the exact internal factor that failed in the Chromium example. Changing the application's intended radius would conceal the symptom in one rendering state and make another state wrong.
Why percentages avoid that mismatch
An absolute 40px length and a relative 10% length describe the same horizontal radius on a 400px-wide reference box, but they carry different information into shape construction.
The absolute value needs the correct length conversion. The percentage can be resolved from the reference box already used by the shape. CSS Shapes defines percentage lengths against that reference box, and Chromium's inset path construction resolves corner sizes using the supplied box dimensions.
For any common scale factor k, the ratio stays attached to the box:
percentage = radius / boxWidth
resolved radius = percentage × (k × boxWidth)
= k × radius
That relationship is why normalizing the geometry is useful. In the Chromium reproduction, the relative representation stays consistent across both rendering paths.
Using percentages does not require multiplying by window.devicePixelRatio in application code. Pixel density belongs to the browser's rendering conversion. Hard-coding an extra factor would change the intended geometry and can break the correct paused state.
Step 1: compensate for the element's current scale
Before formatting CSS, calculate the radius that should be visible at the current animation progress. The following works with arbitrary start and end scales and radii:
// progress = 0 at the start; progress = 1 at the end.
const sx = startScaleX + (endScaleX - startScaleX) * progress;
const sy = startScaleY + (endScaleY - startScaleY) * progress;
const visibleRadius = Math.max(
0,
startRadius + (endRadius - startRadius) * progress,
);
const epsilon = 0.000001;
const rx = visibleRadius / Math.max(Math.abs(sx), epsilon);
const ry = visibleRadius / Math.max(Math.abs(sy), epsilon);
sx and sy are the interpolated scales for this frame. Dividing the desired visible radius by them gives the local radii that will become the requested radius after the transform. The epsilon prevents division by zero. An easing function or spring can supply progress; the coordinate conversion is the same.
The axes are independent. If the page is scaled by 0.5 horizontally and 0.25 vertically, a visible 16px circular corner needs local radii of 32px and 64px. Using one uncorrected local radius would produce an ellipse on screen.
Scale compensation determines the intended geometry. The next step determines how to express that geometry in CSS.
Step 2: normalize every clip length against the full element
The serializer below accepts local insets and scale-compensated radii. It has no framework or animation-library dependency:
type Insets = {
top: number;
right: number;
bottom: number;
left: number;
};
export function insetClipPath(
box: { width: number; height: number },
insets: Insets,
radii: readonly { x: number; y: number }[],
): string {
const percent = (value: number, size: number): string =>
`${size > 0 ? (value / size) * 100 : 0}%`;
const offsets = [
percent(insets.top, box.height),
percent(insets.right, box.width),
percent(insets.bottom, box.height),
percent(insets.left, box.width),
].join(" ");
const rx = radii.map((radius) => percent(radius.x, box.width)).join(" ");
const ry = radii.map((radius) => percent(radius.y, box.height)).join(" ");
return `inset(${offsets} round ${rx} / ${ry})`;
}
Each part has a specific job:
percentchanges the unit while preserving the ratio. The zero-size guard avoids emittingNaNor infinity.- Top and bottom insets use the element's height; left and right use its width. Their order remains CSS's top, right, bottom, left order.
- Horizontal radii use width and vertical radii use height. The
/separates those two radius lists. - A single radius pair describes uniform corners. Four pairs preserve top-left, top-right, bottom-right, and bottom-left separately, including corners removed by ancestor clipping.
The box is the full border box of the element receiving clip-path. For a page-level reveal, use the page element's dimensions. For an animated image or temporary clone, use that element's dimensions. The visible area left after applying the insets is not the reference box.
The same serializer is used in SSGOI's Zoom and Hero transitions.
Step 3: check the numbers on a tall page
Take a 400×900 page, expose its top 400×400 image, and use local radii of 40px on both axes:
insetClipPath(
{ width: 400, height: 900 },
{ top: 0, right: 0, bottom: 500, left: 0 },
[{ x: 40, y: 40 }],
);
The conversion is:
bottom inset: 500 / 900 × 100 = 55.5556%
horizontal radius: 40 / 400 × 100 = 10%
vertical radius: 40 / 900 × 100 = 4.4444%
Rounded for readability, the result is:
clip-path: inset(0% 0% 55.5556% 0% round 10% / 4.4444%);
At scale(0.4), both visible radii remain 16px. The different percentages do not make the corner elliptical: they multiply different reference dimensions to recover the same local 40px length.
Using the cropped image's 400px height as the vertical denominator would be wrong. It would emit 10% vertically, which the browser resolves against the full 900px page: a 90px local radius rather than 40px.
Reproduce the distinction, not just the endpoints
The browser fixture contains the static reference, running pixel clip, running percentage clip, and running pixel clip with the compositor fallback.
Run pnpm --filter @ssgoi/core dev, then open /tests/browser/rounded-clip.html on the printed Vite URL. The animations oscillate by only 0.002% around the same pose, so the renderer stays active without a visible geometry change. Toggle Pause/Resume and compare the corners on the display where the problem occurs.
Geometry tests can verify the percentage conversion and visible radius through intermediate frames, including unequal X/Y scales and tall elements. A live browser comparison checks whether the running renderer actually paints that geometry. will-change: contents is a useful diagnostic here; the percentage representation avoids requiring that fallback for the verified case.