← back to posts

$ posts/css-hover-ghost-trails.md

---
date:  2026-09-29
tag:   Technical
read:  4 min
---

CSS hover animations leaving ghost trails on Windows: animate top/left, not transform

If a CSS transform animation leaves stale, discolored streaks on screen, but screenshots taken through the browser look perfectly clean, the bug probably isn’t in your CSS at all. It’s an OS-level compositing artifact: the browser promoted your animated element to its own GPU layer, and on a fractionally scaled Windows display the operating system’s compositor failed to clean up behind that layer. The fix that ended it for me was animating top and left instead of transform, so the layer never exists.

The symptom

Post rows on this site lift up and to the right on hover, turning amber. On my Windows laptop, raking the cursor across the list left thin amber bars standing at the rows’ right edges: slices of the lifted tile’s color, still there after the tile had settled back. They tracked whichever row I had last hovered, and they were pixel-identical every time I reproduced them. Deterministic geometry was the first real clue. Random repaint damage doesn’t land in the same place twice.

The bug, recaptured after the fix by re-injecting the old CSS: hover on, hover off, and an amber bar stays standing in dead space beside the settled row. Recorded from the OS compositor's output, the only place it shows up.

Locating the layer the bug lives on

The debugging that cracked it was not reading CSS. It was figuring out which stage of the rendering pipeline owned the bad pixels.

First, a screen recording, chopped into frames with ffmpeg and cropped down to the artifact. Zoomed in, the bars resolved into 1-2px columns sitting exactly on the hovered row’s border, exactly the row’s height: the trailing edge column of the retreating tile, left behind. My display runs at Windows’ 150% scaling, where element edges land between device pixels, and a fractional edge is exactly the kind of thing a compositor rounds away when deciding what to redraw.

Second, and decisive: I scripted the same cursor-raking with Playwright driving my real Chrome, forced to the same fractional scale factors, and captured through the browser’s own screenshot API. Clean. Every time, even with the buggy CSS. Meanwhile OS screenshots of the same moments showed the bars plainly. Same machine, same GPU, same pixels on glass, two different answers depending on who you ask.

That disagreement is the whole diagnosis. A page renders through a pipeline, and each capture method reads from a different point in it:

page paint (your CSS) ──▶ browser compositor ──▶ OS compositor ──▶ screen
                           ▲ browser screenshots      ▲ OS screenshots and
                             read here                  your eyes read here

The browser’s output was correct. The bars were being introduced after it, where Windows’ DirectComposition blends the browser’s GPU surfaces onto the screen. And that explains the second clue I’d collected along the way: the usual in-page remedies (compositor hints like will-change and contain: paint) changed nothing, because no amount of CSS reaches a pipeline stage below the browser.

Why transform, specifically

A transform transition is special: browsers promote the element to its own composited layer, a separate GPU surface that can move without repainting the page. That’s normally the whole point, and it’s why “prefer transform over top/left” is standard performance advice. But a separate surface is also a separate thing for the OS compositor to place, blend, and clean up after, and on fractionally scaled displays its retreating edge is what was being left behind. While reproducing it on demand for the clip above, I narrowed the trigger further: a smooth, scripted mouse exit never left trails, but a human-style exit that wobbles across the tile’s edge, flickering the hover and promoting and demoting the surface in quick succession, left them reliably. The optimization was the bug, and churning it was the trigger.

The fix

Remove the layer. The tile now lifts by animating top/left on a relatively positioned element:

.tile {
  position: relative;
  top: 0;
  left: 0;
  transition: top 0.3s, left 0.3s, background-color 0.25s;
}
.row:hover .tile {
  top: -8px;
  left: 8px;
}

Layout properties animate in normal paint, on the main thread. No GPU surface is created, so there is nothing for the OS to mis-blend and nothing to leave trails. The performance tradeoff is real but tiny here: one small element relaying out for 0.3 seconds on hover. “Prefer transform” is advice about compositor offloading, and in this bug the offloading itself was the failure. The guidance inverts.

The transferable lesson

When an artifact is visible on screen but absent from the browser’s own captures, stop debugging your CSS and start bisecting the pipeline. Compare capture methods: browser screenshot vs OS screenshot vs a phone pointed at the display. Each one reads from a different stage, and the first stage where the artifact appears is the stage that owns the bug. Everything upstream of it is innocent, no matter how suspicious your hover styles look.

EOF · back to posts