← Back
[W] Writing

The 4px snap at the end of my collapsible

My collapsible had a bug that I think most people would never consciously see. They'd only feel that the animation wasn't quite right.

Every time it opened, the last frame jumped by 4px.

A reproduction of the bug, slowed down so you can see it. Open it without the clip layer and watch the very end. Then turn the clip layer on and try again.

Where the 4px came from

A nice collapsible does two things at once. The panel's height grows, and the content inside fades in while sliding down a little. In HextaUI, it starts 4px up.

To animate the height, you need to know the final height, so you measure the content. But I was measuring it while it was shifted up, so the panel worked out a height 4px too short.

So it animated to the wrong height, then corrected itself once the animation finished.

Before
  • Content starts 4px up
  • Panel measures the shifted box
  • Animates short
  • Snaps 4px
After
  • Clip layer holds the slide
  • Panel measures the real box
  • Animates to the right height
From the commit that fixed it: "A clip layer that lets go once the panel settles keeps the slide out of the measurement."

The fix was to add one more layer. It clips the sliding content, so the slide happens inside a box the panel doesn't measure. Once the panel has settled, the clip lets go.

The gap nobody animates

The other bug is easier to see, and it's probably in your app right now.

Put a collapsible in a flex column with a gap. Close it. The panel has no height, but it's still an item in the column, so it still gets its gap.

Close the panel and look at the number. The space is twice what it should be, until you collapse the gap as well.

So when it closes, HextaUI's panel works out which gap it's responsible for and removes it too. That takes more thought than I'd expected:

  • It only applies to flex columns and single-column grids, since a row's gap goes sideways.
  • flex-direction: column-reverse swaps which side the gap is on.
  • The first item has no gap before it, so it takes the one after instead.
  • If the panel is wrapped in elements with display: contents, its real parent is further up the tree, so the code has to walk up to find it.

That last one is real code, and it's the kind of case you only find when a wrapper breaks the fix.

function getLayoutItem(panel: HTMLElement) {
  let item = panel;
  let parent = panel.parentElement;

  while (parent && getComputedStyle(parent).display === "contents") {
    item = parent;
    parent = parent.parentElement;
  }

  return { item, parent };
}

Slow everything down

You can't see either bug at normal speed, but you can feel both. The only way I know to find these is to slow the animation right down, and watch the first and last frames.

Most animation problems are at the ends. The middle moves too quickly to judge, but the ends are where things come to rest, and that's what your eye catches.

Next