All notes

visibility: hidden vs display: none in animations

When content appears step by step, the page jumps with every step. Hiding the later steps with visibility: hidden instead of removing them fixes that.

Think of a list that fades in item by item, or a typing effect that adds one line after the other. Every time a new item appears, it pushes everything below it down, and the page jumps. It's a small thing, but you see it on every step.

Why the page jumps

The jump comes from how the new item gets on the page. In Angular, the obvious way is @if (or *ngIf), and in React it's {step >= 2 && …}. Both create the element only when the condition becomes true:

@if (step >= 2) {
  <li>Configure</li>
}

Until step 2, this item does not exist, so it takes up no space. When it is created, the list gets one line taller and moves everything below it. Switching the item from display: none to visible does the same, because an element with display: none has no size either. That includes [hidden] in Angular: it sets the hidden attribute, and the browser turns that into display: none.

Keep the element and hide it

So the fix is to put every step on the page from the start and only hide the ones that are not shown yet. The item stays in the template and switches a class instead:

<li [class.is-pending]="step < 2">Configure</li>

The class does nothing but hide the item:

.is-pending {
  visibility: hidden;
}

visibility: hidden hides the element but keeps its space. So the list has the height of all its items from the start, and each step only makes one of them visible. Nothing below it moves.

Keeping the element can also help the largest contentful paint (LCP), the moment the biggest element appears. The home page of this site has a small terminal that types a short intro: whoami, cat about.txt, ls, each with its output. The HTML already contains the finished intro, so it works without JavaScript. Once the page has loaded and the browser is idle, the terminal plays the typing animation once. That animation used to create its rows again, and a row created a few seconds after the page had loaded became the LCP. Keeping the rows and only hiding them with a class like is-pending took the LCP from 6.2 to 1.6 seconds, measured in a phone profile with a four times slower CPU and slow 4G (What the budgets caught has the details).

Why not opacity: 0

opacity: 0 keeps the space too, but it only makes the element transparent. The three ways to hide something differ in more than the look:

display: nonevisibility: hiddenopacity: 0
Takes up spacenoyesyes
Screen readershiddenhiddenstill read
Keyboard focusnonostill focusable
Clicksnonostill clickable

So an item hidden with opacity: 0 can still be focused, clicked and read out, and nobody sees it. visibility: hidden is the only one of the three that keeps the space and hides the element from everyone.

Opacity is still useful for a fade, and visibility can be part of the same transition. It cannot fade, so the browser treats it as a switch: as long as one end of the transition is visible, the element counts as visible. When the toast below is hidden, it fades out first and only then becomes hidden for keyboards and screen readers:

.toast {
  transition: opacity 200ms, visibility 200ms;
}

.toast.is-hidden {
  opacity: 0;
  visibility: hidden;
}

Every state needs its space

The fix only works if every state has its space, including the short ones in between. The terminal reserved space for every row of the finished intro, plus one line for the input field at the end. But after the last command, before the input field takes over, the animation shows an empty prompt with a blinking cursor for 120 ms, the way a real terminal waits for the next command. That prompt is not part of the finished intro, so it was added as a new row, next to the line already reserved for the input field. For those 120 ms the terminal was one line taller, and then it snapped back. Now the cursor is drawn in the input field's reserved line, so there is no extra row.

That's why the intro now has a test for every step, not just a few. The animation is a list of steps with a time each, for example "cat about.txt typed up to the fourth character". The test turns every step into rows and checks that each step has the same rows, only with is-pending on different ones, which is the rule from the list example above. In a real browser, a Playwright script (Playwright controls a headless browser from a test) reads the height of the terminal on every frame while the intro runs, and it finds one value.