DEV Community

Aimantas
Aimantas

Posted on Edited on Originally published at aimantas.com

CSS view transitions in Laravel and Livewire

Hi, I'm Aimantas. I build small web products, and the bit I've enjoyed most lately is tiny: tap a tile in Ball Quiz and it grows into the game board. Leave, and it shrinks back into the tile. The morph is plain CSS, on a Laravel and Livewire app.

It also stopped working on phones because of a two-pixel overflow, which is a story I'll get to. First, the fun part.

One rule turns page loads into transitions

Ball Quiz pages are ordinary documents. One at-rule asks the browser to animate between them, and a media query switches it off for people who ask for less motion:

@view-transition { navigation: auto; }

@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}
Enter fullscreen mode Exit fullscreen mode

By default the browser snapshots the old page, loads the new one and crossfades. Browsers without support just load the page, so nothing is lost. I tuned it so leaving is quick (140ms) and arriving is a bit slower (260ms) with an 8px rise, which reads as "new page" without feeling like a slideshow:

::view-transition-old(root) { animation: 140ms cubic-bezier(.4, 0, 1, 1) both bq-vt-page-out; }
::view-transition-new(root) { animation: 260ms cubic-bezier(.16, 1, .3, 1) both bq-vt-page-in; }
@keyframes bq-vt-page-out { to { opacity: 0; } }
@keyframes bq-vt-page-in { from { opacity: 0; transform: translateY(8px); } }
Enter fullscreen mode Exit fullscreen mode

Give the moving thing a name

The tile-to-board morph is one idea: put the same view-transition-name on an element on each page and the browser animates position and size between them. A name has to be unique on the page when the snapshot is taken, and the lobby shows lots of tiles, so every game gets its own. There are nine game modes, plus the daily and the sticker album, so eleven names. Here's Sprint:

.bq-play-tile[data-game="sprint"],
.bq-game-page[data-game="sprint"][data-game-active="true"] .desk-quiz {
  view-transition-name: bq-game-sprint;
}

::view-transition-group(bq-game-sprint) { animation-duration: 380ms; }
Enter fullscreen mode Exit fullscreen mode

The data-game-active check matters: only a board with a round actually running claims the name, so a setup screen can't steal the morph.

A tile and a board aren't the same shape, so I crop the snapshots instead of letting them squash:

::view-transition-old(*), ::view-transition-new(*) {
  height: 100%; object-fit: cover; overflow: clip;
}
Enter fullscreen mode Exit fullscreen mode

Decide what stays put

This was my favourite part. Motion only looks good when something holds still. The header gets its own name and a zero-length animation, so it stays pinned while the page changes under it. The active nav pill gets a name too, and slides to the next link over 300ms:

.desk-header { view-transition-name: bq-header; }
::view-transition-group(bq-header) { animation-duration: 0s; }
Enter fullscreen mode Exit fullscreen mode

The bottom answer sheet gets the same treatment. Without its own name it would shrink into a tile it never belonged to, so it leaves on its own track, sliding down in 160ms:

.bq-game-feedback { view-transition-name: bq-sheet; }
::view-transition-old(bq-sheet) { animation: 160ms cubic-bezier(.4, 0, 1, 1) both bq-vt-sheet-out; }
@keyframes bq-vt-sheet-out { to { transform: translateY(100%); } }
Enter fullscreen mode Exit fullscreen mode

When the next page is slow

The browser keeps the old page on screen until the new one is ready, which is lovely when that takes 50ms and awkward when it takes two seconds. So there's a small script on top. After 120ms it shows a progress bar along the top. After 350ms it drops in a skeleton shaped like the destination, and the tile morphs into that skeleton straight away. When the real page arrives, the cross-document transition morphs the skeleton into it. Trimmed down, the swap looks like this:

const swap = () => {
  morphFrom.style.viewTransitionName = 'none';
  skeleton.style.viewTransitionName = `bq-game-${morphFrom.dataset.game}`;
  main.prepend(skeleton);
};

document.startViewTransition(swap).ready.catch(() => {});
Enter fullscreen mode Exit fullscreen mode

The tile gives up its name and the skeleton takes it, so the name stays unique. The catch is there on purpose: the browser skips a transition when the next page arrives first, and that's fine.

The two-pixel bug

On 12 October the tile-to-board morph was working on desktop and quietly not happening on phones. No error on screen, just a hard cut.

The reason: a view transition gets skipped (the browser throws InvalidStateError) if the area being snapshotted changes between the two pages. On mobile, any horizontal overflow widens the layout viewport. When the board is revealed it slides in with translateX(20px), and that overflowed the page by 2px. Two pixels were enough for the browser to drop the whole transition.

The fix is one line:

main { overflow-x: clip; }
Enter fullscreen mode Exit fullscreen mode

I used clip rather than hidden on purpose. hidden turns main into a scroll container, which would break the sticky game bar. clip cuts the overflow and leaves everything else alone. I added a frontend test for it, because this is exactly the kind of bug that comes back quietly.

Small feedback inside a round

The same "motion shows what changed" idea carries into the game. A new question slides in 20px over 280ms, and the answer sheet slides up over 260ms. Both only run when the visitor hasn't asked for reduced motion:

@media (prefers-reduced-motion: no-preference) {
  .desk-quiz-play   { animation: bq-question-in 280ms cubic-bezier(.16, 1, .3, 1) backwards; }
  .bq-game-feedback { animation: bq-sheet-up 260ms cubic-bezier(.16, 1, .3, 1) backwards; }
}
@keyframes bq-question-in { from { opacity: 0; transform: translateX(20px); } }
@keyframes bq-sheet-up    { from { transform: translateY(100%); } }
Enter fullscreen mode Exit fullscreen mode

I use the same @view-transition rule on aimantas.com too, where it's a quiet crossfade, plus a scroll-driven reading bar with animation-timeline: scroll(root) and no scroll listener. The original post there has live demos you can tap.

If you want to try it

  1. Add @view-transition { navigation: auto; } and switch it off for prefers-reduced-motion. Best effect for the least code.
  2. Give an element on both pages the same unique view-transition-name. In a list, name each item.
  3. Decide what should stay still. A pinned header makes everything else look deliberate.
  4. Make leaving faster than arriving.
  5. Crop snapshots with object-fit when shapes differ, and check your phone for a stray pixel of overflow.

What I haven't checked

I've read the stylesheet and script and confirmed the same rules are on the live ball-quiz.com stylesheet (2 November 2026). I haven't tested the motion in every browser, and I haven't measured whether it changes how many rounds people play. It feels better to me. Whether it is better is a separate question, and I'd rather find that out than assume it.

If you try this in your own app, I'd like to hear what you ended up naming your first transition. I write up what I'm building, and what breaks, at aimantas.com.

Top comments (0)