A scroll-driven word-swap reveal rests on one assumption that rarely gets written down anywhere: the box holding the text is exactly as tall as the text it holds. The active word sits inside the box, the neighboring word waits outside it, and scroll progress moves both. As long as the box height and the text height are the same number, the illusion holds. Miss by a few pixels and what shows up is not a dramatic break, just a thin line visible at some positions and not others.
I ran into my version of it while working on a design system port whose target was a strict 1:1 visual and structural match with its reference. The homepage intro had a rotating word block driven by a scroll scrub, and the previous or next word kept bleeding through as a ghost strip at mid-scrub positions.
The person who kept catching that strip was on the client side, on desktop and on mobile alike. The reports came back short, the same two Indonesian phrases every time: "belum sama" and "masih beda", meaning not the same yet and still different. My working notes for this project already carried a rule written specifically for that kind of feedback: when those words come in, load both sites side by side and measure the delta with a DOM probe instead of studying screenshots.
Measure it, do not look at it
The rule is concrete. Open the site under construction and its reference in Playwright at the same viewport, then compare computed styles and rects rather than staring at captures and guessing. A sibling rule covers vendor source: read it deeply, make no assumptions from screenshots.
The probe is this small:
const wrap = document.querySelector(".swap_word");
const p = wrap.querySelector("p");
console.log(wrap.getBoundingClientRect().height); // 84
console.log(p.getBoundingClientRect().height); // 74.4Two numbers that should have matched did not. The parent was 84px while the visible <p> was only 74.4px. The difference is 84 - 74.4 = 9.6px, and the note I wrote at the time rounded it to a "10px slack strip". Measured against the text height itself, 9.6 / 74.4 = 0.129, so the box stood 12.9% taller than its own contents.
Why the strip showed through at mid-progress
The mechanism reads itself once those two numbers sit next to each other. The JS scrub translates the words by the text height, not by the height of the box:
const textHeight = p.getBoundingClientRect().height; // 74.4
hidden.style.transform = `translateY(-${textHeight}px)`;If the box is 84px and the travel is only 74.4px, 9.6px of it never leaves the visible area. What fills that leftover is the neighboring word, and at mid-progress positions in the scrub that is exactly what pushed through the screen as a ghost.
The slow part is that nothing in the JavaScript needed fixing. The travel distance was already correct with respect to the text, and the fix that eventually landed did not touch the scrub at all. What was wrong was the box holding the text.
The fix
The height of the box is what got corrected, so that it matched the text line box exactly. Three things at once: line-height: 1 on the inner <p>, zero margin on that same <p>, and the block wrappers for the visible and hidden words shrink-wrapped to their content.
.swap_word__visible p,
.swap_word__hidden p {
line-height: 1;
margin: 0;
}After that the parent came out at exactly 74.4px, the same as the text height. And once those two numbers agree, the hidden word's translateY(-74.4px) seals flush against the top edge of the parent. Zero gap, and zero gap means no bleed at any scrub state, not merely at the mid positions where it had been showing.
The two contributors to that 9.6px are named in the fix itself: leading from line-height, and the default margin on the <p>. Both were zeroed together, and the parent height dropped precisely to the text height.
The invariant I wrote down
One sentence, and this is the part I kept for next time: when building a word-swap reveal with translateY, the container height must exactly equal the line box height. Not approximately, not close enough. It comes down to three lines, line-height: 1 and margin: 0 on the inner <p>, plus wrappers that follow their own content height.
The fix went in as a single changelog entry at theme version 2.9.4, recorded as the removal of that ghost duplicate on the word block. By the time the project status note was written, on 11 June 2026, the theme was running at version 2.10.1, and all the visual and structural fixes from the 2.9.x series were still retained there. The visual state recorded at that same point lists Home at 768 and 393 as clean, with no ghost text left on the intro words.
Ever since, whenever I build a reveal that moves text by the height of the text itself, the first thing I measure is not the animation. It is whether those two heights are genuinely the same number.