No. 08
This Site
I wanted a two-pixel bar to glide between nav items. It took five rewrites and a lesson in how browsers actually paint a page.
- View Transitions
- GSAP
- Next.js
- Tailwind
- Role
- Design + Dev
- Team
- Solo
- Timeframe
- 2025
A Bar That Should Just Slide
The section nav on this site has a sliding highlight on hover, and the page itself slides between sections using the View Transitions API. So when you move to a new section, the little accent bar marking the active item should glide from the old item to the new one — in the same motion as the page. A two-pixel sliver of color. Felt like a five-minute job.
It was not a five-minute job. It was five rewrites, two wrong mental models, and one moment of realizing I'd been fighting the browser the whole time.
The Snapshot Trap
Here's what a View Transition actually does. When you call startViewTransition, the browser freezes the current page into an image, swaps the DOM to the new page, freezes that into a second image, and animates between the two pictures. For the length of that animation, the real elements aren't what you're looking at — their snapshots are.
Chasing It With Timers
So I tried to outwait it. Subscribe to a 'transition finished' event, then move the bar once the DOM was live again. It still jumped. I added the position-tracking, restored the CSS transition by hand, sequenced the frames — and it still jumped.
The deeper problem was structural, and I'd been ignoring it. The sidebar isn't persistent. Each section is its own page rendering its own shell, so the entire nav unmounts and remounts on every navigation. There was no surviving bar to animate from — the new one mounted already sitting at its destination.
Stop Fighting the Platform
The fix was to stop animating the bar myself and let the View Transition do it. The same machinery that slides the page can slide the bar — I just had to tell it the bar was worth tracking.
- 01
Name it
Give the bar a view-transition-name, so the browser treats it as its own element to pair across the old and new page. Scoped to the slide only, so it doesn't detach during the card → case-study morph where the whole sidebar moves as one piece.
- 02
Place it in the DOM
Render the bar inside the active list item instead of positioning it with JS. Now its location is correct in both snapshots automatically — the old page captures it on the old item, the new page on the new one. Zero measurement.
- 03
Delete the rest
The timers, the module-level position cache, the completion subscription, the manual transform math — all of it came out. The browser interpolates between the two captured positions for free.
Two ways to move a bar across a navigation
Animate it yourself · jumps
- ·compute the new position in JS
- ·run a CSS transition on the element
- ·…but it fires during the View Transition
- ·the live element isn't painted — only its snapshot is
Name it, let the browser · glides
- ·give the bar a view-transition-name
- ·render it inside the active item
- ·browser pairs old + new snapshots
- ·interpolates position with the page slide
Same goal. The left fights the transition; the right rides it.
The bar lives inside the active list item and is named only during a slide. The browser pairs it across the old and new page and tweens its position — no JS positioning. (The demo above uses a plain CSS transition since it never remounts; the real nav rides the View Transition.)
// markup — bar is a child of the active <li>
<li>
{isActive && <span data-toc-bar aria-hidden />}
<Link …>{label}</Link>
</li>
/* css — named only during the page slide, so the
browser interpolates its position with the slide */
html[data-slide-active] [data-toc-bar] {
view-transition-name: toc-active-bar;
}Where It Landed
Now the page slides and the accent bar glides to the new active item in the same gesture — same easing, same 480ms, one continuous motion. The only way to confirm it was the way that counts: watching it in a real browser, after the console logs showed the snapshot quietly doing the work I'd been trying to do by hand.
- Rewrites to get it right
- 5
- JS positioning, final version
- 0
- Shared with the page slide
- 480ms