Skip to content
Consultation

The 1px Sidebar Wobble That Ended My Chip Borders

Adityo Guni Waluyo

Swapping a chip border for a background nudged the whole row by 1px. The fix: an inset ring, because box-shadow never affects layout.

I was restyling the sidebar on my Next.js blog the other night. The boxed cards with rounded-2xl and backdrop-blur got pulled out, replaced by plain sections split by hairline rules. The latest list picked up mono index numbers 01 through 05. Then I flipped the tag chips from a full border to a soft background, hovered the first chip to check the active state, and the entire row of chips jumped a pixel to the right.

That tiny shift annoyed me more than it should. I had swapped the chip style between two states: inactive had border border-primary/40, active had bg-primary/10 and no border. Because a border is part of the CSS box model, dropping it shrank the box by exactly the border width, and the other chips in the flex row reflowed.

One pixel. Barely visible, but on a sidebar it reads as sloppy.

My first guess was that I had forgotten box-sizing. Wrong. In the standard box model, padding and border are added to width and height unless the element opts into box-sizing: border-box. But that only helps when the border exists in every state. The moment one state has no border at all, the rendered box gets smaller, border-box or not.

The inset ring won for one simple reason: box-shadow never takes part in layout.

Three Ways Out, and the One I Picked

Option one: keep a permanent transparent border in every state. The 1px stays reserved and hover just recolors it. The trade-off is an invisible box that still occupies layout space, and every chip carries a property it does not really use.

Option two: lean on box-sizing: border-box so the border counts inside the width. As above, that only works while the border is present in all states. Remove it once and the box shrinks anyway.

Option three: drop the border entirely and use an inset ring, which is an inset box-shadow. Tailwind ships it as a utility. It used to be ring-inset; Tailwind v4 renamed it to inset-ring. An inset shadow is drawn inside the padding box, above the background and below the content, per the MDN reference. And the W3C CSS Backgrounds and Borders spec is blunt about it: shadows never affect layout, and do not trigger scrolling or increase the size of the scrollable area. So when the chip changes state, the row stays still.

I went with option three, even though the transparent-border trick is the more common habit. An inset shadow is the cleanest way to draw an inner edge without touching the position of anything else.

In code, before and after:

// Before: border swap, the box wobbles 1px on state change
<button className={`px-3 py-1 rounded-full ${active ? 'bg-primary/10' : 'border border-primary/40'}`}>
  {tag}
</button>

// After: inset ring, layout stays still
<button className="px-3 py-1 rounded-full bg-primary/10 ring-1 ring-inset ring-primary/40">
  {tag}
</button>

The after version keeps a thin inset ring in every state. No border gets removed, so no box shrinks, and the neighbors stop reacting to hover.

The sidebar feels quieter now: hairline-separated sections, a numbered latest list, chips with soft backgrounds and inset rings. One note if you plan to upgrade: on Tailwind v4 the utility is inset-ring, not ring-inset. If you want to go deeper on layout shifts, web.dev explains that a shift happens when a visible element changes its start position between frames, and that a good CLS score is 0.1 or less. The rule I took from this restyle: when a state change swaps a border for nothing, the layout will notice. A shadow won't. On the component side, I've written before about Show More sections and their SEO trade-off.

Related articles