{"id":40371,"date":"2026-09-10T07:00:00","date_gmt":"2026-09-10T14:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=40371"},"modified":"2026-09-09T13:00:25","modified_gmt":"2026-09-09T20:00:25","slug":"view-transition-gallery-cloudinary","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary","title":{"rendered":"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary"},"content":{"rendered":"<div class=\"wp-block-cloudinary-markdown \"><p>Every time your UI changes state (a photo opens, a panel slides in, a filter is applied), the user\u2019s brain has to re-orient. A jarring instant swap forces that work on them. A well-designed transition <em>does it for them<\/em>: It communicates what moved, where it went, and why the current state relates to the previous one.<\/p>\n<p>For years, building these transitions in the browser meant one of three things:<\/p>\n<ul>\n<li>Reaching for a JavaScript animation library (GSAP, Framer Motion, Anime.js).<\/li>\n<li>Writing intricate CSS that only worked if you knew the before <em>and<\/em> after states at stylesheet time.<\/li>\n<li>Accepting that smooth cross-page transitions were simply not possible on the web.<\/li>\n<\/ul>\n<p>The <strong>View Transition API<\/strong> changes all three. It lets you wrap any DOM mutation in <code>document.startViewTransition()<\/code> 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.<\/p>\n<p>Combine that with <strong>Cloudinary<\/strong>, 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.<\/p>\n<ul>\n<li>\n<strong>Live Demo:<\/strong> <a href=\"https:\/\/view-transition-gallery.vercel.app\/\">https:\/\/view-transition-gallery.vercel.app\/<\/a>\n<\/li>\n<li>\n<strong>GitHub:<\/strong> <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\">https:\/\/github.com\/musebe\/view-transition-gallery<\/a>\n<\/li>\n<\/ul>\n<h2>What We\u2019re Building<\/h2>\n<ul>\n<li>\n<strong>Responsive photo grid<\/strong> served with Cloudinary <code>srcsets<\/code>.<\/li>\n<li>\n<strong>Lightbox<\/strong> that opens with a circle expanding from the thumbnail\u2019s exact position.<\/li>\n<li>\n<strong>Directional slide navigation<\/strong> (arrow keys or buttons) with CSS keyframes.<\/li>\n<li>\n<strong>Live Effects Lab<\/strong> with 12 Cloudinary transformations applied with a single cross-fade.<\/li>\n<li>\n<strong>Mosaic Builder<\/strong> that allowed you to drag and drop four photos to get one composited Cloudinary URL.<\/li>\n<li>\n<strong>Game layer<\/strong> with scoring, combos, achievements, and a toast queue.<\/li>\n<\/ul>\n<h2>Prerequisites and Project Setup<\/h2>\n<p>Before you start, you\u2019ll need:<\/p>\n<ul>\n<li>\n<strong>Node.js 18+<\/strong> and <code>npm<\/code>.<\/li>\n<li>A <strong>Cloudinary account<\/strong> (the free tier is fine; the demo uses the public <code>demo<\/code> cloud so you can run it immediately without one).<\/li>\n<li>Basic familiarity with <strong>TypeScript<\/strong> and the DOM.<\/li>\n<\/ul>\n<p>The scaffold in 60 seconds:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\">git <span class=\"hljs-keyword\">clone<\/span> https:<span class=\"hljs-comment\">\/\/github.com\/musebe\/view-transition-gallery.git<\/span>\ncd view-transition-gallery\nnpm install\nnpm run dev\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Open <code>http:\/\/localhost:5173<\/code>. You should see the gallery immediately. No API keys required.<\/p>\n<p>Your tech stack at a glance:<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Tool<\/th>\n<th>Why<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><strong>Vite 5<\/strong><\/td>\n<td>Instant dev server + zero-config TypeScript<\/td>\n<\/tr>\n<tr>\n<td><strong>TypeScript 5 (strict)<\/strong><\/td>\n<td>Catch bugs at compile time, not in production<\/td>\n<\/tr>\n<tr>\n<td><strong>Vanilla JS<\/strong><\/td>\n<td>No framework overhead, just Web APIs<\/td>\n<\/tr>\n<tr>\n<td><strong>Cloudinary demo cloud<\/strong><\/td>\n<td>Real CDN delivery without account setup<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>The project file map:<\/p>\n<pre class=\"js-syntax-highlighted\"><code>src\/\n\u251c\u2500\u2500 main.ts          \u2190 bootstrap: wires everything together\n\u251c\u2500\u2500 types.ts         \u2190 shared interfaces (GalleryImage, Effect, etc.)\n\u251c\u2500\u2500 cloudinary.ts    \u2190 all URL builders (thumbnails, effects, mosaics)\n\u251c\u2500\u2500 transitions.ts   \u2190 View Transition helpers (circle expand\/collapse)\n\u251c\u2500\u2500 gallery.ts       \u2190 grid rendering + lightbox logic\n\u251c\u2500\u2500 effects.ts       \u2190 Effects Lab: 12 live Cloudinary transforms\n\u251c\u2500\u2500 mosaic.ts        \u2190 drag-and-drop mosaic builder\n\u251c\u2500\u2500 game.ts          \u2190 scoring, combos, achievements, HUD\n\u251c\u2500\u2500 welcome.ts       \u2190 first-visit dialog\n\u2514\u2500\u2500 styles\/\n    \u251c\u2500\u2500 gallery.css       \u2190 grid, lightbox, effects panel\n    \u251c\u2500\u2500 transitions.css   \u2190 all ::view-transition-* rules\n    \u2514\u2500\u2500 game.css          \u2190 HUD, toasts, mosaic overlay\n<\/code><\/pre>\n<blockquote>\n<p>Browse the full source on GitHub: <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\">https:\/\/github.com\/musebe\/view-transition-gallery<\/a><\/p>\n<\/blockquote>\n<h2>Cloudinary Foundations: Responsive Images Done Right<\/h2>\n<p>Before starting on an animation, you\u2019ll need images. Cloudinary\u2019s delivery URLs carry the entire transformation pipeline inside them with no server code and no build step.<\/p>\n<h3>The URL Anatomy<\/h3>\n<pre class=\"js-syntax-highlighted\"><code>https:\/\/res.cloudinary.com\/{cloud_name}\/image\/upload\/{transformations}\/{public_id}\n<\/code><\/pre>\n<p>Every segment between <code>upload\/<\/code> and the public ID is a transformation. Multiple transforms chain with <code>\/<\/code>.<\/p>\n<h3><code>c_fill,g_auto<\/code>: AI-Powered Smart Cropping<\/h3>\n<p>For thumbnails, you\u2019ll want a consistent 4:3 aspect ratio regardless of the source image\u2019s dimensions. <code>c_fill<\/code> crops to fit; <code>g_auto<\/code> uses Cloudinary\u2019s content-aware gravity to keep the interesting part of the photo in frame:<\/p>\n<pre class=\"js-syntax-highlighted\"><code>c_fill,g_auto,w_400,ar_4:3\n<\/code><\/pre>\n<p>A portrait photo of a person will keep their face centred. A landscape shot will preserve the horizon. No manual focal points needed.<\/p>\n<h3>Building a <code>srcset<\/code> Factory<\/h3>\n<p>Rather than hardcoding URLs, you\u2019ll generate the full <code>srcset<\/code> programmatically:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/cloudinary.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">const<\/span> THUMBNAIL_WIDTHS = &#91;<span class=\"hljs-number\">200<\/span>, <span class=\"hljs-number\">400<\/span>, <span class=\"hljs-number\">600<\/span>, <span class=\"hljs-number\">800<\/span>] <span class=\"hljs-keyword\">as<\/span> <span class=\"hljs-keyword\">const<\/span>;\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">buildThumbnailSrcset<\/span>(<span class=\"hljs-params\">config: CloudinaryConfig, publicId: string<\/span>): <span class=\"hljs-title\">ResponsiveImage<\/span> <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> srcset = THUMBNAIL_WIDTHS\n    .map(<span class=\"hljs-function\">(<span class=\"hljs-params\">w<\/span>) =&gt;<\/span> <span class=\"hljs-string\">`<span class=\"hljs-subst\">${buildThumbnailUrl(config, publicId, w)}<\/span><span class=\"hljs-subst\">${w}<\/span>w`<\/span>)\n    .join(<span class=\"hljs-string\">', '<\/span>);\n\n  <span class=\"hljs-keyword\">return<\/span> {\n    <span class=\"hljs-attr\">src<\/span>: buildThumbnailUrl(config, publicId, <span class=\"hljs-number\">400<\/span>),\n    srcset,\n    <span class=\"hljs-attr\">sizes<\/span>: <span class=\"hljs-string\">'(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw'<\/span>,\n  };\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>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.<\/p>\n<blockquote>\n<p>View the full <code>cloudinary.ts<\/code> source: <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/cloudinary.ts\">https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/cloudinary.ts<\/a><\/p>\n<\/blockquote>\n<h3>The <code>f_auto<\/code> \/ <code>q_auto<\/code> Rule<\/h3>\n<p>This is the most common Cloudinary mistake. <code>f_auto<\/code> (auto format: WebP, AVIF) and <code>q_auto<\/code> (auto quality) <strong>must be separate chained segments<\/strong>, not comma-joined:<\/p>\n<pre class=\"js-syntax-highlighted\"><code># Correct: f_auto and q_auto are separate segments\nc_fill,g_auto,w_400,ar_4:3\/f_auto\/q_auto\/samples\/landscapes\/beach-boat\n\n# Wrong: f_auto,q_auto treated as one segment, q_auto silently ignored\nc_fill,g_auto,w_400,ar_4:3\/f_auto,q_auto\/samples\/landscapes\/beach-boat\n<\/code><\/pre>\n<p>The reason: <code>f_auto<\/code> and <code>q_auto<\/code> are delivery instructions that must be applied <em>after<\/em> content transforms. Chaining them separately guarantees Cloudinary applies them in the correct order.<\/p>\n<h2>The View Transition API: Core Concepts<\/h2>\n<h3>What <code>document.startViewTransition()<\/code> Does<\/h3>\n<p>When you call it, the browser:<\/p>\n<ol>\n<li>\n<strong>Captures<\/strong> a screenshot of the current page state (the \u201cold\u201d snapshot).<\/li>\n<li>\n<strong>Runs your callback<\/strong>, so the DOM update happens synchronously here.<\/li>\n<li>\n<strong>Captures<\/strong> the new page state (the \u201cnew\u201d live render).<\/li>\n<li>\n<strong>Animates<\/strong> between old and new using pseudo-element overlays.<\/li>\n<\/ol>\n<p>The default animation is a simple cross-fade. The power comes from customising those pseudo-elements.<\/p>\n<h3>The 4 Pseudo-Elements<\/h3>\n<pre class=\"js-syntax-highlighted\"><code>::view-transition                    \u2190 the full-page overlay (z-index: max)\n  \u2514\u2500 ::view-transition-group(root)   \u2190 wraps the root animation group\n       \u2514\u2500 ::view-transition-image-pair(root)\n            \u251c\u2500 ::view-transition-old(root)   \u2190 frozen screenshot of old state\n            \u2514\u2500 ::view-transition-new(root)   \u2190 live render of new state\n<\/code><\/pre>\n<p>When you assign <code>view-transition-name: my-name<\/code> to an element, it gets its own group, separate from root, so it can animate independently.<\/p>\n<h3>A Reusable Wrapper<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/transitions.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">isViewTransitionSupported<\/span>(<span class=\"hljs-params\"><\/span>): <span class=\"hljs-title\">boolean<\/span> <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">typeof<\/span> <span class=\"hljs-built_in\">document<\/span>.startViewTransition === <span class=\"hljs-string\">'function'<\/span>;\n}\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">startTransition<\/span>(<span class=\"hljs-params\">updateFn: (<\/span>) =&gt; <span class=\"hljs-title\">void<\/span>): <span class=\"hljs-title\">ViewTransition<\/span> | <span class=\"hljs-title\">null<\/span> <\/span>{\n  <span class=\"hljs-keyword\">if<\/span> (!isViewTransitionSupported()) {\n    updateFn(); <span class=\"hljs-comment\">\/\/ instant fallback<\/span>\n    <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">null<\/span>;\n  }\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-built_in\">document<\/span>.startViewTransition(updateFn);\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Every transition call in this project goes through <code>startTransition<\/code>. If the browser doesn\u2019t support the API, the DOM update still happens, just without animation. Pure progressive enhancement.<\/p>\n<blockquote>\n<p>View the full <code>transitions.ts<\/code> source: <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/transitions.ts\">https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/transitions.ts<\/a><\/p>\n<\/blockquote>\n<h3>The <code>.ready<\/code> and <code>.finished<\/code> Promises<\/h3>\n<p><code>startViewTransition()<\/code> returns a <code>ViewTransition<\/code> object with two key promises:<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Promise<\/th>\n<th>Resolves When<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>.ready<\/code><\/td>\n<td>Both snapshots are captured and animation is about to start<\/td>\n<\/tr>\n<tr>\n<td><code>.finished<\/code><\/td>\n<td>Animation is fully complete<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p><code>.ready<\/code> is where you inject JavaScript-driven animations (like the circle clip-path). <code>.finished<\/code> is where you clean up: Remove <code>view-transition-name<\/code> assignments, load the full-res image, and so on.<\/p>\n<h2>Technique 1: Circle-Expand Reveal<\/h2>\n<p>This is the signature effect of the project. When you click a thumbnail, the lightbox opens as a circle expanding from the thumbnail\u2019s centre until it fills the screen. Closing collapses it back.<\/p>\n<p>So why not the default morph?<\/p>\n<p>The default <code>view-transition-name<\/code> 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 <code>src<\/code> 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.<\/p>\n<p>Here\u2019s how clip-path on <code>::view-transition-new(root)<\/code> works:<\/p>\n<pre class=\"js-syntax-highlighted\"><code>                 viewport\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502                             \u2502\n\u2502      \u25cf\u2500\u2500 circle(r0)         \u2502  \u2190 new state, clipped to small circle\n\u2502                             \u2502     centred on the thumbnail\n\u2502                             \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n          \u2193 animates to \u2193\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502                             \u2502\n\u2502  \u25cf\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500  \u2502  \u2190 circle expands to cover viewport\n\u2502  \u2502    new state visible  \u2502  \u2502\n\u2502  \u2502   (full lightbox)     \u2502  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n<\/code><\/pre>\n<p>The <code>::view-transition-new(root)<\/code> 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:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/transitions.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">animateCircleExpand<\/span>(<span class=\"hljs-params\">cx: number, cy: number, r0: number, r1: number, durationMs = <span class=\"hljs-number\">620<\/span><\/span>) <\/span>{\n  <span class=\"hljs-built_in\">document<\/span>.documentElement.animate(\n    {\n      <span class=\"hljs-attr\">clipPath<\/span>: &#91;\n        <span class=\"hljs-string\">`circle(<span class=\"hljs-subst\">${r0}<\/span>px at<span class=\"hljs-subst\">${cx}<\/span>px<span class=\"hljs-subst\">${cy}<\/span>px)`<\/span>,\n        <span class=\"hljs-string\">`circle(<span class=\"hljs-subst\">${r1}<\/span>px at<span class=\"hljs-subst\">${cx}<\/span>px<span class=\"hljs-subst\">${cy}<\/span>px)`<\/span>,\n      ],\n    },\n    {\n      <span class=\"hljs-attr\">duration<\/span>: durationMs,\n      <span class=\"hljs-attr\">easing<\/span>: <span class=\"hljs-string\">'cubic-bezier(0.4, 0, 0.2, 1)'<\/span>,\n      <span class=\"hljs-attr\">pseudoElement<\/span>: <span class=\"hljs-string\">'::view-transition-new(root)'<\/span>,\n    },\n  );\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><code>cx<\/code> and <code>cy<\/code> are the centre of the clicked thumbnail. <code>r0<\/code> is the thumbnail\u2019s half-diagonal so the circle starts roughly thumbnail-sized. <code>r1<\/code> is the radius of a circle centred at <code>(cx, cy)<\/code> that fully covers the viewport:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">fullScreenRadius<\/span>(<span class=\"hljs-params\">cx: number, cy: number<\/span>): <span class=\"hljs-title\">number<\/span> <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-built_in\">Math<\/span>.hypot(\n    <span class=\"hljs-built_in\">Math<\/span>.max(cx, <span class=\"hljs-built_in\">window<\/span>.innerWidth  - cx),\n    <span class=\"hljs-built_in\">Math<\/span>.max(cy, <span class=\"hljs-built_in\">window<\/span>.innerHeight - cy),\n  );\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Next, you\u2019ll wire it in the gallery:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/gallery.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">async<\/span> openPhoto(index: number): <span class=\"hljs-built_in\">Promise<\/span>&lt;<span class=\"hljs-keyword\">void<\/span>&gt; {\n  <span class=\"hljs-keyword\">const<\/span> thumbRect = thumbnail.getBoundingClientRect();\n  <span class=\"hljs-keyword\">const<\/span> cx = thumbRect.left + thumbRect.width  \/ <span class=\"hljs-number\">2<\/span>;\n  <span class=\"hljs-keyword\">const<\/span> cy = thumbRect.top  + thumbRect.height \/ <span class=\"hljs-number\">2<\/span>;\n  <span class=\"hljs-keyword\">const<\/span> r0 = <span class=\"hljs-built_in\">Math<\/span>.hypot(thumbRect.width, thumbRect.height) \/ <span class=\"hljs-number\">2<\/span>;\n  <span class=\"hljs-keyword\">const<\/span> r1 = fullScreenRadius(cx, cy);\n\n  <span class=\"hljs-comment\">\/\/ Pre-populate with thumbnail src so the new snapshot has real dimensions<\/span>\n  <span class=\"hljs-keyword\">this<\/span>.lightboxImg.src = thumbnail.currentSrc || thumbnail.src;\n\n  <span class=\"hljs-keyword\">const<\/span> transition = startTransition(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n    <span class=\"hljs-keyword\">this<\/span>.currentIndex = index;\n    <span class=\"hljs-keyword\">this<\/span>.dialog.showModal();\n  });\n\n  <span class=\"hljs-keyword\">if<\/span> (transition) {\n    <span class=\"hljs-keyword\">await<\/span> transition.ready;       <span class=\"hljs-comment\">\/\/ inject JS animation here<\/span>\n    animateCircleExpand(cx, cy, r0, r1);\n  }\n\n  <span class=\"hljs-comment\">\/\/ After animation completes, swap in the full-res Cloudinary image<\/span>\n  transition?.finished.then(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n    <span class=\"hljs-keyword\">const<\/span> { src, srcset, sizes } = buildFullsizeSrcset(<span class=\"hljs-keyword\">this<\/span>.config, image.publicId);\n    <span class=\"hljs-keyword\">this<\/span>.lightboxImg.src    = src;\n    <span class=\"hljs-keyword\">this<\/span>.lightboxImg.srcset = srcset;\n  });\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><strong>Pro tip:<\/strong> The <code>.ready<\/code> &gt; animate &gt; <code>.finished<\/code> &gt; clean up pattern is the View Transition API\u2019s fundamental rhythm. Internalize it and you can build any animation imaginable.<\/p>\n<blockquote>\n<p>View the full <code>gallery.ts<\/code> source: <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/gallery.ts\">https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/gallery.ts<\/a><\/p>\n<\/blockquote>\n<h2>Technique 2: Directional Slide Navigation<\/h2>\n<p>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.<\/p>\n<p>Signalling direction with a data attribute:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/transitions.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">setTransitionDirection<\/span>(<span class=\"hljs-params\">direction: <span class=\"hljs-string\">'forward'<\/span> | <span class=\"hljs-string\">'backward'<\/span><\/span>): <span class=\"hljs-title\">void<\/span> <\/span>{\n  <span class=\"hljs-built_in\">document<\/span>.documentElement.setAttribute(<span class=\"hljs-string\">'data-transition-direction'<\/span>, direction);\n}\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">clearTransitionDirection<\/span>(<span class=\"hljs-params\"><\/span>): <span class=\"hljs-title\">void<\/span> <\/span>{\n  <span class=\"hljs-built_in\">document<\/span>.documentElement.removeAttribute(<span class=\"hljs-string\">'data-transition-direction'<\/span>);\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>In the Gallery navigate method:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/gallery.ts (excerpt)<\/span>\nsetTransitionDirection(direction &gt; <span class=\"hljs-number\">0<\/span> ? <span class=\"hljs-string\">'forward'<\/span> : <span class=\"hljs-string\">'backward'<\/span>);\n\n<span class=\"hljs-keyword\">const<\/span> t = startTransition(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n  <span class=\"hljs-keyword\">this<\/span>.lightboxImg.src = src;\n  <span class=\"hljs-keyword\">this<\/span>.currentIndex = nextIndex;\n});\n\n<span class=\"hljs-keyword\">await<\/span> t?.finished;\nclearTransitionDirection(); <span class=\"hljs-comment\">\/\/ clean up after animation<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Then, the CSS picks up the signal:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-comment\">\/* src\/styles\/transitions.css (excerpt) *\/<\/span>\n\n<span class=\"hljs-comment\">\/* Forward: old slides left, new enters from right *\/<\/span>\n<span class=\"hljs-selector-pseudo\">:root<\/span><span class=\"hljs-selector-attr\">&#91;data-transition-direction=<span class=\"hljs-string\">'forward'<\/span>]<\/span> <span class=\"hljs-selector-pseudo\">::view-transition-old(root)<\/span> {\n  <span class=\"hljs-attribute\">animation<\/span>: vt-slide-out-left <span class=\"hljs-number\">320ms<\/span> <span class=\"hljs-built_in\">cubic-bezier<\/span>(<span class=\"hljs-number\">0.16<\/span>, <span class=\"hljs-number\">1<\/span>, <span class=\"hljs-number\">0.3<\/span>, <span class=\"hljs-number\">1<\/span>) both;\n}\n<span class=\"hljs-selector-pseudo\">:root<\/span><span class=\"hljs-selector-attr\">&#91;data-transition-direction=<span class=\"hljs-string\">'forward'<\/span>]<\/span> <span class=\"hljs-selector-pseudo\">::view-transition-new(root)<\/span> {\n  <span class=\"hljs-attribute\">animation<\/span>: vt-slide-in-right <span class=\"hljs-number\">320ms<\/span> <span class=\"hljs-built_in\">cubic-bezier<\/span>(<span class=\"hljs-number\">0.16<\/span>, <span class=\"hljs-number\">1<\/span>, <span class=\"hljs-number\">0.3<\/span>, <span class=\"hljs-number\">1<\/span>) both;\n}\n\n<span class=\"hljs-keyword\">@keyframes<\/span> vt-slide-out-left {\n  <span class=\"hljs-selector-tag\">from<\/span> { <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">translateX<\/span>(<span class=\"hljs-number\">0<\/span>);    <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">1<\/span>; }\n  <span class=\"hljs-selector-tag\">to<\/span>   { <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">translateX<\/span>(-<span class=\"hljs-number\">10%<\/span>); <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">0<\/span>; }\n}\n<span class=\"hljs-keyword\">@keyframes<\/span> vt-slide-in-right {\n  <span class=\"hljs-selector-tag\">from<\/span> { <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">translateX<\/span>(<span class=\"hljs-number\">10%<\/span>);  <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">0<\/span>; }\n  <span class=\"hljs-selector-tag\">to<\/span>   { <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">translateX<\/span>(<span class=\"hljs-number\">0<\/span>);    <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">1<\/span>; }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The <code>data-transition-direction<\/code> attribute is set before <code>startViewTransition<\/code>, read during the animation, and removed after <code>.finished<\/code>. The timing is critical. Setting it after the transition starts means the CSS sees no attribute and falls back to the default.<\/p>\n<blockquote>\n<p>View the full <code>transitions.css<\/code> source: <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/styles\/transitions.css\">https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/styles\/transitions.css<\/a><\/p>\n<\/blockquote>\n<h2>Technique 3: Scoped Element Transitions (Effects Lab)<\/h2>\n<p>This technique is arguably the most powerful in the View Transition API toolbox. Instead of transitioning the entire page, you transition <em>one specific element<\/em> while everything else stays perfectly static.<\/p>\n<p>The <strong>Effects Lab<\/strong> is a panel that slides up inside the lightbox, showing 12 Cloudinary transformations, each with a live preview thumbnail built from the same image:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/effects.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> EFFECTS: Effect&#91;] = &#91;\n  { <span class=\"hljs-attr\">id<\/span>: <span class=\"hljs-string\">'original'<\/span>,   <span class=\"hljs-attr\">label<\/span>: <span class=\"hljs-string\">'Original'<\/span>,  <span class=\"hljs-attr\">emoji<\/span>: <span class=\"hljs-string\">'\ud83d\uddbc\ufe0f'<\/span>, <span class=\"hljs-attr\">transform<\/span>: <span class=\"hljs-string\">''<\/span> },\n  { <span class=\"hljs-attr\">id<\/span>: <span class=\"hljs-string\">'sepia'<\/span>,      <span class=\"hljs-attr\">label<\/span>: <span class=\"hljs-string\">'Sepia'<\/span>,     <span class=\"hljs-attr\">emoji<\/span>: <span class=\"hljs-string\">'\ud83d\udcf8'<\/span>, <span class=\"hljs-attr\">transform<\/span>: <span class=\"hljs-string\">'e_sepia'<\/span> },\n  { <span class=\"hljs-attr\">id<\/span>: <span class=\"hljs-string\">'cartoonify'<\/span>, <span class=\"hljs-attr\">label<\/span>: <span class=\"hljs-string\">'Cartoon'<\/span>,   <span class=\"hljs-attr\">emoji<\/span>: <span class=\"hljs-string\">'\ud83c\udfa8'<\/span>, <span class=\"hljs-attr\">transform<\/span>: <span class=\"hljs-string\">'e_cartoonify'<\/span> },\n  { <span class=\"hljs-attr\">id<\/span>: <span class=\"hljs-string\">'oil_paint'<\/span>,  <span class=\"hljs-attr\">label<\/span>: <span class=\"hljs-string\">'Oil Paint'<\/span>, <span class=\"hljs-attr\">emoji<\/span>: <span class=\"hljs-string\">'\ud83d\udd8c\ufe0f'<\/span>, <span class=\"hljs-attr\">transform<\/span>: <span class=\"hljs-string\">'e_oil_paint:40'<\/span> },\n  { <span class=\"hljs-attr\">id<\/span>: <span class=\"hljs-string\">'art_frost'<\/span>,  <span class=\"hljs-attr\">label<\/span>: <span class=\"hljs-string\">'Frost'<\/span>,     <span class=\"hljs-attr\">emoji<\/span>: <span class=\"hljs-string\">'\u2744\ufe0f'<\/span>, <span class=\"hljs-attr\">transform<\/span>: <span class=\"hljs-string\">'e_art:frost'<\/span> },\n  { <span class=\"hljs-attr\">id<\/span>: <span class=\"hljs-string\">'pixelate'<\/span>,   <span class=\"hljs-attr\">label<\/span>: <span class=\"hljs-string\">'Pixel Art'<\/span>, <span class=\"hljs-attr\">emoji<\/span>: <span class=\"hljs-string\">'\ud83d\udc7e'<\/span>, <span class=\"hljs-attr\">transform<\/span>: <span class=\"hljs-string\">'e_pixelate:8'<\/span> },\n  <span class=\"hljs-comment\">\/\/ ... 6 more<\/span>\n];\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Each preview thumbnail URL is built on the fly:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/cloudinary.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">buildEffectPreviewUrl<\/span>(<span class=\"hljs-params\">config, publicId, effectTransform<\/span>): <span class=\"hljs-title\">string<\/span> <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> transform = effectTransform\n    ? <span class=\"hljs-string\">`c_fill,w_120,h_80\/<span class=\"hljs-subst\">${effectTransform}<\/span>\/f_auto\/q_auto`<\/span>\n    : <span class=\"hljs-string\">`c_fill,w_120,h_80\/f_auto\/q_auto`<\/span>;\n  <span class=\"hljs-keyword\">return<\/span> buildUrl(config, publicId, transform);\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The browser requests these lazily as the panel opens. No wasted bandwidth.<\/p>\n<p>You\u2019ll scope the transition to one element:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/effects.ts (excerpt)<\/span>\nprivate applyEffect(effect: Effect, <span class=\"hljs-attr\">lightboxImg<\/span>: HTMLImageElement): <span class=\"hljs-keyword\">void<\/span> {\n  <span class=\"hljs-comment\">\/\/ Assign a transition name to ONLY the lightbox image<\/span>\n  assignTransitionName(lightboxImg, <span class=\"hljs-string\">'effect-image'<\/span>);\n\n  <span class=\"hljs-keyword\">const<\/span> t = startTransition(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n    lightboxImg.src    = buildEffectUrl(<span class=\"hljs-keyword\">this<\/span>.config, <span class=\"hljs-keyword\">this<\/span>.publicId, effect.transform);\n    lightboxImg.srcset = <span class=\"hljs-string\">''<\/span>;\n  });\n\n  <span class=\"hljs-comment\">\/\/ Remove the name when done; leaving it would affect future transitions<\/span>\n  t?.finished.then(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> clearTransitionName(lightboxImg));\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Because only the <code>&lt;img&gt;<\/code> has a <code>view-transition-name<\/code>, only it gets captured and animated. The lightbox chrome (close button, navigation, Effects Lab panel itself) are untouched.<\/p>\n<p>Here\u2019s how to apply the blur\/saturate cross-fade:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-comment\">\/* src\/styles\/transitions.css (excerpt) *\/<\/span>\n<span class=\"hljs-selector-pseudo\">::view-transition-old(effect-image)<\/span> {\n  <span class=\"hljs-attribute\">animation<\/span>: vt-effect-out <span class=\"hljs-number\">250ms<\/span> ease-out both;\n}\n<span class=\"hljs-selector-pseudo\">::view-transition-new(effect-image)<\/span> {\n  <span class=\"hljs-attribute\">animation<\/span>: vt-effect-in <span class=\"hljs-number\">380ms<\/span> <span class=\"hljs-built_in\">cubic-bezier<\/span>(<span class=\"hljs-number\">0.16<\/span>, <span class=\"hljs-number\">1<\/span>, <span class=\"hljs-number\">0.3<\/span>, <span class=\"hljs-number\">1<\/span>) both;\n}\n\n<span class=\"hljs-keyword\">@keyframes<\/span> vt-effect-out {\n  <span class=\"hljs-selector-tag\">to<\/span> { <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">0.2<\/span>; <span class=\"hljs-attribute\">filter<\/span>: <span class=\"hljs-built_in\">blur<\/span>(<span class=\"hljs-number\">10px<\/span>) <span class=\"hljs-built_in\">saturate<\/span>(<span class=\"hljs-number\">0<\/span>) <span class=\"hljs-built_in\">brightness<\/span>(<span class=\"hljs-number\">1.3<\/span>); <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">scale<\/span>(<span class=\"hljs-number\">1.04<\/span>); }\n}\n<span class=\"hljs-keyword\">@keyframes<\/span> vt-effect-in {\n  <span class=\"hljs-selector-tag\">from<\/span> { <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">0<\/span>; <span class=\"hljs-attribute\">filter<\/span>: <span class=\"hljs-built_in\">blur<\/span>(<span class=\"hljs-number\">10px<\/span>) <span class=\"hljs-built_in\">saturate<\/span>(<span class=\"hljs-number\">0<\/span>) <span class=\"hljs-built_in\">brightness<\/span>(<span class=\"hljs-number\">0.7<\/span>); <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">scale<\/span>(<span class=\"hljs-number\">0.96<\/span>); }\n  <span class=\"hljs-selector-tag\">to<\/span>   { <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">1<\/span>; <span class=\"hljs-attribute\">filter<\/span>: <span class=\"hljs-built_in\">blur<\/span>(<span class=\"hljs-number\">0<\/span>)    <span class=\"hljs-built_in\">saturate<\/span>(<span class=\"hljs-number\">1<\/span>) <span class=\"hljs-built_in\">brightness<\/span>(<span class=\"hljs-number\">1<\/span>);   <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">scale<\/span>(<span class=\"hljs-number\">1<\/span>); }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>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.<\/p>\n<blockquote>\n<p>View the full <code>effects.ts<\/code> source: <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/effects.ts\">https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/effects.ts<\/a>)<\/p>\n<\/blockquote>\n<h2>Technique 4: The Cloudinary Mosaic (1 URL, 4 Images)<\/h2>\n<p>This is where Cloudinary\u2019s URL-based architecture really shines. The Mosaic Builder lets users drag four collected photos into a 2&#215;2 grid and produces a single Cloudinary delivery URL that composites all four on the fly, server-side, with no upload required.<\/p>\n<p>Here\u2019s how the overlay (<code>l_<\/code>) API works. Cloudinary\u2019s overlay parameter (<code>l_<\/code>) places one image on top of another, and you chain them to build a full composition:<\/p>\n<pre class=\"js-syntax-highlighted\"><code>Base canvas (800x600)\n  \u2192 overlay image 1 at top-left  (x: -200, y: -150)\n  \u2192 overlay image 2 at top-right (x:  200, y: -150)\n  \u2192 overlay image 3 at bot-left  (x: -200, y:  150)\n  \u2192 overlay image 4 at bot-right (x:  200, y:  150)\n  \u2192 f_auto \/ q_auto\n<\/code><\/pre>\n<p>The x\/y values are offsets from the canvas centre. Each overlay is 400&#215;300, exactly one quarter of the 800&#215;600 canvas.<\/p>\n<p>When a public ID appears inside an overlay parameter, forward slashes must be replaced with colons:<\/p>\n<pre class=\"js-syntax-highlighted\"><code># Public ID:  samples\/landscapes\/beach-boat\n# In overlay: samples:landscapes:beach-boat\n\nl_samples:landscapes:beach-boat\/c_fill,w_400,h_300\/fl_layer_apply,x_-200,y_-150\n<\/code><\/pre>\n<p>This is the rule that trips up everyone the first time.<\/p>\n<p>The URL builder:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/cloudinary.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">buildMosaicUrl<\/span>(<span class=\"hljs-params\">config: CloudinaryConfig, publicIds: &#91;string, string, string, string]<\/span>): <span class=\"hljs-title\">string<\/span> <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> positions = &#91;\n    { <span class=\"hljs-attr\">x<\/span>: <span class=\"hljs-number\">-200<\/span>, <span class=\"hljs-attr\">y<\/span>: <span class=\"hljs-number\">-150<\/span> }, { <span class=\"hljs-attr\">x<\/span>: <span class=\"hljs-number\">200<\/span>, <span class=\"hljs-attr\">y<\/span>: <span class=\"hljs-number\">-150<\/span> },\n    { <span class=\"hljs-attr\">x<\/span>: <span class=\"hljs-number\">-200<\/span>, <span class=\"hljs-attr\">y<\/span>:  <span class=\"hljs-number\">150<\/span> }, { <span class=\"hljs-attr\">x<\/span>: <span class=\"hljs-number\">200<\/span>, <span class=\"hljs-attr\">y<\/span>:  <span class=\"hljs-number\">150<\/span> },\n  ];\n\n  <span class=\"hljs-keyword\">const<\/span> overlays = publicIds.map(<span class=\"hljs-function\">(<span class=\"hljs-params\">id, i<\/span>) =&gt;<\/span> {\n    <span class=\"hljs-keyword\">const<\/span> overlayId = id.replace(<span class=\"hljs-regexp\">\/\\\/\/g<\/span>, <span class=\"hljs-string\">':'<\/span>);\n    <span class=\"hljs-keyword\">const<\/span> pos = positions&#91;i]!;\n    <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">`l_<span class=\"hljs-subst\">${overlayId}<\/span>\/c_fill,w_400,h_300\/fl_layer_apply,x_<span class=\"hljs-subst\">${pos.x}<\/span>,y_<span class=\"hljs-subst\">${pos.y}<\/span>`<\/span>;\n  });\n\n  <span class=\"hljs-keyword\">return<\/span> buildUrl(config, <span class=\"hljs-string\">'sample'<\/span>, <span class=\"hljs-string\">`c_fill,w_800,h_600\/<span class=\"hljs-subst\">${overlays.join(<span class=\"hljs-string\">'\/'<\/span>)}<\/span>\/f_auto\/q_auto`<\/span>);\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The whole composition (cropping, positioning, format negotiation) happens at Cloudinary\u2019s CDN edge, not on your server or in the browser.<\/p>\n<p>Drag-and-drop with a mobile fallback:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/mosaic.ts (excerpt)<\/span>\n<span class=\"hljs-keyword\">private<\/span> bindCardEvents(card: HTMLElement, image: GalleryImage): void {\n  <span class=\"hljs-comment\">\/\/ Desktop: HTML5 drag and drop<\/span>\n  card.addEventListener(<span class=\"hljs-string\">'dragstart'<\/span>, (e) =&gt; {\n    card.classList.add(<span class=\"hljs-string\">'is-dragging'<\/span>);\n    e.dataTransfer?.setData(<span class=\"hljs-string\">'text\/plain'<\/span>, image.publicId);\n  });\n\n  <span class=\"hljs-comment\">\/\/ Mobile: tap-to-add to the next empty slot<\/span>\n  card.addEventListener(<span class=\"hljs-string\">'click'<\/span>, () =&gt; this.addToNextSlot(image));\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>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.<\/p>\n<p>When the mosaic is created, the result dialog opens with a spring-bounce scale animation via a named transition:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-16\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/mosaic.ts (excerpt)<\/span>\nassignTransitionName(<span class=\"hljs-keyword\">this<\/span>.resultDialog, <span class=\"hljs-string\">'mosaic-result'<\/span>);\n<span class=\"hljs-keyword\">const<\/span> t = startTransition(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n  <span class=\"hljs-keyword\">this<\/span>.resultImg.src = url;\n  <span class=\"hljs-keyword\">this<\/span>.resultDialog.showModal();\n});\nt?.finished.then(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> clearTransitionName(<span class=\"hljs-keyword\">this<\/span>.resultDialog));\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-16\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-17\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-comment\">\/* src\/styles\/transitions.css (excerpt) *\/<\/span>\n<span class=\"hljs-selector-pseudo\">::view-transition-new(mosaic-result)<\/span> {\n  <span class=\"hljs-attribute\">animation<\/span>: vt-mosaic-in <span class=\"hljs-number\">480ms<\/span> <span class=\"hljs-built_in\">cubic-bezier<\/span>(<span class=\"hljs-number\">0.34<\/span>, <span class=\"hljs-number\">1.56<\/span>, <span class=\"hljs-number\">0.64<\/span>, <span class=\"hljs-number\">1<\/span>) both;\n}\n<span class=\"hljs-keyword\">@keyframes<\/span> vt-mosaic-in {\n  <span class=\"hljs-selector-tag\">from<\/span> { <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">0<\/span>; <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">scale<\/span>(<span class=\"hljs-number\">0.7<\/span>) <span class=\"hljs-built_in\">rotate<\/span>(-<span class=\"hljs-number\">1deg<\/span>); }\n  <span class=\"hljs-selector-tag\">to<\/span>   { <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">1<\/span>; <span class=\"hljs-attribute\">transform<\/span>: <span class=\"hljs-built_in\">scale<\/span>(<span class=\"hljs-number\">1<\/span>)   <span class=\"hljs-built_in\">rotate<\/span>(<span class=\"hljs-number\">0deg<\/span>);  }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-17\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>View the full <code>mosaic.ts<\/code> source: <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/mosaic.ts\">https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/mosaic.ts<\/a><\/p>\n<\/blockquote>\n<h2>The Native <code>&lt;dialog&gt;<\/code> Lightbox<\/h2>\n<p>The lightbox is a standard HTML <code>&lt;dialog&gt;<\/code> element. No custom focus trapping. No scroll-lock JavaScript. No z-index juggling.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-18\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml shcb-wrap-lines\"><span class=\"hljs-comment\">&lt;!-- index.html (excerpt) --&gt;<\/span>\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">dialog<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"lightbox\"<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"lightbox\"<\/span> <span class=\"hljs-attr\">aria-label<\/span>=<span class=\"hljs-string\">\"Photo lightbox\"<\/span> <span class=\"hljs-attr\">aria-modal<\/span>=<span class=\"hljs-string\">\"true\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"lightbox__close\"<\/span> <span class=\"hljs-attr\">aria-label<\/span>=<span class=\"hljs-string\">\"Close lightbox\"<\/span>&gt;<\/span>...<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">figure<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"lightbox__figure\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">img<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"lightbox__img\"<\/span> <span class=\"hljs-attr\">src<\/span>=<span class=\"hljs-string\">\"\"<\/span> <span class=\"hljs-attr\">alt<\/span>=<span class=\"hljs-string\">\"\"<\/span>\/&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">figcaption<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"lightbox__caption\"<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">figcaption<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">figure<\/span>&gt;<\/span>\n  <span class=\"hljs-comment\">&lt;!-- Effects Lab panel slides up from bottom --&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"effects-panel\"<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">\"effects-panel\"<\/span>&gt;<\/span>...<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">dialog<\/span>&gt;<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-18\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-19\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\">dialog.showModal(); <span class=\"hljs-comment\">\/\/ open: focus trapped, Escape closes, ::backdrop shown<\/span>\ndialog.close();     <span class=\"hljs-comment\">\/\/ close<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-19\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><code>@starting-style<\/code> is a CSS rule that defines an element\u2019s styles at the moment it first becomes rendered, giving the browser a \u201cfrom\u201d state to interpolate from:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-20\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-comment\">\/* src\/styles\/gallery.css (excerpt) *\/<\/span>\n<span class=\"hljs-selector-class\">.lightbox<\/span><span class=\"hljs-selector-pseudo\">::backdrop<\/span> {\n  <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-built_in\">rgba<\/span>(<span class=\"hljs-number\">7<\/span>, <span class=\"hljs-number\">7<\/span>, <span class=\"hljs-number\">10<\/span>, <span class=\"hljs-number\">0.93<\/span>);\n  <span class=\"hljs-attribute\">backdrop-filter<\/span>: <span class=\"hljs-built_in\">blur<\/span>(<span class=\"hljs-number\">6px<\/span>);\n  <span class=\"hljs-attribute\">transition<\/span>: opacity <span class=\"hljs-number\">350ms<\/span> ease;\n  <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">1<\/span>;\n}\n\n<span class=\"hljs-keyword\">@starting-style<\/span> {\n  <span class=\"hljs-selector-class\">.lightbox<\/span><span class=\"hljs-selector-pseudo\">::backdrop<\/span> {\n    <span class=\"hljs-attribute\">opacity<\/span>: <span class=\"hljs-number\">0<\/span>; <span class=\"hljs-comment\">\/* browser animates FROM this *\/<\/span>\n  }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-20\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Without <code>@starting-style<\/code>, 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.<\/p>\n<blockquote>\n<p><strong>Pro tip:<\/strong> <code>@starting-style<\/code> works on any element entering the DOM or becoming visible, not just <code>&lt;dialog&gt;<\/code>. It\u2019s the native equivalent of the <code>enter<\/code> class in Vue Transitions or <code>AnimatePresence<\/code> in Framer Motion.<\/p>\n<\/blockquote>\n<h2>Gamification Layer<\/h2>\n<p>Making a demo technically impressive is one thing. Making people want to <em>explore<\/em> it is another. The game layer is specifically designed to reward discovery of Cloudinary features.<\/p>\n<p>The scoring model:<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Action<\/th>\n<th>Points<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Open a new photo<\/td>\n<td>+10 x combo multiplier<\/td>\n<\/tr>\n<tr>\n<td>Navigate within 3 s (builds combo)<\/td>\n<td>+5 x combo multiplier<\/td>\n<\/tr>\n<tr>\n<td>Apply an effect (first time per effect)<\/td>\n<td>+15<\/td>\n<\/tr>\n<tr>\n<td>Build a mosaic<\/td>\n<td>+50<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>The combo incentivizes rapid navigation. The fastest path to points is also the fastest path to seeing all the transitions.<\/p>\n<p>With a toast queue with progress bar, multiple achievements can unlock simultaneously. Rather than overlapping toasts, they queue:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-21\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/game.ts (excerpt)<\/span>\nprivate showNextToast(): <span class=\"hljs-keyword\">void<\/span> {\n  <span class=\"hljs-keyword\">const<\/span> next = <span class=\"hljs-keyword\">this<\/span>.toastQueue.shift();\n  <span class=\"hljs-keyword\">if<\/span> (!next) { <span class=\"hljs-keyword\">this<\/span>.hideToast(); <span class=\"hljs-keyword\">return<\/span>; }\n\n  <span class=\"hljs-keyword\">this<\/span>.isToastVisible = <span class=\"hljs-literal\">true<\/span>;\n  <span class=\"hljs-keyword\">this<\/span>.toastIconEl.textContent = next.icon;\n  <span class=\"hljs-keyword\">this<\/span>.toastNameEl.textContent = next.name;\n\n  <span class=\"hljs-comment\">\/\/ Drain the progress bar over 6 seconds<\/span>\n  <span class=\"hljs-keyword\">this<\/span>.toastProgressEl.style.transition = <span class=\"hljs-string\">'none'<\/span>;\n  <span class=\"hljs-keyword\">this<\/span>.toastProgressEl.style.width = <span class=\"hljs-string\">'100%'<\/span>;\n  requestAnimationFrame(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n    <span class=\"hljs-keyword\">this<\/span>.toastProgressEl.style.transition = <span class=\"hljs-string\">`width<span class=\"hljs-subst\">${TOAST_DURATION_MS}<\/span>ms linear`<\/span>;\n    <span class=\"hljs-keyword\">this<\/span>.toastProgressEl.style.width = <span class=\"hljs-string\">'0%'<\/span>;\n  });\n\n  <span class=\"hljs-keyword\">this<\/span>.toastTimerId = setTimeout(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> <span class=\"hljs-keyword\">this<\/span>.showNextToast(), TOAST_DURATION_MS);\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-21\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The <code>requestAnimationFrame<\/code> trick is necessary: setting <code>transition: none<\/code> and then <code>width: 100%<\/code> in the same frame means the browser never renders the full bar. The rAF forces a paint before the drain animation starts.<\/p>\n<p>You can even add floating score popups, which is when points are awarded, a \u201c+10 x3\u201d label floats upward from the thumbnail using the Web Animations API:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-22\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/game.ts (excerpt)<\/span>\nel.animate(\n  &#91;\n    { <span class=\"hljs-attr\">opacity<\/span>: <span class=\"hljs-number\">1<\/span>, <span class=\"hljs-attr\">transform<\/span>: <span class=\"hljs-string\">'translate(-50%, 0) scale(1)'<\/span> },\n    { <span class=\"hljs-attr\">opacity<\/span>: <span class=\"hljs-number\">0<\/span>, <span class=\"hljs-attr\">transform<\/span>: <span class=\"hljs-string\">'translate(-50%, -72px) scale(1.2)'<\/span> },\n  ],\n  { <span class=\"hljs-attr\">duration<\/span>: <span class=\"hljs-number\">1100<\/span>, <span class=\"hljs-attr\">easing<\/span>: <span class=\"hljs-string\">'cubic-bezier(0.16, 1, 0.3, 1)'<\/span>, <span class=\"hljs-attr\">fill<\/span>: <span class=\"hljs-string\">'forwards'<\/span> },\n).finished.then(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> el.remove());\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-22\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>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.<\/p>\n<blockquote>\n<p>View the full <code>game.ts<\/code> source: <a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/game.ts\">https:\/\/github.com\/musebe\/view-transition-gallery\/blob\/main\/src\/game.ts<\/a><\/p>\n<\/blockquote>\n<h2>Performance and Accessibility<\/h2>\n<p>Every animation in this project (View Transitions and CSS alike) respects the user\u2019s motion preference:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-23\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-comment\">\/* src\/styles\/transitions.css (excerpt) *\/<\/span>\n<span class=\"hljs-keyword\">@media<\/span> (<span class=\"hljs-attribute\">prefers-reduced-motion:<\/span> reduce) {\n  <span class=\"hljs-selector-pseudo\">::view-transition-group(<\/span>*),\n  <span class=\"hljs-selector-pseudo\">::view-transition-old(<\/span>*),\n  <span class=\"hljs-selector-pseudo\">::view-transition-new(<\/span>*) {\n    <span class=\"hljs-attribute\">animation-duration<\/span>: <span class=\"hljs-number\">0.001ms<\/span> <span class=\"hljs-meta\">!important<\/span>;\n    <span class=\"hljs-attribute\">animation-delay<\/span>:    <span class=\"hljs-number\">0ms<\/span> <span class=\"hljs-meta\">!important<\/span>;\n  }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-23\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Setting duration to <code>0.001ms<\/code> rather than <code>0<\/code> avoids a Chrome bug where zero-duration View Transitions can flicker.<\/p>\n<p>To achieve lazy loading and decoding, every thumbnail uses <code>loading=&quot;lazy&quot;<\/code> and <code>decoding=&quot;async&quot;<\/code>. Set in the TypeScript rather than the HTML, so no image outside the viewport blocks the main thread:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-24\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/gallery.ts (excerpt)<\/span>\nimg.loading  = <span class=\"hljs-string\">'lazy'<\/span>;\nimg.decoding = <span class=\"hljs-string\">'async'<\/span>;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-24\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The lightbox responds to keyboard events on the document:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-25\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-built_in\">document<\/span>.addEventListener(<span class=\"hljs-string\">'keydown'<\/span>, (e) =&gt; {\n  <span class=\"hljs-keyword\">if<\/span> (!<span class=\"hljs-keyword\">this<\/span>.dialog.open) <span class=\"hljs-keyword\">return<\/span>;\n  <span class=\"hljs-keyword\">if<\/span> (e.key === <span class=\"hljs-string\">'Escape'<\/span>)     { e.preventDefault(); <span class=\"hljs-keyword\">this<\/span>.closePhoto(); }\n  <span class=\"hljs-keyword\">if<\/span> (e.key === <span class=\"hljs-string\">'ArrowRight'<\/span>) <span class=\"hljs-keyword\">this<\/span>.navigate(<span class=\"hljs-number\">1<\/span>);\n  <span class=\"hljs-keyword\">if<\/span> (e.key === <span class=\"hljs-string\">'ArrowLeft'<\/span>)  <span class=\"hljs-keyword\">this<\/span>.navigate(<span class=\"hljs-number\">-1<\/span>);\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-25\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The <code>if (!this.dialog.open)<\/code> guard ensures the handler is a no-op when the lightbox is closed. No need to add or remove the listener on open\/close.<\/p>\n<p>Lastly, every interactive element has an explicit <code>aria-label<\/code>. The HUD uses <code>aria-live=&quot;polite&quot;<\/code> on the score and collected-count so screen readers announce changes without interrupting. The progress bar uses the full <code>role=&quot;progressbar&quot;<\/code> pattern with <code>aria-valuemin<\/code>, <code>aria-valuemax<\/code>, and <code>aria-valuenow<\/code>.<\/p>\n<p>To make sure everything automatically formats, use Cloudinary\u2019s <code>f_auto<\/code> 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 <code>Accept<\/code> header. No JavaScript, no <code>&lt;picture&gt;<\/code> elements. Typical savings: 30 to 50 percent over JPEG at equivalent visual quality.<\/p>\n<h2>Key Takeaways<\/h2>\n<p>You\u2019ve built a project with Cloudinary, the View Transition API, and Vanilla TypeScript. (No animation libraries were harmed in the making of this demo!)<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Technique<\/th>\n<th>How<\/th>\n<th>Use When<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><strong>Circle-expand<\/strong><\/td>\n<td>JS <code>clip-path<\/code> on <code>::view-transition-new(root)<\/code> via <code>.ready<\/code><\/td>\n<td>Opening from a specific origin point<\/td>\n<\/tr>\n<tr>\n<td><strong>Directional slide<\/strong><\/td>\n<td>CSS <code>@keyframes<\/code> + <code>data-<\/code> attribute on <code>&lt;html&gt;<\/code><\/td>\n<td>Navigating a sequence with direction<\/td>\n<\/tr>\n<tr>\n<td><strong>Scoped element<\/strong><\/td>\n<td><code>view-transition-name<\/code> on one element only<\/td>\n<td>Changing content inside a larger, unchanged context<\/td>\n<\/tr>\n<tr>\n<td><strong>Named dialog reveal<\/strong><\/td>\n<td><code>view-transition-name<\/code> on a <code>&lt;dialog&gt;<\/code><\/td>\n<td>Modal that should animate distinctly from the page<\/td>\n<\/tr>\n<tr>\n<td><strong>Cross-page (bonus)<\/strong><\/td>\n<td><code>@view-transition { navigation: auto; }<\/code> in CSS<\/td>\n<td>MPA navigation, covered in the View Transition API Level 2 spec<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>Ready to level up your media workflow? <a href=\"https:\/\/cloudinary.com\/users\/register_free\">Start using Cloudinary for free<\/a> and build better visual experiences today.<\/p>\n<h3>What to Build Next<\/h3>\n<ul>\n<li>\n<strong>Video gallery.<\/strong> The same circle-expand works on <code>&lt;video&gt;<\/code> elements.<\/li>\n<li>\n<strong>Page transitions.<\/strong> Add <code>@view-transition { navigation: auto; }<\/code> to a multi-page app for free cross-page animations.<\/li>\n<li>\n<strong>Shared-element morphs.<\/strong> Assign the same <code>view-transition-name<\/code> to a thumbnail and the hero image on a detail page to get an automatic position and size morph.<\/li>\n<li>\n<strong>Cloudinary named transformations.<\/strong> Store complex effect chains as named transforms and call them with <code>t_my-transform<\/code> to keep URLs short.<\/li>\n<\/ul>\n<p><strong>Resources:<\/strong><\/p>\n<ul>\n<li>\n<a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/View_Transition_API\">View Transition API on MDN<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/developer.chrome.com\/docs\/web-platform\/view-transitions\">Smooth transitions with the View Transition API on web.dev<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/cloudinary.com\/documentation\/transformation_reference\">Cloudinary Transformation Reference<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/cloudinary.com\/documentation\/layers\">Cloudinary Image Overlay Documentation<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/view-transition-gallery.vercel.app\/\">Live Demo<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/github.com\/musebe\/view-transition-gallery\">Full Source Code on GitHub<\/a>\n<\/li>\n<\/ul>\n<\/div>\n\n\n<style>\n.faqs {\n    padding: 30px 60px;\n    margin-top: 40px;\n    background: var(--color-background-offset);\n    border-radius: 20px;\n}\n\n#frequently_asked_questions {\n    margin-bottom: 20px;\n}\n\n.question {\n    margin-bottom: 12px;\n    border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n}\n\n.question summary {\n    position: relative;\n    padding: 18px 40px 18px 0;\n    cursor: pointer;\n    font-weight: 700;\n    list-style: none;\n}\n\n.question summary::-webkit-details-marker {\n    display: none;\n}\n\n.question summary::after {\n    content: \"+\";\n    position: absolute;\n    top: 50%;\n    right: 0;\n    transform: translateY(-50%);\n    font-size: 24px;\n    font-weight: 400;\n    line-height: 1;\n}\n\n.question[open] summary::after {\n    content: \"\u2212\";\n}\n\n.answer {\n    padding: 0 40px 20px 0;\n}\n<\/style>\n\n<div class=\"faqs\">\n\n<h2 id=\"frequently_asked_questions\">Frequently Asked Questions<\/h2>\n\n<details class=\"question\">\n    <summary>What is the View Transition API and how does it work?<\/summary>\n    <div class=\"answer\">\n        The View Transition API is a browser-native JavaScript API that animates between two DOM states. You call <code>document.startViewTransition(callback)<\/code>, 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.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How do you create a circle-reveal animation with the View Transition API?<\/summary>\n    <div class=\"answer\">\n        After calling <code>document.startViewTransition()<\/code>, wait for the <code>.ready<\/code> promise, then use <code>document.documentElement.animate()<\/code> with a <code>clip-path<\/code> keyframe on the <code>::view-transition-new(root)<\/code> pseudo-element.\n        <br><br>\n        Start with <code>circle(r0px at cx cy)<\/code>, where <code>cx<\/code> and <code>cy<\/code> are the origin point, such as the center of a clicked thumbnail, and animate outward to <code>circle(r1px at cx cy)<\/code>, where <code>r1<\/code> is large enough to cover the full viewport.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>What is the difference between <code>::view-transition-old<\/code> and <code>::view-transition-new<\/code>?<\/summary>\n    <div class=\"answer\">\n        <code>::view-transition-old<\/code> is a frozen screenshot of the page state before the DOM update. <code>::view-transition-new<\/code> represents the page state after the DOM update. Both are painted as CSS pseudo-elements layered over the page during the transition.\n        <br><br>\n        You can animate them independently to create effects such as sliding the old state out while sliding the new state in.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How does <code>view-transition-name<\/code> scope a transition to one element?<\/summary>\n    <div class=\"answer\">\n        When you assign <code>view-transition-name: my-name<\/code> to an element, the browser gives that element its own capture group, separate from the root. During the transition, that element&#8217;s old and new states can be animated independently while everything else on the page remains part of its respective transition group.\n        <br><br>\n        Every active <code>view-transition-name<\/code> must be unique on the page at transition time. Duplicate names can cause the transition for those elements to be skipped.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How does Cloudinary serve the right image format automatically?<\/summary>\n    <div class=\"answer\">\n        Adding <code>f_auto<\/code> to a Cloudinary delivery URL instructs Cloudinary to negotiate an appropriate image format based on the requesting browser&#8217;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.\n        <br><br>\n        This allows format optimization to happen automatically without requiring JavaScript or a <code>&lt;picture&gt;<\/code> element.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How do you composite four images into a single Cloudinary URL?<\/summary>\n    <div class=\"answer\">\n        Use Cloudinary&#8217;s overlay parameter, <code>l_<\/code>, chained once per image. Each overlay can specify the source public ID, a resize transformation, and <code>fl_layer_apply<\/code> with <code>x<\/code> and <code>y<\/code> offsets to position the layer on the canvas.\n        <br><br>\n        The browser requests the final composited asset through a single delivery URL, while Cloudinary handles the cropping, positioning, transformations, and format delivery.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>What is <code>@starting-style<\/code> and why is it useful for dialog animations?<\/summary>\n    <div class=\"answer\">\n        <code>@starting-style<\/code> 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 <code>&lt;dialog&gt;<\/code> backdrop may not have a previous rendered state from which the browser can animate.\n        <br><br>\n        For example, setting <code>opacity: 0<\/code> inside <code>@starting-style<\/code> 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.\n    <\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Does the View Transition API work in all browsers?<\/summary>\n    <div class=\"answer\">\n        Browser support for the View Transition API varies by browser and version, so it should be implemented as a progressive enhancement. You can check for support with <code>typeof document.startViewTransition === 'function'<\/code> and fall back to a direct DOM update when the API isn&#8217;t available.\n        <br><br>\n        This ensures the underlying functionality continues to work even when the transition animation isn&#8217;t supported.\n    <\/div>\n<\/details>\n\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Frequently Asked Questions What is the View Transition API and how does it work? The View Transition API is a browser-native JavaScript API that animates between two DOM states. You call 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, [&hellip;]<\/p>\n","protected":false},"author":87,"featured_media":40434,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[332,370,165],"class_list":["post-40371","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-api","tag-image","tag-image-transformation"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v28.3 (Yoast SEO v28.3) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary<\/title>\n<meta name=\"description\" content=\"Learn how to build an image gallery: circle-reveal transitions, scoped element animations, live effect cross-fades, and a four-image mosaic from a single URL. No libraries needed.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary\" \/>\n<meta property=\"og:description\" content=\"Learn how to build an image gallery: circle-reveal transitions, scoped element animations, live effect cross-fades, and a four-image mosaic from a single URL. No libraries needed.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-09-10T14:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA\" \/>\n\t<meta property=\"og:image:width\" content=\"2000\" \/>\n\t<meta property=\"og:image:height\" content=\"1100\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/jpeg\" \/>\n<meta name=\"author\" content=\"melindapham\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/person\\\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary\",\"datePublished\":\"2026-09-10T14:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary\"},\"wordCount\":569,\"publisher\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1788983742\\\/Web_Assets\\\/blog\\\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\\\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA\",\"keywords\":[\"API\",\"Image\",\"Image Transformation\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2026\",\"copyrightHolder\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary\",\"url\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary\",\"name\":\"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1788983742\\\/Web_Assets\\\/blog\\\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\\\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA\",\"datePublished\":\"2026-09-10T14:00:00+00:00\",\"description\":\"Learn how to build an image gallery: circle-reveal transitions, scoped element animations, live effect cross-fades, and a four-image mosaic from a single URL. No libraries needed.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary#primaryimage\",\"url\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1788983742\\\/Web_Assets\\\/blog\\\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\\\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA\",\"contentUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1788983742\\\/Web_Assets\\\/blog\\\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\\\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA\",\"width\":2000,\"height\":1100},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/view-transition-gallery-cloudinary#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/\",\"name\":\"Cloudinary Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#organization\",\"name\":\"Cloudinary Blog\",\"url\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1649718331\\\/Web_Assets\\\/blog\\\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\\\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"contentUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1649718331\\\/Web_Assets\\\/blog\\\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\\\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"width\":312,\"height\":60,\"caption\":\"Cloudinary Blog\"},\"image\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/person\\\/0d5ad601e4c3b5be89245dfb14be42d9\",\"name\":\"melindapham\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"caption\":\"melindapham\"}}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary","description":"Learn how to build an image gallery: circle-reveal transitions, scoped element animations, live effect cross-fades, and a four-image mosaic from a single URL. No libraries needed.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary","og_locale":"en_US","og_type":"article","og_title":"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary","og_description":"Learn how to build an image gallery: circle-reveal transitions, scoped element animations, live effect cross-fades, and a four-image mosaic from a single URL. No libraries needed.","og_url":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary","og_site_name":"Cloudinary Blog","article_published_time":"2026-09-10T14:00:00+00:00","og_image":[{"width":2000,"height":1100,"url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA","type":"image\/jpeg"}],"author":"melindapham","twitter_card":"summary_large_image","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary","datePublished":"2026-09-10T14:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary"},"wordCount":569,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA","keywords":["API","Image","Image Transformation"],"inLanguage":"en-US","copyrightYear":"2026","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary","url":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary","name":"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary#primaryimage"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA","datePublished":"2026-09-10T14:00:00+00:00","description":"Learn how to build an image gallery: circle-reveal transitions, scoped element animations, live effect cross-fades, and a four-image mosaic from a single URL. No libraries needed.","breadcrumb":{"@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA","width":2000,"height":1100},{"@type":"BreadcrumbList","@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary"}]},{"@type":"WebSite","@id":"https:\/\/cloudinary.com\/blog\/#website","url":"https:\/\/cloudinary.com\/blog\/","name":"Cloudinary Blog","description":"","publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/cloudinary.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/cloudinary.com\/blog\/#organization","name":"Cloudinary Blog","url":"https:\/\/cloudinary.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","width":312,"height":60,"caption":"Cloudinary Blog"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9","name":"melindapham","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","caption":"melindapham"}}]}},"parsely":{"version":"1.1.0","canonical_url":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary","smart_links":{"inbound":0,"outbound":0},"traffic_boost_suggestions_count":0,"meta":{"@context":"https:\/\/schema.org","@type":"NewsArticle","headline":"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary","url":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA&w=150&h=150&crop=1","image":{"@type":"ImageObject","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA"},"articleSection":"Uncategorized","author":[{"@type":"Person","name":"melindapham"}],"creator":["melindapham"],"publisher":{"@type":"Organization","name":"Cloudinary Blog","logo":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA"},"keywords":["api","image","image transformation"],"dateCreated":"2026-09-10T14:00:00Z","datePublished":"2026-09-10T14:00:00Z","dateModified":"2026-09-10T14:00:00Z"},"rendered":"<meta name=\"parsely-title\" content=\"Buttery-Smooth Image Gallery Transitions With the View Transition API and Cloudinary\" \/>\n<meta name=\"parsely-link\" content=\"https:\/\/cloudinary.com\/blog\/view-transition-gallery-cloudinary\" \/>\n<meta name=\"parsely-type\" content=\"post\" \/>\n<meta name=\"parsely-image-url\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA&w=150&amp;h=150&amp;crop=1\" \/>\n<meta name=\"parsely-pub-date\" content=\"2026-09-10T14:00:00Z\" \/>\n<meta name=\"parsely-section\" content=\"Uncategorized\" \/>\n<meta name=\"parsely-tags\" content=\"api,image,image transformation\" \/>\n<meta name=\"parsely-author\" content=\"melindapham\" \/>","tracker_url":"https:\/\/cdn.parsely.com\/keys\/cloudinary.com\/p.js"},"jetpack_featured_media_url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1788983742\/Web_Assets\/blog\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images\/Blog_Buttery-Smooth_Image_Gallery_Transitions_with_the_View_Transition_API_and_Cloudinary_Responsive_Images.jpg?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40371","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/users\/87"}],"replies":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/comments?post=40371"}],"version-history":[{"count":3,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40371\/revisions"}],"predecessor-version":[{"id":40436,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40371\/revisions\/40436"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/40434"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=40371"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=40371"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=40371"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}