The Font Swap That Moved the Button Mid Click
Swap fixed the invisible text and introduced the layout shift, and the shift landed on the checkout button at the moment of the click, so the user tapped…
The invisible text incident was fixed by switching the font loading to swap, and the next incident arrived a week later, from the metric that swap feeds: layout shift. A user tapped the buy button at the instant the web font swapped in, the button moved under the pointer, and the tap landed on the element that now occupied the button's old position, which was the cookie banner's reject link, and the order of events, tap then shift then wrong target, is the layout shift's entire harm, concentrated in one click.
This is the anatomy of the swap trade, the invisible text versus the shifted layout, and of the third option, the metric matched fallback, that ends the trade instead of picking a side.
This was a storefront with a display font on its buttons and headings, font display swap, and a CLS p75 that crossed the threshold the week the swap shipped, which is the trade made visible as a regression in the other metric.
The trade, precisely
A web font arrives late, because it is fetched, and the fetch loses to the critical path, so the text that uses it must render before the font or not at all, and the browser offers the two policies. Block renders nothing until the font arrives, which is the invisible text, the first incident. Swap renders immediately in the fallback font and re renders when the web font lands, which is the shift, the second incident, because the fallback and the web font have different metrics, different widths, heights and baselines, so the same text occupies different boxes, and the re render moves everything downstream, which is the shift, and the shift that lands during an interaction moves the target mid gesture, which is the tap that hit the banner.
The two incidents are one defect, the font arriving after the text, with two symptoms chosen by the policy, and the policy pick moves the symptom, not the defect, which is why fixing one incident produced the other.
Why the shift lands on the button
The shift's harm is proportional to what moves and when, and the button is the worst case for both: it is small, so a baseline or width change moves it by its own height, and it is the target of the tap, so the move converts intent into a wrong action, which is a data loss of intent, the most expensive shift there is. The heading that shifts harms nobody, because nobody aims at the heading, and the shift metric weighs them equally, which is the metric's blindness: CLS measures pixels moved, not intent destroyed, and the button's few pixels carry more harm than the banner's many.
This is the tail blindness again, the p75 of shift is the user who shifted during the interaction window, and the window is exactly the swap's arrival, so the shift and the interaction are correlated in time, which is what makes the tap land wrong, and the correlation is invisible in the aggregate number.
The third option: match the metrics
The trade ends when the fallback and the web font occupy the same box, because then the swap re renders the glyphs without moving the box, and the shift is zero while the text is never invisible, which is both incidents closed at once. The matching is done by choosing a fallback whose metrics approximate the web font and adjusting its size and ascent with the override descriptors, so the fallback's advance width and line height equal the web font's, and the swap becomes a repaint, not a reflow.
The adjustment is a calibration, measured once per font pair, and it is stable, because both fonts' metrics are constants, so the calibrated fallback is a fixed asset, which is the property a fix needs to stop being an incident generator.
The supporting discipline
The calibration is the core, and the surrounding discipline keeps the shift budget spent only where it cannot harm.
Reserve the space. The text's box is sized by the fallback from the first paint, and the calibration keeps it sized identically after the swap, so the reservation is the calibration's structural twin, and images and embeds get the same reservation, which is the CLS discipline from the announcement bar that cost 0.31 CLS, generalised to type.
Load the display font early or not at all. A font that arrives in the first paint window swaps before the interaction window, so its shift, calibrated or not, lands while the user is still reading the hero, not tapping the button, and the early load is the preload with the crossorigin attribute, while a font that cannot earn its early slot is a font that should not be on the button, which is the budget deciding the aesthetics.
Keep the interactive surface in the system face. The button, the input, the tap target render in the system font, which is present at first paint by definition, so the interactive surface never swaps, and the shift is structurally impossible where it matters, and the display font lives on the headings, where the shift is pixels, not intent.
The test that catches it
The regression test renders the page with the font blocked and with it loaded, and asserts the button's box is identical in both, which is the calibration made executable, and a second test taps at the moment of the swap in a synthetic timeline and asserts the hit target is the button, which is the intent preserved. The tests fail on the uncalibrated swap in milliseconds, which is the second incident caught before it ships, which is the first incident's lesson applied to the fix.
The rule I keep
Swap trades the invisible text for the layout shift, and the shift that lands on a tap target during the swap window converts intent into a wrong action, so the trade is ended, not chosen, by calibrating the fallback to the web font's metrics, reserving the box, loading the display font early or demoting it, and keeping the interactive surface in the system face.
The button that moved mid click was not a styling bug. It was a timing bug with a visual symptom, the font arriving after the intent, and the calibration is the fix because it removes the move while keeping the text, which is the only resolution of a trade that attacks the defect instead of picking a symptom.
Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud