Components
Page transition New · experimental
A thick band of goo, with see-through bubbles rising through it, sweeps across the page when you navigate. It's the seam between the two pages, the new one behind it and the old one ahead of it.
Customize
A thick band of goo pours in from one edge, slows a little over the middle of the screen, then flows on and out the far edge. Both of its edges are low, wide mounds that are different every time: the front leads with a little momentum and the trailing edge stretches back like poured goo.
The band is the seam between the two pages. Everything it has already passed shows the new page, and everything ahead of it still shows the old one, so you never see the page swap. Pouring down, the new page is above the band and the old page is below it.
How the split works
When you navigate, the old page is held as a snapshot first. Then the route changes and the new page renders underneath it. Once the new page is ready, the band sweeps across, and the snapshot is cut away along the band's front edge on every frame. The cut follows the same curve as the goo, a few pixels inside it, so the band always hides the seam. A bubble near the front can show a little of both pages.
- View Transitions (Chrome and Edge 111+, Safari 18+, Firefox 144+): the snapshot is
::view-transition-old(root), clipped every frame, and the new page is the live::view-transition-new(root). The band gets its ownview-transition-name(goop-page-transition) once the snapshot is taken, so it stays live and is painted on top. The browser pauses rendering while the new page loads, so the sweep starts once it's ready. - Fallback (browsers without View Transitions): the visible part of the old page is copied into a fixed overlay under the band and clipped the same way. Canvases are copied too, and a WebGL canvas that comes out blank is filled with the page background. Here the band pours in right away and waits near the top edge, with nothing new showing yet, until the new page is ready.
- If neither works, the page swaps while the band is over the middle of the screen, as it does with
split: false.
split: false turns this off: the route changes while the band is over the middle, the new page fades in behind it (about 200 ms), and the band keeps creeping while it waits for a slow page.
A few bubbles of mixed sizes rise through the goo, against the way it pours. They're real holes, so you see the page through them. They wobble, squash a little as they speed up, and some merge. A bubble that reaches an edge pops into it: the edge dips where it popped, then relaxes. They're re-rolled every time and nothing is left behind.
Nuxt
Turn it on in the module options. It's off by default.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@goopui/nuxt'],
goop: {
pageTransition: true,
// or: pageTransition: { direction: 'up', duration: 500 },
},
})
Before each route change, once the next page's components have loaded, the old page is held. The band sweeps on page:finish, once the new page has rendered. If that never fires, it goes after timeout ms (default 2000). Hash and query changes don't transition, and neither does the first load or a swipe back the browser has already animated. The live handle is useNuxtApp().$goopPageTransition.
The module runs its own view transition, so it turns Nuxt's experimental.viewTransition off (with a warning) unless you set split: false. Page transitions (app.pageTransition) still run, but with the split you don't need them: leave them off.
Vue
With vue-router, pass the router to GoopPageTransition once, near the root:
<script setup lang="ts">
import { GoopPageTransition } from '@goopui/vue'
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<GoopPageTransition :router="router" direction="down" />
<RouterView />
</template>
The router helper holds the old page in beforeResolve and starts the sweep right after the new page renders.
Or drive it yourself with the composable. cover() resolves once the old page is held (change the page then), and reveal() means the new page is ready. It resolves once the band is gone. With a view transition, rendering is paused between the two calls, so don't wait for an animation frame in between:
import { useGoopPageTransition } from '@goopui/vue'
const goo = useGoopPageTransition({ direction: 'left' })
async function swap() {
await goo.cover()
await renderTheNextPage()
await goo.reveal()
}
Core
Without a framework, use createPageTransition from @goopui/core. It returns { cover, reveal, phase, seam, update, destroy }. seam says how the next transition splits the pages: 'view-transition', 'clone' or null.
import { createPageTransition } from '@goopui/core'
const goo = createPageTransition({ direction: 'down' })
await goo.cover()
// swap the page
await goo.reveal()
Colors
The goo reads --goop-page-transition, which falls back to --primary. Set --goop-page-transition-accent to give the edges and the bubbles a thin colored rim, and --goop-page-transition-highlight for a soft lighter glow through the middle of the band. This site uses Slime with a deep green rim:
:root {
--goop-page-transition: #1fa64a;
--goop-page-transition-accent: oklch(0.43 0.12 150);
--goop-page-transition-highlight: oklch(0.73 0.18 146);
}
The color, accent and highlight options override them. They take any CSS color or var(), and accent: false turns the lip off.
Options
| Prop | Type | Default | Notes |
|---|---|---|---|
direction | 'down' | 'up' | 'left' | 'right' | 'down' | Which way the goo travels. |
duration | number | 1150 | Total cover and reveal time in ms ('subtle' is a little quicker, 'wild' a little slower). The hold in between lasts until the page is ready. |
coverDuration, revealDuration | number | Set each part separately (ms). | |
split | boolean | true | The band is the seam between the old page and the new one. false swaps the page while the band is over the middle and fades the new page in. Only with the band, and never under reduced motion. |
band | boolean | number | true | A band that sweeps through. false gives a full-screen wall that covers, then drains; a number from 0.2 to 1 sets the band's thickness as a share of the screen. |
bubbles | boolean | number | true | See-through bubbles rising through the goo. false turns them off, and a number from 0 to 2 scales how many. |
bulge | boolean | number | true | Mounds on the front. false gives a flat front, and a number from 0 to 2 scales them. |
wobble | boolean | number | true | The mounds and bubbles wobble and drift as they move. |
lag | boolean | number | true | The trailing edge stretches back as it moves. |
preset | 'subtle' | 'playful' | 'wild' | 'playful' | The overall feel. |
speed | number | 1 | Speed multiplier. |
color, accent, highlight | string | false | CSS variables | Wall, lip and glow colours. See Colors. |
zIndex | number | 2147483000 | The overlay's z-index. |
goo | boolean | true | Vue only: false turns it off (core: enabled: false). |
In Vue they're props on GoopPageTransition and options to useGoopPageTransition, and goo: false turns it off. In core, use enabled: false for the same thing.
The Nuxt module also takes fade, the element to fade in as the new page swaps in with split: false (default '#__nuxt', or false for none), and fadeDuration (default 200 ms). With vue-router, goopRouterTransition takes the same fade option. The split never fades anything.
Performance
The overlay is one fixed, full-screen SVG with one filled path, plus one more for the lip. Each frame rewrites that path from about 60 points for the band, plus 20 for each live bubble, and sets the old page's clip path from the same 30 front points. The bubbles are holes cut in the same path (fill-rule: evenodd), not extra layers. There's no blur filter, canvas, or WebGL, so it's cheap on phones. Being position: fixed, it never changes the page's scroll size, and nothing is in the DOM until the first transition.
Accessibility
The overlay is aria-hidden. It blocks clicks until the route has changed, so a double click can't start two navigations, and lets them through while it flows away. Focus and scroll are left to the router.
Under reduced motion there's no goo, no bubbles and no split. The screen fades to the goo color and back in about 150 ms each way. See Accessibility & reduced motion.