View Transitions Gallery
A photo gallery that morphs a clicked thumbnail into a full-bleed detail hero using the View Transitions API, with shared-element name matching, crossfading metadata, keyboard navigation, and a graceful crossfade-free fallback when the browser lacks support.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1100px 600px at 15% -10%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(900px 500px at 110% 10%, rgba(34, 211, 238, 0.1), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.86em;
color: var(--accent-2);
background: rgba(34, 211, 238, 0.08);
border: 1px solid var(--line);
padding: 0.05em 0.4em;
border-radius: var(--r-sm);
}
.app {
max-width: 1040px;
margin: 0 auto;
padding: clamp(20px, 4vw, 44px) clamp(16px, 4vw, 32px) 64px;
}
/* ---------- Header ---------- */
.app__head {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
}
.app__title {
margin: 0;
font-size: clamp(1.6rem, 4vw, 2.3rem);
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(100deg, var(--ink), #b9a7ff 70%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.app__sub {
margin: 8px 0 0;
max-width: 46ch;
font-size: 0.94rem;
color: var(--muted);
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 0.8rem;
font-weight: 500;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
}
.status__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.5);
}
.status.is-ok .status__dot {
background: var(--ok);
animation: pulse 2.4s infinite;
}
.status.is-warn .status__dot {
background: var(--warn);
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.45);
}
70% {
box-shadow: 0 0 0 7px rgba(52, 211, 153, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0);
}
}
/* ---------- Grid ---------- */
.grid__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.card {
position: relative;
display: block;
width: 100%;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
cursor: pointer;
background: var(--surface);
color: var(--ink);
text-align: left;
aspect-ratio: 4 / 5;
transition:
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.22s ease,
box-shadow 0.22s ease;
}
.card:nth-child(3n + 1) {
aspect-ratio: 4 / 3;
}
.card__img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.card__label {
position: absolute;
inset: auto 0 0 0;
padding: 30px 14px 12px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 8px;
background: linear-gradient(to top, rgba(6, 6, 9, 0.82), transparent);
font-size: 0.82rem;
font-weight: 600;
}
.card__id {
font-family: "JetBrains Mono", monospace;
font-size: 0.68rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.72);
}
.card:hover,
.card:focus-visible {
transform: translateY(-4px);
border-color: var(--line-2);
box-shadow:
0 14px 34px -12px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(139, 92, 246, 0.35);
outline: none;
}
.card:focus-visible {
box-shadow:
0 14px 34px -12px rgba(0, 0, 0, 0.7),
0 0 0 3px rgba(139, 92, 246, 0.6);
}
.card:active {
transform: translateY(-1px);
}
/* ---------- Detail ---------- */
.detail__back {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 18px;
padding: 8px 14px 8px 10px;
font: inherit;
font-size: 0.86rem;
font-weight: 600;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.detail__back:hover {
background: var(--surface-2);
border-color: var(--line-2);
}
.detail__back:active {
transform: scale(0.97);
}
.detail__back:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.hero {
margin: 0;
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: 0 30px 80px -40px rgba(0, 0, 0, 0.9);
}
.hero__media {
position: relative;
min-height: 340px;
}
.hero__meta {
padding: 24px 24px 26px;
display: flex;
flex-direction: column;
gap: 14px;
}
.hero__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.hero__title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.015em;
}
.hero__id {
font-family: "JetBrains Mono", monospace;
font-size: 0.74rem;
color: var(--accent-2);
white-space: nowrap;
}
.hero__desc {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
}
.specs {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
font-size: 0.84rem;
border-top: 1px solid var(--line);
padding-top: 14px;
}
.specs dt {
color: var(--muted);
}
.specs dd {
margin: 0;
font-weight: 500;
text-align: right;
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
}
.palette {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 4px;
}
.palette__sw {
flex: 1;
height: 30px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
position: relative;
cursor: default;
}
.palette__sw span {
position: absolute;
inset: auto 0 -18px 0;
text-align: center;
font-family: "JetBrains Mono", monospace;
font-size: 0.6rem;
color: var(--muted);
opacity: 0;
transition: opacity 0.15s ease;
}
.palette__sw:hover span {
opacity: 1;
}
.detail__nav {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 22px;
}
.navbtn {
padding: 9px 18px;
font: inherit;
font-size: 0.86rem;
font-weight: 600;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.navbtn:hover {
background: #262933;
border-color: var(--line-2);
}
.navbtn:active {
transform: scale(0.96);
}
.navbtn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.detail__count {
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
color: var(--muted);
min-width: 56px;
text-align: center;
}
[hidden] {
display: none !important;
}
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
.hero {
grid-template-columns: 1fr;
}
.hero__media {
min-height: 260px;
}
}
@media (max-width: 520px) {
.grid__list {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.card,
.card:nth-child(3n + 1) {
aspect-ratio: 1 / 1;
}
.app__head {
flex-direction: column;
}
}
@media (max-width: 360px) {
.grid__list {
grid-template-columns: 1fr;
}
}
/* ---------- View Transitions ---------- */
@media (prefers-reduced-motion: no-preference) {
::view-transition-group(*) {
animation-duration: 0.42s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.32s;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.001s !important;
}
}
/* Fallback crossfade for browsers without the View Transitions API */
.view-swap {
animation: fadeSwap 0.3s ease both;
}
@keyframes fadeSwap {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}// View Transitions Gallery — morph a grid thumbnail into a detail hero
// using document.startViewTransition() with a shared view-transition-name.
/** @typedef {{id:string,title:string,desc:string,camera:string,lens:string,iso:string,shutter:string,colors:string[]}} Photo */
/** @type {Photo[]} */
const PHOTOS = [
{
id: "APT-014",
title: "Cobalt Terraces",
desc: "Terraced salt flats at first light, mineral blues bleeding into a pale horizon.",
camera: "Sony A7 IV",
lens: "24mm f/1.4",
iso: "100",
shutter: "1/250s",
colors: ["#1e3a8a", "#3b82f6", "#93c5fd", "#e0f2fe"],
},
{
id: "APT-027",
title: "Ember Dunes",
desc: "Wind-carved ridgelines glowing amber as the desert sun drops below the crest.",
camera: "Fuji X-T5",
lens: "56mm f/1.2",
iso: "160",
shutter: "1/500s",
colors: ["#7c2d12", "#ea580c", "#fb923c", "#fed7aa"],
},
{
id: "APT-039",
title: "Fern Cathedral",
desc: "Old-growth canopy filtering green light through a lattice of unfurling ferns.",
camera: "Canon R5",
lens: "35mm f/1.8",
iso: "400",
shutter: "1/125s",
colors: ["#14532d", "#16a34a", "#4ade80", "#bbf7d0"],
},
{
id: "APT-052",
title: "Neon Underpass",
desc: "Rain-slicked concrete reflecting a corridor of magenta and cyan tube light.",
camera: "Nikon Z8",
lens: "50mm f/1.8",
iso: "800",
shutter: "1/60s",
colors: ["#831843", "#db2777", "#22d3ee", "#a5f3fc"],
},
{
id: "APT-061",
title: "Glacier Seam",
desc: "A crevasse splitting ancient ice, its core glowing an impossible turquoise.",
camera: "Sony A7R V",
lens: "16mm f/2.8",
iso: "64",
shutter: "1/320s",
colors: ["#0e7490", "#06b6d4", "#67e8f9", "#ecfeff"],
},
{
id: "APT-078",
title: "Violet Static",
desc: "Long-exposure aurora smeared across a starfield in ribbons of violet and rose.",
camera: "Fuji GFX 100",
lens: "23mm f/4",
iso: "1600",
shutter: "8s",
colors: ["#4c1d95", "#7c3aed", "#c4b5fd", "#f5f3ff"],
},
];
/** Build a deterministic SVG "photo" from a palette — no network needed. */
function makeArt(photo, seed) {
const [a, b, c, d] = photo.colors;
const gid = `g${seed}`;
const rid = `r${seed}`;
// pseudo-random blobs from the id
const n = photo.id.replace(/\D/g, "") | 0;
const cx1 = 20 + (n % 40);
const cy1 = 25 + ((n * 3) % 40);
const cx2 = 60 + (n % 30);
const cy2 = 55 + ((n * 7) % 35);
return `
<svg class="card__img" viewBox="0 0 100 120" preserveAspectRatio="xMidYMid slice"
role="img" aria-label="${photo.title}">
<defs>
<linearGradient id="${gid}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="${a}"/>
<stop offset="0.55" stop-color="${b}"/>
<stop offset="1" stop-color="${c}"/>
</linearGradient>
<radialGradient id="${rid}" cx="0.3" cy="0.25" r="0.9">
<stop offset="0" stop-color="${d}" stop-opacity="0.55"/>
<stop offset="0.5" stop-color="${c}" stop-opacity="0.15"/>
<stop offset="1" stop-color="${a}" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="100" height="120" fill="url(#${gid})"/>
<circle cx="${cx1}" cy="${cy1}" r="34" fill="${d}" opacity="0.18"/>
<circle cx="${cx2}" cy="${cy2}" r="42" fill="${a}" opacity="0.28"/>
<rect width="100" height="120" fill="url(#${rid})"/>
</svg>`;
}
const gridView = document.getElementById("gridView");
const detailView = document.getElementById("detailView");
const gridList = document.getElementById("gridList");
const statusEl = document.getElementById("status");
const statusText = document.getElementById("statusText");
const supportsVT = typeof document.startViewTransition === "function";
let currentIndex = -1;
// --- Status line -----------------------------------------------------------
if (supportsVT) {
statusEl.classList.add("is-ok");
statusText.textContent = "View Transitions API active";
} else {
statusEl.classList.add("is-warn");
statusText.textContent = "API unsupported — using crossfade fallback";
}
// --- Build the grid --------------------------------------------------------
PHOTOS.forEach((photo, i) => {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "card";
btn.dataset.index = String(i);
btn.setAttribute("aria-label", `Open ${photo.title}, photo ${photo.id}`);
btn.innerHTML =
makeArt(photo, i) +
`<span class="card__label">${photo.title}<span class="card__id">${photo.id}</span></span>`;
btn.addEventListener("click", () => openDetail(i));
li.appendChild(btn);
gridList.appendChild(li);
});
// --- Transition helper -----------------------------------------------------
/**
* Run a DOM-mutating callback inside a view transition when available,
* otherwise apply a lightweight CSS crossfade as a graceful fallback.
*/
function transition(mutate, incomingEl) {
if (supportsVT && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
document.startViewTransition(mutate);
return;
}
mutate();
if (incomingEl) {
incomingEl.classList.remove("view-swap");
// force reflow so the animation re-triggers
void incomingEl.offsetWidth;
incomingEl.classList.add("view-swap");
}
}
// --- Open detail (grid → detail) ------------------------------------------
function openDetail(index) {
const photo = PHOTOS[index];
const card = gridList.querySelector(`.card[data-index="${index}"]`);
// Tag the clicked thumbnail as the shared element BEFORE the snapshot.
if (card) card.style.viewTransitionName = "hero-media";
transition(() => {
currentIndex = index;
renderDetail(photo, index);
gridView.hidden = true;
detailView.hidden = false;
}, detailView);
// Clean up the name after the old snapshot is captured.
requestAnimationFrame(() => {
if (card) card.style.viewTransitionName = "";
});
}
// --- Close detail (detail → grid) -----------------------------------------
function closeDetail() {
const returningCard = gridList.querySelector(
`.card[data-index="${currentIndex}"]`
);
if (returningCard) returningCard.style.viewTransitionName = "hero-media";
transition(() => {
detailView.hidden = true;
gridView.hidden = false;
}, gridView);
requestAnimationFrame(() => {
if (returningCard) {
returningCard.style.viewTransitionName = "";
returningCard.focus();
}
});
currentIndex = -1;
}
// --- Navigate between photos in detail view --------------------------------
function step(delta) {
const next = (currentIndex + delta + PHOTOS.length) % PHOTOS.length;
transition(() => {
currentIndex = next;
renderDetail(PHOTOS[next], next);
}, document.getElementById("heroFig"));
}
// --- Render the detail panel ----------------------------------------------
function renderDetail(photo, index) {
const media = document.getElementById("heroMedia");
media.innerHTML = makeArt(photo, index);
media.style.viewTransitionName = "hero-media";
document.getElementById("heroTitle").textContent = photo.title;
document.getElementById("heroId").textContent = photo.id;
document.getElementById("heroDesc").textContent = photo.desc;
document.getElementById("detailCount").textContent =
`${index + 1} / ${PHOTOS.length}`;
document.getElementById("heroSpecs").innerHTML = `
<dt>Camera</dt><dd>${photo.camera}</dd>
<dt>Lens</dt><dd>${photo.lens}</dd>
<dt>ISO</dt><dd>${photo.iso}</dd>
<dt>Shutter</dt><dd>${photo.shutter}</dd>`;
document.getElementById("heroPalette").innerHTML = photo.colors
.map(
(c) =>
`<div class="palette__sw" style="background:${c}"><span>${c}</span></div>`
)
.join("");
}
// --- Wiring ----------------------------------------------------------------
document.getElementById("backBtn").addEventListener("click", closeDetail);
document.getElementById("prevBtn").addEventListener("click", () => step(-1));
document.getElementById("nextBtn").addEventListener("click", () => step(1));
// Keyboard: roving arrow-key focus on the grid, Esc/arrows in detail.
gridList.addEventListener("keydown", (e) => {
const cards = [...gridList.querySelectorAll(".card")];
const active = document.activeElement;
let idx = cards.indexOf(active);
if (idx === -1) return;
const cols = getComputedStyle(gridList).gridTemplateColumns.split(" ").length;
let target = null;
if (e.key === "ArrowRight") target = cards[Math.min(idx + 1, cards.length - 1)];
else if (e.key === "ArrowLeft") target = cards[Math.max(idx - 1, 0)];
else if (e.key === "ArrowDown") target = cards[Math.min(idx + cols, cards.length - 1)];
else if (e.key === "ArrowUp") target = cards[Math.max(idx - cols, 0)];
if (target) {
e.preventDefault();
target.focus();
}
});
document.addEventListener("keydown", (e) => {
if (detailView.hidden) return;
if (e.key === "Escape") closeDetail();
else if (e.key === "ArrowRight") step(1);
else if (e.key === "ArrowLeft") step(-1);
});
// Reveal the grid once wired up.
gridView.hidden = false;<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>View Transitions Gallery</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="app" role="main">
<header class="app__head">
<div class="app__titles">
<p class="eyebrow">View Transitions API</p>
<h1 class="app__title">Aperture Gallery</h1>
<p class="app__sub">
Click a frame to morph it into the detail hero. Uses
<code>document.startViewTransition()</code> with a shared
<code>view-transition-name</code>.
</p>
</div>
<div class="status" id="status" role="status" aria-live="polite">
<span class="status__dot" id="statusDot" aria-hidden="true"></span>
<span id="statusText">Checking support…</span>
</div>
</header>
<!-- GRID VIEW -->
<section
class="grid"
id="gridView"
aria-label="Photo gallery grid"
hidden
>
<ul class="grid__list" id="gridList" role="list"></ul>
</section>
<!-- DETAIL VIEW -->
<section
class="detail"
id="detailView"
aria-label="Photo detail"
hidden
>
<button class="detail__back" id="backBtn" type="button">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
d="M15 6l-6 6 6 6"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Back to grid
</button>
<div class="detail__stage">
<figure class="hero" id="heroFig">
<div class="hero__media" id="heroMedia"></div>
<figcaption class="hero__meta" id="heroMeta">
<div class="hero__row">
<h2 class="hero__title" id="heroTitle"></h2>
<span class="hero__id" id="heroId"></span>
</div>
<p class="hero__desc" id="heroDesc"></p>
<dl class="specs" id="heroSpecs"></dl>
<div class="palette" id="heroPalette" aria-label="Color palette"></div>
</figcaption>
</figure>
</div>
<nav class="detail__nav" aria-label="Photo navigation">
<button class="navbtn" id="prevBtn" type="button" aria-label="Previous photo">‹ Prev</button>
<span class="detail__count" id="detailCount"></span>
<button class="navbtn" id="nextBtn" type="button" aria-label="Next photo">Next ›</button>
</nav>
</section>
</main>
<script src="script.js"></script>
</body>
</html>View Transitions Gallery
This component is a compact curated gallery that demonstrates the browser’s native View Transitions API (document.startViewTransition). Click any thumbnail and the DOM swaps from a masonry grid into a single detail view — but instead of a jarring reflow, the clicked image smoothly morphs into a large hero while its caption, palette, and metadata crossfade into place. The trick is assigning a matching view-transition-name to the thumbnail and the hero so the browser treats them as the same “shared element” across two document states.
The interaction is fully keyboard-driven: arrow keys move focus, Enter opens, and Escape returns to the grid with the reverse transition playing automatically. A live status line reports whether the API is active, and the whole experience degrades gracefully — if startViewTransition is missing, the state change still happens instantly with a light opacity crossfade, so nothing breaks on older engines.
Under the hood it showcases three pieces of the platform: the imperative startViewTransition(callback) entry point, the ::view-transition-group/::view-transition-old/::view-transition-new pseudo-elements for tuning timing and easing, and dynamic view-transition-name assignment so only the relevant element animates. It is a self-contained, dependency-free reference for building app-like page-to-page motion with vanilla JS and CSS.