|
| 1 | +# View Transitions |
| 2 | + |
| 3 | +The [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) is a native browser feature that animates DOM changes with smooth visual transitions. When combined with the turbo router, page navigations get an automatic cross-fade animation, making your multi-page site feel as polished as a single-page application. |
| 4 | + |
| 5 | +Instead of the usual instant swap between pages, the browser captures the old page state, applies the DOM update, captures the new state, and animates between the two. All of this happens natively, with no JavaScript animation libraries needed. |
| 6 | + |
| 7 | +## Enabling View Transitions |
| 8 | + |
| 9 | +To enable view transitions, include the following meta tag in the `<head>` section of your page or layout, alongside the turbo router meta tag. |
| 10 | + |
| 11 | +```html |
| 12 | +<head> |
| 13 | + <meta name="turbo-visit-control" content="enable" /> |
| 14 | + <meta name="turbo-view-transition" content="same-origin" /> |
| 15 | +</head> |
| 16 | +``` |
| 17 | + |
| 18 | +That's it. Every turbo-routed page navigation will now use a smooth cross-fade transition. Browsers that don't support the View Transitions API will fall back to the standard instant page swap with no errors or side effects. |
| 19 | + |
| 20 | +::: tip |
| 21 | +The `same-origin` value follows the web platform convention for view transitions. Only same-origin navigations will be animated, which is the standard and expected behavior. |
| 22 | +::: |
| 23 | + |
| 24 | +## Customizing the Transition |
| 25 | + |
| 26 | +The default transition is a cross-fade on the entire page. You can customize the animation duration, easing, or style using CSS pseudo-elements provided by the View Transitions API. |
| 27 | + |
| 28 | +### Adjusting Duration and Easing |
| 29 | + |
| 30 | +```css |
| 31 | +::view-transition-old(root), |
| 32 | +::view-transition-new(root) { |
| 33 | + animation-duration: 0.3s; |
| 34 | + animation-timing-function: ease-in-out; |
| 35 | +} |
| 36 | +``` |
| 37 | + |
| 38 | +### Slide Transition |
| 39 | + |
| 40 | +Replace the default cross-fade with a horizontal slide. |
| 41 | + |
| 42 | +```css |
| 43 | +@keyframes slide-out { |
| 44 | + to { transform: translateX(-100%); } |
| 45 | +} |
| 46 | + |
| 47 | +@keyframes slide-in { |
| 48 | + from { transform: translateX(100%); } |
| 49 | +} |
| 50 | + |
| 51 | +::view-transition-old(root) { |
| 52 | + animation: slide-out 0.3s ease-in-out; |
| 53 | +} |
| 54 | + |
| 55 | +::view-transition-new(root) { |
| 56 | + animation: slide-in 0.3s ease-in-out; |
| 57 | +} |
| 58 | +``` |
| 59 | + |
| 60 | +### Fade with Scale |
| 61 | + |
| 62 | +A subtle zoom effect that works well for content-heavy pages. |
| 63 | + |
| 64 | +```css |
| 65 | +::view-transition-old(root) { |
| 66 | + animation: 0.2s ease-in both fade-out, 0.3s ease-in both scale-down; |
| 67 | +} |
| 68 | + |
| 69 | +::view-transition-new(root) { |
| 70 | + animation: 0.3s ease-out 0.1s both fade-in, 0.3s ease-out 0.1s both scale-up; |
| 71 | +} |
| 72 | + |
| 73 | +@keyframes fade-out { to { opacity: 0; } } |
| 74 | +@keyframes fade-in { from { opacity: 0; } } |
| 75 | +@keyframes scale-down { to { transform: scale(0.95); } } |
| 76 | +@keyframes scale-up { from { transform: scale(0.95); } } |
| 77 | +``` |
| 78 | + |
| 79 | +## Directional Animations |
| 80 | + |
| 81 | +During navigation, the turbo router sets a `data-turbo-visit-direction` attribute on the `<html>` element with one of three values: |
| 82 | + |
| 83 | +Direction | When |
| 84 | +--------- | ---- |
| 85 | +**forward** | Clicking a link (advance action) |
| 86 | +**back** | Browser back/forward (restore action) |
| 87 | +**none** | Replace or same-page actions |
| 88 | + |
| 89 | +This lets you create directional slide animations that feel natural, sliding content in from the right when navigating forward and from the left when going back. |
| 90 | + |
| 91 | +```css |
| 92 | +@keyframes slide-from-right { |
| 93 | + from { transform: translateX(100%); } |
| 94 | +} |
| 95 | + |
| 96 | +@keyframes slide-to-left { |
| 97 | + to { transform: translateX(-100%); } |
| 98 | +} |
| 99 | + |
| 100 | +@keyframes slide-from-left { |
| 101 | + from { transform: translateX(-100%); } |
| 102 | +} |
| 103 | + |
| 104 | +@keyframes slide-to-right { |
| 105 | + to { transform: translateX(100%); } |
| 106 | +} |
| 107 | + |
| 108 | +/* Forward navigation */ |
| 109 | +html[data-turbo-visit-direction="forward"]::view-transition-old(root) { |
| 110 | + animation: slide-to-left 0.3s ease-in-out; |
| 111 | +} |
| 112 | + |
| 113 | +html[data-turbo-visit-direction="forward"]::view-transition-new(root) { |
| 114 | + animation: slide-from-right 0.3s ease-in-out; |
| 115 | +} |
| 116 | + |
| 117 | +/* Back navigation */ |
| 118 | +html[data-turbo-visit-direction="back"]::view-transition-old(root) { |
| 119 | + animation: slide-to-right 0.3s ease-in-out; |
| 120 | +} |
| 121 | + |
| 122 | +html[data-turbo-visit-direction="back"]::view-transition-new(root) { |
| 123 | + animation: slide-from-left 0.3s ease-in-out; |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +The attribute is removed after the visit completes, so it won't affect other CSS rules. |
| 128 | + |
| 129 | +## Animating Specific Elements |
| 130 | + |
| 131 | +The real power of view transitions comes from animating individual elements independently. By assigning a `view-transition-name` to an element, the browser will track it across page navigations and animate it separately from the rest of the page. |
| 132 | + |
| 133 | +### Hero Image Example |
| 134 | + |
| 135 | +On a product listing page: |
| 136 | + |
| 137 | +```html |
| 138 | +<img src="/products/widget.jpg" style="view-transition-name: hero-image;" /> |
| 139 | +``` |
| 140 | + |
| 141 | +On the product detail page, the same image with the same transition name: |
| 142 | + |
| 143 | +```html |
| 144 | +<img src="/products/widget.jpg" style="view-transition-name: hero-image;" /> |
| 145 | +``` |
| 146 | + |
| 147 | +The browser will smoothly morph the image from its position on the listing page to its position on the detail page, while cross-fading the rest of the content. This creates a natural sense of continuity between pages. |
| 148 | + |
| 149 | +### Page Header Example |
| 150 | + |
| 151 | +Keep the header stable while the content transitions beneath it. |
| 152 | + |
| 153 | +```css |
| 154 | +header { |
| 155 | + view-transition-name: main-header; |
| 156 | +} |
| 157 | + |
| 158 | +::view-transition-old(main-header), |
| 159 | +::view-transition-new(main-header) { |
| 160 | + animation: none; /* No animation, header stays in place */ |
| 161 | +} |
| 162 | +``` |
| 163 | + |
| 164 | +This gives the feel of only the main content area changing while the navigation remains fixed. |
| 165 | + |
| 166 | +### Dynamic Transition Names |
| 167 | + |
| 168 | +For lists of items where each card should animate individually, assign unique transition names dynamically. |
| 169 | + |
| 170 | +```html |
| 171 | +<div class="product-card" style="view-transition-name: product-42;"> |
| 172 | + <h3>Widget</h3> |
| 173 | + <img src="/products/widget-thumb.jpg" /> |
| 174 | +</div> |
| 175 | +``` |
| 176 | + |
| 177 | +::: warning |
| 178 | +Every `view-transition-name` must be unique on the page. If two elements share the same name, the transition will fail silently. When generating names dynamically, use a unique identifier like a database ID or slug. |
| 179 | +::: |
| 180 | + |
| 181 | +## Combining with Page Rendering Events |
| 182 | + |
| 183 | +View transitions work seamlessly with the existing [page rendering events](./javascript.md). You can still use `page:before-render` to pause rendering and run exit animations before the view transition begins. |
| 184 | + |
| 185 | +```js |
| 186 | +addEventListener('page:before-render', async (event) => { |
| 187 | + event.preventDefault(); |
| 188 | + |
| 189 | + // Run exit animation before the transition |
| 190 | + await animateOut(); |
| 191 | + |
| 192 | + // Resume rendering, the view transition wraps the DOM swap |
| 193 | + event.detail.resume(); |
| 194 | +}); |
| 195 | +``` |
| 196 | + |
| 197 | +The view transition wrapping occurs inside the `resume()` callback, so deferred renders receive the transition animation too. |
| 198 | + |
| 199 | +## Respecting User Preferences |
| 200 | + |
| 201 | +Some users prefer reduced motion for accessibility reasons. You should respect this preference by disabling or simplifying animations using the `prefers-reduced-motion` media query. |
| 202 | + |
| 203 | +```css |
| 204 | +@media (prefers-reduced-motion: reduce) { |
| 205 | + ::view-transition-old(root), |
| 206 | + ::view-transition-new(root) { |
| 207 | + animation-duration: 0s; |
| 208 | + } |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +## Browser Compatibility |
| 213 | + |
| 214 | +The View Transitions API is supported in Chrome 111+, Edge 111+, Safari 18+, and Firefox 126+. When a browser does not support the API, the turbo router will fall back to the standard instant page swap, so it is safe to enable in production without affecting older browsers. |
0 commit comments