Hide an element on just one step of a client-side checkout with :has()
An A/B test that had to remove the progress bar on the final checkout step only — on a checkout that re-renders itself. Why the JS approach kept losing, and the one reactive CSS rule that won.
Working on an A/B test, I had to remove the little step indicator — the Cart → Delivery → Payment progress bar — but only on the final step. The hypothesis: once you’re on the payment step there’s nothing left to navigate, so the bar is just clutter.
Easy, I thought. Grab it and hide it:
document.querySelector(".checkout-steps").style.display = "none";
It worked for about a second. This was a client-side checkout — the kind that swaps steps in place with Alpine / Livewire / React rather than loading a new page — and the moment it re-rendered the step strip, my inline display: none was gone. The component rebuilt the node and my style went with it.
So I reached for an observer to re-apply it. And that’s when the second problem showed up: the bar lives on every step, but I only want it gone on the last one. A one-shot hide fires on whichever step happens to render first — usually the wrong one — and then I’m fighting the framework to keep re-deciding “are we on the final step yet?” on every re-render. That’s a lot of JavaScript to express something that is, fundamentally, a styling rule.
Let CSS do the deciding
Here’s the thing I kept forgetting: :has() is reactive. It isn’t evaluated once — it re-matches as the DOM changes. So instead of a script that watches the checkout and toggles a property, I can write a single rule that’s simply true on the final step and false everywhere else.
A reactive checkout already tells you which step is active — it marks the current one (aria-current, an .active class, whatever your framework uses). So match on that:
/* Hide the step strip only while the last step is the active one. */
.checkout-steps:has(.step:last-child[aria-current="step"]) {
display: none;
}
Inject that once and walk away. On Cart and Delivery the last step isn’t current, so the rule doesn’t match and the bar shows. Step onto Payment, the framework moves the marker, :has() re-matches, the bar disappears. Navigate back, it returns. No observer, no re-apply loop, and crucially nothing to wipe — it’s a stylesheet rule, not a property sitting on a node the component is about to rebuild.
For an A/B test that matters twice over: the rule only exists in the variant (Control never gets the stylesheet), and it’s immune to the re-render churn that was eating my JavaScript version.
Mind the gap — if there even is one
One thing to watch, though on a well-built page it may never bite you. Hiding the bar with display: none pulls it out of flow entirely, so whatever spacing it was contributing leaves with it. If that spacing lives on a gap or on the content around the bar rather than on the bar itself — which, honestly, a properly structured checkout should — there’s nothing to collapse and the layout just closes up cleanly. Most of the time you’re already done.
But if yours does jump, the fix is the same trick again — compensate, but only under the same condition, so the earlier steps (where the bar still shows) keep their original spacing:
.checkout-steps:has(.step:last-child[aria-current="step"]) {
display: none;
}
.checkout-container:has(.step:last-child[aria-current="step"]) {
margin-block-start: 1rem;
}
Both rules share one predicate — “the last step is active” — so the hide and the spacing fix can never drift apart. If you ever change which step counts as “final”, you change it in one place.
:has() has been in every major browser since late 2023, so for most production traffic this is just CSS now. When you catch yourself writing a MutationObserver to toggle a class based on the state of another element — stop, and check whether a parent :has() already knows the answer.
Happy styling 🙂