Every time your UI changes state (a photo opens, a panel slides in, a filter is applied), the user’s brain has to re-orient. A jarring instant swap forces that work on them. A well-designed transition does it for them: It communicates what moved, where it went, and why the current state relates to the previous one.
For years, building these transitions in the browser meant one of three things:
- Reaching for a JavaScript animation library (GSAP, Framer Motion, Anime.js).
- Writing intricate CSS that only worked if you knew the before and after states at stylesheet time.
- Accepting that smooth cross-page transitions were simply not possible on the web.
The View Transition API changes all three. It lets you wrap any DOM mutation in document.startViewTransition() and the browser handles the rest. It snapshots the old state, renders the new state, and animates between them using hardware-accelerated CSS transitions by default, or whatever custom animation you specify.
Combine that with Cloudinary, which serves responsive images, applies real-time transformations, and composites multiple images into one, and you have a foundation for a gallery experience that rivals native apps.
- Live Demo: https://view-transition-gallery.vercel.app/
- GitHub: https://github.com/musebe/view-transition-gallery
-
Responsive photo grid served with Cloudinary
srcsets. - Lightbox that opens with a circle expanding from the thumbnail’s exact position.
- Directional slide navigation (arrow keys or buttons) with CSS keyframes.
- Live Effects Lab with 12 Cloudinary transformations applied with a single cross-fade.
- Mosaic Builder that allowed you to drag and drop four photos to get one composited Cloudinary URL.
- Game layer with scoring, combos, achievements, and a toast queue.
Before you start, you’ll need:
-
Node.js 18+ and
npm. - A Cloudinary account (the free tier is fine; the demo uses the public
democloud so you can run it immediately without one). - Basic familiarity with TypeScript and the DOM.
The scaffold in 60 seconds:
git clone https://github.com/musebe/view-transition-gallery.git
cd view-transition-gallery
npm install
npm run dev
Code language: PHP (php)
Open http://localhost:5173. You should see the gallery immediately. No API keys required.
Your tech stack at a glance:
| Tool | Why |
|---|---|
| Vite 5 | Instant dev server + zero-config TypeScript |
| TypeScript 5 (strict) | Catch bugs at compile time, not in production |
| Vanilla JS | No framework overhead, just Web APIs |
| Cloudinary demo cloud | Real CDN delivery without account setup |
The project file map:
src/
├── main.ts ← bootstrap: wires everything together
├── types.ts ← shared interfaces (GalleryImage, Effect, etc.)
├── cloudinary.ts ← all URL builders (thumbnails, effects, mosaics)
├── transitions.ts ← View Transition helpers (circle expand/collapse)
├── gallery.ts ← grid rendering + lightbox logic
├── effects.ts ← Effects Lab: 12 live Cloudinary transforms
├── mosaic.ts ← drag-and-drop mosaic builder
├── game.ts ← scoring, combos, achievements, HUD
├── welcome.ts ← first-visit dialog
└── styles/
├── gallery.css ← grid, lightbox, effects panel
├── transitions.css ← all ::view-transition-* rules
└── game.css ← HUD, toasts, mosaic overlay
Browse the full source on GitHub: https://github.com/musebe/view-transition-gallery
Before starting on an animation, you’ll need images. Cloudinary’s delivery URLs carry the entire transformation pipeline inside them with no server code and no build step.
https://res.cloudinary.com/{cloud_name}/image/upload/{transformations}/{public_id}
Every segment between upload/ and the public ID is a transformation. Multiple transforms chain with /.
For thumbnails, you’ll want a consistent 4:3 aspect ratio regardless of the source image’s dimensions. c_fill crops to fit; g_auto uses Cloudinary’s content-aware gravity to keep the interesting part of the photo in frame:
c_fill,g_auto,w_400,ar_4:3
A portrait photo of a person will keep their face centred. A landscape shot will preserve the horizon. No manual focal points needed.
Rather than hardcoding URLs, you’ll generate the full srcset programmatically:
// src/cloudinary.ts (excerpt)
const THUMBNAIL_WIDTHS = [200, 400, 600, 800] as const;
export function buildThumbnailSrcset(config: CloudinaryConfig, publicId: string): ResponsiveImage {
const srcset = THUMBNAIL_WIDTHS
.map((w) => `${buildThumbnailUrl(config, publicId, w)}${w}w`)
.join(', ');
return {
src: buildThumbnailUrl(config, publicId, 400),
srcset,
sizes: '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw',
};
}
Code language: JavaScript (javascript)
The browser picks the right width for the current viewport and pixel density. On a 2x Retina screen at 33vw, it requests the 800w variant. On a budget phone at 50vw, it requests 400w.
View the full
cloudinary.tssource: https://github.com/musebe/view-transition-gallery/blob/main/src/cloudinary.ts
This is the most common Cloudinary mistake. f_auto (auto format: WebP, AVIF) and q_auto (auto quality) must be separate chained segments, not comma-joined:
# Correct: f_auto and q_auto are separate segments
c_fill,g_auto,w_400,ar_4:3/f_auto/q_auto/samples/landscapes/beach-boat
# Wrong: f_auto,q_auto treated as one segment, q_auto silently ignored
c_fill,g_auto,w_400,ar_4:3/f_auto,q_auto/samples/landscapes/beach-boat
The reason: f_auto and q_auto are delivery instructions that must be applied after content transforms. Chaining them separately guarantees Cloudinary applies them in the correct order.
When you call it, the browser:
- Captures a screenshot of the current page state (the “old” snapshot).
- Runs your callback, so the DOM update happens synchronously here.
- Captures the new page state (the “new” live render).
- Animates between old and new using pseudo-element overlays.
The default animation is a simple cross-fade. The power comes from customising those pseudo-elements.
::view-transition ← the full-page overlay (z-index: max)
└─ ::view-transition-group(root) ← wraps the root animation group
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root) ← frozen screenshot of old state
└─ ::view-transition-new(root) ← live render of new state
When you assign view-transition-name: my-name to an element, it gets its own group, separate from root, so it can animate independently.
// src/transitions.ts (excerpt)
export function isViewTransitionSupported(): boolean {
return typeof document.startViewTransition === 'function';
}
export function startTransition(updateFn: () => void): ViewTransition | null {
if (!isViewTransitionSupported()) {
updateFn(); // instant fallback
return null;
}
return document.startViewTransition(updateFn);
}
Code language: JavaScript (javascript)
Every transition call in this project goes through startTransition. If the browser doesn’t support the API, the DOM update still happens, just without animation. Pure progressive enhancement.
View the full
transitions.tssource: https://github.com/musebe/view-transition-gallery/blob/main/src/transitions.ts
startViewTransition() returns a ViewTransition object with two key promises:
| Promise | Resolves When |
|---|---|
.ready |
Both snapshots are captured and animation is about to start |
.finished |
Animation is fully complete |
.ready is where you inject JavaScript-driven animations (like the circle clip-path). .finished is where you clean up: Remove view-transition-name assignments, load the full-res image, and so on.
This is the signature effect of the project. When you click a thumbnail, the lightbox opens as a circle expanding from the thumbnail’s centre until it fills the screen. Closing collapses it back.
So why not the default morph?
The default view-transition-name morph on a shared element looks great, but it depends on the lightbox image already having real dimensions when the transition starts. A freshly-set src has 0x0 dimensions until the image loads, which means the morph animates from a point rather than the thumbnail. The circle clip-path approach sidesteps this entirely; it does not care about image dimensions at all.
Here’s how clip-path on ::view-transition-new(root) works:
viewport
┌─────────────────────────────┐
│ │
│ ●── circle(r0) │ ← new state, clipped to small circle
│ │ centred on the thumbnail
│ │
└─────────────────────────────┘
↓ animates to ↓
┌─────────────────────────────┐
│ │
│ ●──────────────────────── │ ← circle expands to cover viewport
│ │ new state visible │ │
│ │ (full lightbox) │ │
└─────────────────────────────┘
The ::view-transition-new(root) pseudo-element holds the live render of the new state (the open lightbox). We clip it to a small circle and animate that clip outward:
// src/transitions.ts (excerpt)
export function animateCircleExpand(cx: number, cy: number, r0: number, r1: number, durationMs = 620) {
document.documentElement.animate(
{
clipPath: [
`circle(${r0}px at${cx}px${cy}px)`,
`circle(${r1}px at${cx}px${cy}px)`,
],
},
{
duration: durationMs,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
pseudoElement: '::view-transition-new(root)',
},
);
}
Code language: JavaScript (javascript)
cx and cy are the centre of the clicked thumbnail. r0 is the thumbnail’s half-diagonal so the circle starts roughly thumbnail-sized. r1 is the radius of a circle centred at (cx, cy) that fully covers the viewport:
export function fullScreenRadius(cx: number, cy: number): number {
return Math.hypot(
Math.max(cx, window.innerWidth - cx),
Math.max(cy, window.innerHeight - cy),
);
}
Code language: JavaScript (javascript)
Next, you’ll wire it in the gallery:
// src/gallery.ts (excerpt)
async openPhoto(index: number): Promise<void> {
const thumbRect = thumbnail.getBoundingClientRect();
const cx = thumbRect.left + thumbRect.width / 2;
const cy = thumbRect.top + thumbRect.height / 2;
const r0 = Math.hypot(thumbRect.width, thumbRect.height) / 2;
const r1 = fullScreenRadius(cx, cy);
// Pre-populate with thumbnail src so the new snapshot has real dimensions
this.lightboxImg.src = thumbnail.currentSrc || thumbnail.src;
const transition = startTransition(() => {
this.currentIndex = index;
this.dialog.showModal();
});
if (transition) {
await transition.ready; // inject JS animation here
animateCircleExpand(cx, cy, r0, r1);
}
// After animation completes, swap in the full-res Cloudinary image
transition?.finished.then(() => {
const { src, srcset, sizes } = buildFullsizeSrcset(this.config, image.publicId);
this.lightboxImg.src = src;
this.lightboxImg.srcset = srcset;
});
}
Code language: JavaScript (javascript)
Pro tip: The .ready > animate > .finished > clean up pattern is the View Transition API’s fundamental rhythm. Internalize it and you can build any animation imaginable.
View the full
gallery.tssource: https://github.com/musebe/view-transition-gallery/blob/main/src/gallery.ts
When navigating between photos, the outgoing image should slide in the direction the user is moving. This is pure CSS; no JavaScript animation is needed after the direction is signalled.
Signalling direction with a data attribute:
// src/transitions.ts (excerpt)
export function setTransitionDirection(direction: 'forward' | 'backward'): void {
document.documentElement.setAttribute('data-transition-direction', direction);
}
export function clearTransitionDirection(): void {
document.documentElement.removeAttribute('data-transition-direction');
}
Code language: JavaScript (javascript)
In the Gallery navigate method:
// src/gallery.ts (excerpt)
setTransitionDirection(direction > 0 ? 'forward' : 'backward');
const t = startTransition(() => {
this.lightboxImg.src = src;
this.currentIndex = nextIndex;
});
await t?.finished;
clearTransitionDirection(); // clean up after animation
Code language: JavaScript (javascript)
Then, the CSS picks up the signal:
/* src/styles/transitions.css (excerpt) */
/* Forward: old slides left, new enters from right */
:root[data-transition-direction='forward'] ::view-transition-old(root) {
animation: vt-slide-out-left 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
:root[data-transition-direction='forward'] ::view-transition-new(root) {
animation: vt-slide-in-right 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes vt-slide-out-left {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-10%); opacity: 0; }
}
@keyframes vt-slide-in-right {
from { transform: translateX(10%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
Code language: CSS (css)
The data-transition-direction attribute is set before startViewTransition, read during the animation, and removed after .finished. The timing is critical. Setting it after the transition starts means the CSS sees no attribute and falls back to the default.
View the full
transitions.csssource: https://github.com/musebe/view-transition-gallery/blob/main/src/styles/transitions.css
This technique is arguably the most powerful in the View Transition API toolbox. Instead of transitioning the entire page, you transition one specific element while everything else stays perfectly static.
The Effects Lab is a panel that slides up inside the lightbox, showing 12 Cloudinary transformations, each with a live preview thumbnail built from the same image:
// src/effects.ts (excerpt)
export const EFFECTS: Effect[] = [
{ id: 'original', label: 'Original', emoji: '🖼️', transform: '' },
{ id: 'sepia', label: 'Sepia', emoji: '📸', transform: 'e_sepia' },
{ id: 'cartoonify', label: 'Cartoon', emoji: '🎨', transform: 'e_cartoonify' },
{ id: 'oil_paint', label: 'Oil Paint', emoji: '🖌️', transform: 'e_oil_paint:40' },
{ id: 'art_frost', label: 'Frost', emoji: '❄️', transform: 'e_art:frost' },
{ id: 'pixelate', label: 'Pixel Art', emoji: '👾', transform: 'e_pixelate:8' },
// ... 6 more
];
Code language: JavaScript (javascript)
Each preview thumbnail URL is built on the fly:
// src/cloudinary.ts (excerpt)
export function buildEffectPreviewUrl(config, publicId, effectTransform): string {
const transform = effectTransform
? `c_fill,w_120,h_80/${effectTransform}/f_auto/q_auto`
: `c_fill,w_120,h_80/f_auto/q_auto`;
return buildUrl(config, publicId, transform);
}
Code language: JavaScript (javascript)
The browser requests these lazily as the panel opens. No wasted bandwidth.
You’ll scope the transition to one element:
// src/effects.ts (excerpt)
private applyEffect(effect: Effect, lightboxImg: HTMLImageElement): void {
// Assign a transition name to ONLY the lightbox image
assignTransitionName(lightboxImg, 'effect-image');
const t = startTransition(() => {
lightboxImg.src = buildEffectUrl(this.config, this.publicId, effect.transform);
lightboxImg.srcset = '';
});
// Remove the name when done; leaving it would affect future transitions
t?.finished.then(() => clearTransitionName(lightboxImg));
}
Code language: JavaScript (javascript)
Because only the <img> has a view-transition-name, only it gets captured and animated. The lightbox chrome (close button, navigation, Effects Lab panel itself) are untouched.
Here’s how to apply the blur/saturate cross-fade:
/* src/styles/transitions.css (excerpt) */
::view-transition-old(effect-image) {
animation: vt-effect-out 250ms ease-out both;
}
::view-transition-new(effect-image) {
animation: vt-effect-in 380ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes vt-effect-out {
to { opacity: 0.2; filter: blur(10px) saturate(0) brightness(1.3); transform: scale(1.04); }
}
@keyframes vt-effect-in {
from { opacity: 0; filter: blur(10px) saturate(0) brightness(0.7); transform: scale(0.96); }
to { opacity: 1; filter: blur(0) saturate(1) brightness(1); transform: scale(1); }
}
Code language: CSS (css)
The old state bleaches out and blurs; the new state sharpens in. The effect change feels like a photographic process, which is exactly on-theme.
View the full
effects.tssource: https://github.com/musebe/view-transition-gallery/blob/main/src/effects.ts)
This is where Cloudinary’s URL-based architecture really shines. The Mosaic Builder lets users drag four collected photos into a 2×2 grid and produces a single Cloudinary delivery URL that composites all four on the fly, server-side, with no upload required.
Here’s how the overlay (l_) API works. Cloudinary’s overlay parameter (l_) places one image on top of another, and you chain them to build a full composition:
Base canvas (800x600)
→ overlay image 1 at top-left (x: -200, y: -150)
→ overlay image 2 at top-right (x: 200, y: -150)
→ overlay image 3 at bot-left (x: -200, y: 150)
→ overlay image 4 at bot-right (x: 200, y: 150)
→ f_auto / q_auto
The x/y values are offsets from the canvas centre. Each overlay is 400×300, exactly one quarter of the 800×600 canvas.
When a public ID appears inside an overlay parameter, forward slashes must be replaced with colons:
# Public ID: samples/landscapes/beach-boat
# In overlay: samples:landscapes:beach-boat
l_samples:landscapes:beach-boat/c_fill,w_400,h_300/fl_layer_apply,x_-200,y_-150
This is the rule that trips up everyone the first time.
The URL builder:
// src/cloudinary.ts (excerpt)
export function buildMosaicUrl(config: CloudinaryConfig, publicIds: [string, string, string, string]): string {
const positions = [
{ x: -200, y: -150 }, { x: 200, y: -150 },
{ x: -200, y: 150 }, { x: 200, y: 150 },
];
const overlays = publicIds.map((id, i) => {
const overlayId = id.replace(/\//g, ':');
const pos = positions[i]!;
return `l_${overlayId}/c_fill,w_400,h_300/fl_layer_apply,x_${pos.x},y_${pos.y}`;
});
return buildUrl(config, 'sample', `c_fill,w_800,h_600/${overlays.join('/')}/f_auto/q_auto`);
}
Code language: JavaScript (javascript)
The whole composition (cropping, positioning, format negotiation) happens at Cloudinary’s CDN edge, not on your server or in the browser.
Drag-and-drop with a mobile fallback:
// src/mosaic.ts (excerpt)
private bindCardEvents(card: HTMLElement, image: GalleryImage): void {
// Desktop: HTML5 drag and drop
card.addEventListener('dragstart', (e) => {
card.classList.add('is-dragging');
e.dataTransfer?.setData('text/plain', image.publicId);
});
// Mobile: tap-to-add to the next empty slot
card.addEventListener('click', () => this.addToNextSlot(image));
}
Code language: PHP (php)
No library needed. The native HTML5 Drag and Drop API handles desktop; a simple click listener handles mobile. The same slot-filling logic serves both.
When the mosaic is created, the result dialog opens with a spring-bounce scale animation via a named transition:
// src/mosaic.ts (excerpt)
assignTransitionName(this.resultDialog, 'mosaic-result');
const t = startTransition(() => {
this.resultImg.src = url;
this.resultDialog.showModal();
});
t?.finished.then(() => clearTransitionName(this.resultDialog));
Code language: JavaScript (javascript)
/* src/styles/transitions.css (excerpt) */
::view-transition-new(mosaic-result) {
animation: vt-mosaic-in 480ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes vt-mosaic-in {
from { opacity: 0; transform: scale(0.7) rotate(-1deg); }
to { opacity: 1; transform: scale(1) rotate(0deg); }
}
Code language: CSS (css)
View the full
mosaic.tssource: https://github.com/musebe/view-transition-gallery/blob/main/src/mosaic.ts
The lightbox is a standard HTML <dialog> element. No custom focus trapping. No scroll-lock JavaScript. No z-index juggling.
<!-- index.html (excerpt) -->
<dialog id="lightbox" class="lightbox" aria-label="Photo lightbox" aria-modal="true">
<button class="lightbox__close" aria-label="Close lightbox">...</button>
<figure class="lightbox__figure">
<img class="lightbox__img" src="" alt=""/>
<figcaption class="lightbox__caption"></figcaption>
</figure>
<!-- Effects Lab panel slides up from bottom -->
<div id="effects-panel" class="effects-panel">...</div>
</dialog>
Code language: HTML, XML (xml)
dialog.showModal(); // open: focus trapped, Escape closes, ::backdrop shown
dialog.close(); // close
Code language: JavaScript (javascript)
@starting-style is a CSS rule that defines an element’s styles at the moment it first becomes rendered, giving the browser a “from” state to interpolate from:
/* src/styles/gallery.css (excerpt) */
.lightbox::backdrop {
background: rgba(7, 7, 10, 0.93);
backdrop-filter: blur(6px);
transition: opacity 350ms ease;
opacity: 1;
}
@starting-style {
.lightbox::backdrop {
opacity: 0; /* browser animates FROM this */
}
}
Code language: CSS (css)
Without @starting-style, the backdrop would pop in instantly because there is no previous state for the browser to transition from when an element is first painted. This CSS-only solution requires no JavaScript.
Pro tip:
@starting-styleworks on any element entering the DOM or becoming visible, not just<dialog>. It’s the native equivalent of theenterclass in Vue Transitions orAnimatePresencein Framer Motion.
Making a demo technically impressive is one thing. Making people want to explore it is another. The game layer is specifically designed to reward discovery of Cloudinary features.
The scoring model:
| Action | Points |
|---|---|
| Open a new photo | +10 x combo multiplier |
| Navigate within 3 s (builds combo) | +5 x combo multiplier |
| Apply an effect (first time per effect) | +15 |
| Build a mosaic | +50 |
The combo incentivizes rapid navigation. The fastest path to points is also the fastest path to seeing all the transitions.
With a toast queue with progress bar, multiple achievements can unlock simultaneously. Rather than overlapping toasts, they queue:
// src/game.ts (excerpt)
private showNextToast(): void {
const next = this.toastQueue.shift();
if (!next) { this.hideToast(); return; }
this.isToastVisible = true;
this.toastIconEl.textContent = next.icon;
this.toastNameEl.textContent = next.name;
// Drain the progress bar over 6 seconds
this.toastProgressEl.style.transition = 'none';
this.toastProgressEl.style.width = '100%';
requestAnimationFrame(() => {
this.toastProgressEl.style.transition = `width${TOAST_DURATION_MS}ms linear`;
this.toastProgressEl.style.width = '0%';
});
this.toastTimerId = setTimeout(() => this.showNextToast(), TOAST_DURATION_MS);
}
Code language: JavaScript (javascript)
The requestAnimationFrame trick is necessary: setting transition: none and then width: 100% in the same frame means the browser never renders the full bar. The rAF forces a paint before the drain animation starts.
You can even add floating score popups, which is when points are awarded, a “+10 x3” label floats upward from the thumbnail using the Web Animations API:
// src/game.ts (excerpt)
el.animate(
[
{ opacity: 1, transform: 'translate(-50%, 0) scale(1)' },
{ opacity: 0, transform: 'translate(-50%, -72px) scale(1.2)' },
],
{ duration: 1100, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' },
).finished.then(() => el.remove());
Code language: JavaScript (javascript)
No CSS class toggling, no timeout-based cleanup. The Web Animations API returns a promise when the animation completes, ideal for fire-and-forget elements that should remove themselves.
View the full
game.tssource: https://github.com/musebe/view-transition-gallery/blob/main/src/game.ts
Every animation in this project (View Transitions and CSS alike) respects the user’s motion preference:
/* src/styles/transitions.css (excerpt) */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.001ms !important;
animation-delay: 0ms !important;
}
}
Code language: CSS (css)
Setting duration to 0.001ms rather than 0 avoids a Chrome bug where zero-duration View Transitions can flicker.
To achieve lazy loading and decoding, every thumbnail uses loading="lazy" and decoding="async". Set in the TypeScript rather than the HTML, so no image outside the viewport blocks the main thread:
// src/gallery.ts (excerpt)
img.loading = 'lazy';
img.decoding = 'async';
Code language: JavaScript (javascript)
The lightbox responds to keyboard events on the document:
document.addEventListener('keydown', (e) => {
if (!this.dialog.open) return;
if (e.key === 'Escape') { e.preventDefault(); this.closePhoto(); }
if (e.key === 'ArrowRight') this.navigate(1);
if (e.key === 'ArrowLeft') this.navigate(-1);
});
Code language: JavaScript (javascript)
The if (!this.dialog.open) guard ensures the handler is a no-op when the lightbox is closed. No need to add or remove the listener on open/close.
Lastly, every interactive element has an explicit aria-label. The HUD uses aria-live="polite" on the score and collected-count so screen readers announce changes without interrupting. The progress bar uses the full role="progressbar" pattern with aria-valuemin, aria-valuemax, and aria-valuenow.
To make sure everything automatically formats, use Cloudinary’s f_auto to serve WebP to Chrome and Edge, AVIF to supported clients, and JPEG as a universal fallback, all from the same URL and negotiated via the Accept header. No JavaScript, no <picture> elements. Typical savings: 30 to 50 percent over JPEG at equivalent visual quality.
You’ve built a project with Cloudinary, the View Transition API, and Vanilla TypeScript. (No animation libraries were harmed in the making of this demo!)
| Technique | How | Use When |
|---|---|---|
| Circle-expand | JS clip-path on ::view-transition-new(root) via .ready |
Opening from a specific origin point |
| Directional slide | CSS @keyframes + data- attribute on <html> |
Navigating a sequence with direction |
| Scoped element | view-transition-name on one element only |
Changing content inside a larger, unchanged context |
| Named dialog reveal | view-transition-name on a <dialog> |
Modal that should animate distinctly from the page |
| Cross-page (bonus) | @view-transition { navigation: auto; } in CSS |
MPA navigation, covered in the View Transition API Level 2 spec |
Ready to level up your media workflow? Start using Cloudinary for free and build better visual experiences today.
-
Video gallery. The same circle-expand works on
<video>elements. -
Page transitions. Add
@view-transition { navigation: auto; }to a multi-page app for free cross-page animations. -
Shared-element morphs. Assign the same
view-transition-nameto a thumbnail and the hero image on a detail page to get an automatic position and size morph. -
Cloudinary named transformations. Store complex effect chains as named transforms and call them with
t_my-transformto keep URLs short.
Resources:
What is the View Transition API and how does it work?
document.startViewTransition(callback), passing the DOM update as a callback. The browser captures a screenshot of the current state, runs the callback to update the DOM, captures the new state, then animates between them using pseudo-element overlays. The default animation is a cross-fade, but you can replace it with any CSS or JavaScript animation.
How do you create a circle-reveal animation with the View Transition API?
document.startViewTransition(), wait for the .ready promise, then use document.documentElement.animate() with a clip-path keyframe on the ::view-transition-new(root) pseudo-element.
Start with
circle(r0px at cx cy), where cx and cy are the origin point, such as the center of a clicked thumbnail, and animate outward to circle(r1px at cx cy), where r1 is large enough to cover the full viewport.
What is the difference between ::view-transition-old and ::view-transition-new?
::view-transition-old is a frozen screenshot of the page state before the DOM update. ::view-transition-new represents the page state after the DOM update. Both are painted as CSS pseudo-elements layered over the page during the transition.
You can animate them independently to create effects such as sliding the old state out while sliding the new state in.
How does view-transition-name scope a transition to one element?
view-transition-name: my-name to an element, the browser gives that element its own capture group, separate from the root. During the transition, that element’s old and new states can be animated independently while everything else on the page remains part of its respective transition group.
Every active
view-transition-name must be unique on the page at transition time. Duplicate names can cause the transition for those elements to be skipped.
How does Cloudinary serve the right image format automatically?
f_auto to a Cloudinary delivery URL instructs Cloudinary to negotiate an appropriate image format based on the requesting browser’s capabilities. Depending on browser support and the source asset, Cloudinary can automatically deliver modern formats such as AVIF or WebP, with other formats used when appropriate.
This allows format optimization to happen automatically without requiring JavaScript or a
<picture> element.
How do you composite four images into a single Cloudinary URL?
l_, chained once per image. Each overlay can specify the source public ID, a resize transformation, and fl_layer_apply with x and y offsets to position the layer on the canvas.
The browser requests the final composited asset through a single delivery URL, while Cloudinary handles the cropping, positioning, transformations, and format delivery.
What is @starting-style and why is it useful for dialog animations?
@starting-style is a CSS at-rule that defines the starting styles for an element when it first becomes rendered or transitions from a non-rendered state. Without it, elements such as a <dialog> backdrop may not have a previous rendered state from which the browser can animate.
For example, setting
opacity: 0 inside @starting-style provides a starting point that allows an opacity transition to run when the element appears. It can be used for other elements entering the rendered state, not just dialogs.
Does the View Transition API work in all browsers?
typeof document.startViewTransition === 'function' and fall back to a direct DOM update when the API isn’t available.
This ensures the underlying functionality continues to work even when the transition animation isn’t supported.