CSS view transitions in Laravel and Livewire
How Ball Quiz tiles grow into the game board with one CSS rule. The code, the timings, two demos you can play and the 13 tests it fixed.
- Visitors
- 11 Last 24 hours
- Revenue
- — Not split by project yet
The short version
- Ball Quiz pages now animate between each other with one CSS rule.
- Tap a game tile and it grows into the board. Leave, and it shrinks back.
- Live on ball-quiz.com as of 12 October. Code, timings and two demos below.
What I built on 10 October
Tap the blue Sprint tile in Ball Quiz and it grows into the game board. Press Leave and it shrinks back.
Ball Quiz is a free football badge quiz I'm building with Laravel and Livewire. The transition itself is plain CSS on top, and it was my favourite commit of the day. This site uses the same feature, view transitions, so I could compare the two.
Motion shows what changed
Both projects follow one rule: things move to show what changed.
Ball Quiz: arcade
- Keys drop onto a hard ledge when pressed
- A correct answer pops
- Points count up on the finish card
This site: quiet
- Pages crossfade when you move between them
- Nothing bounces
- Numbers never count up: a business stat shouldn't look like a slot machine
One rule turns page loads into transitions
Ball Quiz pages stay plain documents, and the browser animates the page loads between them.
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) {
@view-transition { navigation: none; }
}The browser snapshots the old page, loads the new one and crossfades by default. Browsers without support just load the page.
Leaving is fast (140ms), arriving slower (260ms), with an 8px rise that reads as "new page" without feeling like a slide show:
::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); } }The tile-to-board morph is a shared name
Give an element on each page the same view-transition-name and the browser morphs position and size between them.
A name must be unique on the page at snapshot time and the lobby shows several tiles, so each game gets its own:
.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; }With data-game-active, only a board with a round in progress claims the name, so a setup page can't steal the morph. The daily card and the album band work the same way.
Tile and board differ in shape, so the snapshots crop instead of squash:
::view-transition-old(*), ::view-transition-new(*) {
height: 100%; object-fit: cover; overflow: clip;
}Some things stay still
Deciding what shouldn't move was the fun part.
The header gets its own name and a zero-length animation, so it stays pinned. The active navigation 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; }Inside the board morph, the bottom-fixed answer sheet would have shrunk into a tile it never belonged to, so it gets its own name and slides down on its own track.
Leave needed JavaScript. From the lobby it goes back through history, which restores the scroll, so the board has a visible tile to shrink into. Before your first answer it skips "Are you sure?", since you haven't lost anything yet.
Feedback inside a round
Answers change state in place: a pop for correct, a short shake for wrong, before you read "Incorrect."
A lost heart pulses once.
@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%); } }The bar is an animation on transform: scaleX() with no JavaScript timer, so it can't drift from what the player sees. Key presses are the shortest motion on the site; any slower and the button seems to be thinking about it.

On this site
The same @view-transition rule makes every page change on aimantas.com a short crossfade.
Switching between light and dark uses a same-document transition, so the whole page fades to the new theme instead of flashing. If you want something louder, here's a variant I built as a demo: the new style wipes out in a circle from the button you press. It's a same-document transition plus a clip-path animation:
const r = button.getBoundingClientRect();
root.style.setProperty('--vx', `${r.left + r.width / 2}px`);
root.style.setProperty('--vy', `${r.top + r.height / 2}px`);
root.classList.add('vt-theme');
document.startViewTransition(() => applyStyle(button.dataset.style));html.vt-theme::view-transition-old(root) { animation: none; }
html.vt-theme::view-transition-new(root) { animation: wipe .7s cubic-bezier(.65, 0, .35, 1) both; }
@keyframes wipe {
from { clip-path: circle(0 at var(--vx) var(--vy)); }
to { clip-path: circle(150vmax at var(--vx) var(--vy)); }
}Things move to show what changed.
Press a style. The new one wipes out in a circle from the button you pressed.
Headlines and the progress bar
A morphing headline needs one extra rule, or it stretches mid-flight.
Snapshots scale like images. object-fit: none; object-position: left top stops that and pins the title top left.
The reading-progress bar uses animation-timeline: scroll(root) with no scroll listener, plus a small JavaScript fallback.
Reduced motion
Both projects listen to prefers-reduced-motion.
Ball Quiz switches view transitions off and drops transforms, confetti, the points pop and the two-second bar, but keeps the text saying the next question is coming. This site keeps the content and drops the movement.
What broke and what's live
On 12 October 2026, ball-quiz.com serves the transitions I committed on 10 October.
Live
The ball-quiz.com stylesheet has both @view-transition rules and names like bq-game-sprint, bq-header and bq-sheet. Each lobby game has its own data-game.
Broke
The previous Ball Quiz commit broke 13 frontend tests. This one fixed them.
Next
Check on real phones, then compare lobby rounds before and after one-tap tiles.
What I'd tell you to try
Start with the opt-in rule: the best cost-to-effect ratio I found.
- Add
@view-transition { navigation: auto; }and turn it off for reduced motion. - Give a shared element the same unique
view-transition-nameon both pages. In a list, name each item. - Decide what stays still. A pinned header makes the rest look deliberate.
- Make leaving faster than arriving.
- Use
object-fiton snapshot pseudo-elements when shapes or text would stretch.
Where it stands 12 October 2026
Done
- Committed 10 October 2026
- Served on ball-quiz.com (stylesheet checked 12 October)
Not done
- No screen recording of the live game yet
- No engagement data
Next
Record the morph on a real phone and compare rounds started before and after.
Receipts
- Ball Quiz transition commit: 10 October 2026, including the fix for 13 broken frontend effect tests.
- Live check: ball-quiz.com stylesheet fetched 12 October 2026, contains @view-transition and the bq-game-* names. Motion not tested in every browser.
If a fact changes, I add a dated correction here.
