View Transition:SPA 範例
日期:2025-05-05
| 應用案例
1. Dynamic Text Transition (SPA)
- HTML
<h1 class="sentence"> <span>Stay</span> <!-- 具有highlight ID的span元素,這是會被動態替換的文字 --> <span id="highlight">Curious</span> <span>and keep exploring.</span> </h1>
- CSS (SCSS)
// 設定 timing-function 變數 $timing-function: linear( 0, 0.6832 7.89%, 0.9171 11.07%, 1.0251, 1.1058 14.9%, 1.1619 16.86%, 1.1945 18.91%, 1.2024 20.02%, 1.2043 21.18%, 1.1907, 1.1598 26.27%, 1.0604 32.59%, 1.0172 35.84%, 0.9839 39.49%, 0.967 43.26%, 0.9639 45.77%, 0.9661 48.59%, 0.9963 60.54%, 1.0054 67.42%, 1 ); body { display: flex; align-items: center; min-height: 100dvh; h1 { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5ch; // 設定字句間距為半個字元寬度 margin-left: 4rem; font: bold 3rem/1.2 monospace, Arial; span { contain: paint; // 最佳化渲染效能,僅重新渲染此元素 &#highlight { padding: 12px; border-radius: 12px; background-color: #ff5a45; color: #fff; view-transition-name: highlight; // 設定 view-transition-name } &:last-of-type { view-transition-name: last; // 設定 view-transition-name } } } } @keyframes slide-in { from { opacity: 0; translate: 0 -100%; } } @keyframes slide-out { to { opacity: 0; translate: 0 100%; } } // 為 highlight 元素的新舊狀態設定共同樣式 ::view-transition-new(highlight), ::view-transition-old(highlight) { height: 100%; // 確保轉場動畫中元素快照佔據容器的全部高度,避免內容被裁剪 object-fit: none; // 保持內容原始尺寸,不進行縮放或調整,防止文字在轉場過程中變形 object-position: top left; // 將內容對齊到容器左上角,確保所有轉場元素從相同位置開始動畫 } // 設定舊狀態元素的滑出轉場動畫 ::view-transition-old(highlight) { animation: slide-out 0.75s $timing-function; } // 設定新狀態元素的滑入轉場動畫 ::view-transition-new(highlight) { animation: slide-in 0.75s $timing-function; }
- JavaScript
const highlight = document.getElementById("highlight"); // 要輪流顯示的單詞陣列 const words = ["Curious", "Inspired", "Driven", "Passionate"]; let count = 0; const handleSwap = () => { // 檢查瀏覽器是否支援 View Transition if (!document.startViewTransition) { // 如果不支援,直接更新文字內容 highlight.innerText = words[(count += 1) % words.length]; } else { // 如果支援,使用 View Transition 進行轉場動畫 document.startViewTransition(() => { highlight.innerText = words[(count += 1) % words.length]; }); } }; setInterval(handleSwap, 2000);
2. Image Gallery I (SPA)
- HTML
<body> <ul class="gallery"> <li class="thumbnail" id="thumbnail-1"> <img src="https://picsum.photos/id/236/800/800?grayscale" alt="Lorem ipsum dolor sit amet, consectetur adipiscing." /> </li> <li class="thumbnail" id="thumbnail-2"> <img src="https://picsum.photos/id/237/800/800?grayscale" alt="Mus viverra dictumst vivamus nisi, interdum risus suspendisse, dictum ornare pretium." /> </li> <li class="thumbnail" id="thumbnail-3"> <img src="https://picsum.photos/id/238/800/800?grayscale" alt="Felis montes viverra velit nunc cum, a suscipit interdum quis." /> </li> <li class="thumbnail" id="thumbnail-4"> <img src="https://picsum.photos/id/239/800/800?grayscale" alt="Conubia rutrum mus mattis dictum auctor, erat in vehicula bibendum." /> </li> <li class="thumbnail" id="thumbnail-5"> <img src="https://picsum.photos/id/240/800/800?grayscale" alt="Proin laoreet dictum ac, vehicula cras." /> </li> <li class="thumbnail" id="thumbnail-6"> <img src="https://picsum.photos/id/241/800/800?grayscale" alt="Placerat id venenatis ut aenean, fusce aliquam interdum." /> </li> <li class="thumbnail" id="thumbnail-7"> <img src="https://picsum.photos/id/242/800/800?grayscale" alt="Faucibus ultricies auctor magna sollicitudin sem, commodo torquent curae sodales." /> </li> <li class="thumbnail" id="thumbnail-8"> <img src="https://picsum.photos/id/243/800/800?grayscale" alt="Torquent sapien tristique volutpat, vivamus rutrum posuere, risus non" /> </li> <li class="thumbnail" id="thumbnail-9"> <img src="https://picsum.photos/id/244/800/800?grayscale" alt="Porta massa cursus sem pharetra, facilisis volutpat." /> </li> </ul> <div class="lightbox"> <figure class="lightbox-img"> </figure> </div> </body>
- CSS (SCSS)
body { position: relative; display: flex; justify-content: center; align-items: center; width: 100dvw; height: 100dvh; font-family: monospace; .gallery { display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr); gap: 16px; width: fit-content; height: fit-content; .thumbnail { width: 150px; height: 150px; cursor: pointer; transition: all 0.3s; &:hover { transform: scale(1.03); transition: all 0.3s; } img { width: 100%; object-fit: contain; vertical-align: top; } } } .lightbox { display: flex; justify-content: center; align-items: center; position: absolute; inset: 0; z-index: -1; cursor: pointer; view-transition-name: lightbox; &:has(.lightbox-img figcaption) { z-index: 1; } .lightbox-img { width: 500px; background-color: #fff; img { margin-bottom: 8px; width: 100%; } figcaption { text-align: center; } } } } @keyframes transition-in { from { opacity: 0; transform: translateY(3rem); } to { opacity: 1; transform: translateY(0); } } @keyframes transition-out { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(3rem); } } ::view-transition-new(lightbox) { animation: 300ms ease 50ms both transition-in; } ::view-transition-old(lightbox) { animation: 300ms ease 50ms both transition-out; }
- JavaScript
const thumbnails = document.querySelectorAll(".thumbnail"); const lightbox = document.querySelector(".lightbox"); const lightboxImg = document.querySelector(".lightbox-img"); const figcaption = document.createElement("figcaption"); let imgIndex: number; // 打開 / 關閉燈箱 const toggleLightbox = (isOpen, img) => { if (isOpen) { const desc = img.getAttribute("alt"); img.setAttribute("title", "Click image or press ESC to view all"); figcaption.innerHTML = `<p>${desc}</p>`; lightboxImg.append(img); lightboxImg.append(figcaption); } else { img.removeAttribute("title"); const parentElement = thumbnails[imgIndex]; parentElement.append(img); lightboxImg.removeChild(figcaption); imgIndex = undefined; } }; // 監聽圖片點擊事件 thumbnails.forEach((t, index) => { t.addEventListener("click", (e) => { imgIndex = index; const img = e.target; // 若不支援 View Transition 則執行 fallback if (!document.startViewTransition) { toggleLightbox(true, img); return; } // 設定 view-transition-name img.style.viewTransitionName = "clicked-img"; // 開始轉場動畫 document.startViewTransition(() => { toggleLightbox(true, img); }); }); }); // 監聽 lightbox-img 的點擊事件 lightboxImg.addEventListener("click", async (e) => { if (!(e.target instanceof HTMLImageElement)) return; const img = e.target; // 若不支援 View Transition 則執行 fallback if (!document.startViewTransition) { toggleLightbox(false, img); return; } // 開始轉場動畫 const animation = document.startViewTransition(() => { toggleLightbox(false, img); }); // 等待轉場動畫結束 await animation.finished; // 移除 view-transition-name img.style.viewTransitionName = "none"; }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && imgIndex !== undefined) { const img = lightboxImg.querySelector("img"); if (img) { if (!document.startViewTransition) { toggleLightbox(false, img); return; } document.startViewTransition(() => { toggleLightbox(false, img); }); } } });
3. Image Gallery II (SPA)
- HTML
<ul class="gallery"> <li class="item" data-pos="1"> <img src="https://picsum.photos/600/600?grayscale&random=1" alt="random-image-1"> </li> <li class="item" data-pos="2"> <img src="https://picsum.photos/600/600?grayscale&random=2" alt="random-image-2"> </li> <li class="item" data-pos="3"> <img src="https://picsum.photos/600/600?grayscale&random=3" alt="random-image-3"> </li> <li class="item" data-pos="4"> <img src="https://picsum.photos/600/600?grayscale&random=4" alt="random-image-4"> </li> <li class="item" data-pos="5"> <img src="https://picsum.photos/600/600?grayscale&random=5" alt="random-image-5"> </li> <li class="item" data-pos="6"> <img src="https://picsum.photos/600/600?grayscale&random=6" alt="random-image-6"> </li> </ul>
- CSS (SCSS)
body { display: flex; justify-content: center; align-items: center; height: 100dvh; .gallery { display: grid; grid-template-columns: repeat(5, 1fr); grid-auto-rows: 13dvmin; gap: 0.5rem; width: min( 90dvw, 550px ); // 寬度取 90dvw和 550px 兩者中較小的值(最大寬度 550px) .item { // 設定大圖 layout &[data-pos="1"] { grid-column: 1/6; grid-row: 1/6; } // 設定縮圖(非大圖)樣式 &:not([data-pos="1"]):hover { cursor: pointer; } img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; } } } } // 設定轉場動畫效果 ::view-transition-group(*) { animation-duration: 0.5s; animation-timing-function: ease-in-out; }
- JavaScript
const items = document.querySelectorAll(".item"); const handleItemClick = (e) => { // 找到實際點擊的元素,需考慮冒泡事件 (Event bubbling) let target = e.target; // 向上遍歷 DOM 直到找到 .item 元素 while (target && !target.matches(".item")) { target = target.parentElement; } // 如果找不到符合的元素或點擊大圖,則不執行操作 if (!target || target.dataset.pos === "1") return; // 找到當前的大圖元素 const mainImg = document.querySelector('li[data-pos="1"]'); // 檢查瀏覽器是否支援 View Transitions if (document.startViewTransition) { // 開始轉場動畫 document.startViewTransition(() => { // 調換大圖和被點擊的縮圖兩者的位置資訊 [mainImg.dataset.pos, target.dataset.pos] = [ target.dataset.pos, mainImg.dataset.pos ]; }); } else { // 若不支援 View Transition 則執行 fallback [mainImg.dataset.pos, target.dataset.pos] = [ target.dataset.pos, mainImg.dataset.pos ]; } }; // 頁面載入時初始化 document.addEventListener("DOMContentLoaded", () => { // 為圖片設置 View Transition 名稱 items.forEach((item, i) => { const img = item.querySelector("img"); img?.style.setProperty("view-transition-name", `item-${i}`); }); // 監聽圖片的點擊事件 window.addEventListener("click", handleItemClick); });
4. Tabs Filter (SPA)
- HTML
<div class="container"> <h1>Tech Articles</h1> <div class="filter-tabs"> <button class="filter-btn active" data-filter="all">All</button> <button class="filter-btn" data-filter="frontend">Frontend</button> <button class="filter-btn" data-filter="backend">Backend</button> <button class="filter-btn" data-filter="fullstack">Fullstack</button> </div> <div class="articles-wrapper"> <div class="article-card" data-category="fullstack"> <div class="article-card-content"> <h2 class="article-title">Complete GraphQL Development Guide</h2> <div class="article-meta"> <div class="article-date">2025/03/20</div> <div class="article-category fullstack">Fullstack</div> </div> <p>Learn GraphQL from scratch, including complete front and back-end integration...</p> </div> </div> <div class="article-card" data-category="frontend"> <div class="article-card-content"> <h2 class="article-title">Optimizing Web Animation Performance</h2> <div class="article-meta"> <div class="article-date">2025/03/19</div> <div class="article-category frontend">Frontend</div> </div> <p>Learn how to create smooth, 60fps animations with techniques like will-change, transform, and requestAnimationFrame...</p> </div> </div> <div class="article-card" data-category="backend"> <div class="article-card-content"> <h2 class="article-title">Node.js Performance Optimization Guide</h2> <div class="article-meta"> <div class="article-date">2025/03/18</div> <div class="article-category backend">Backend</div> </div> <p>A comprehensive guide on how to improve the performance of your Node.js applications...</p> </div> </div> <div class="article-card" data-category="backend"> <div class="article-card-content"> <h2 class="article-title">Rust for Backend Services</h2> <div class="article-meta"> <div class="article-date">2025/03/16</div> <div class="article-category backend">Backend</div> </div> <p>Building high-performance, memory-safe backend services with Rust and understanding its advantages over traditional languages...</p> </div> </div> <div class="article-card" data-category="frontend"> <div class="article-card-content"> <h2 class="article-title">Vue.js 3 New Features Guide</h2> <div class="article-meta"> <div class="article-date">2025/03/15</div> <div class="article-category frontend">Frontend</div> </div> <p>This article introduces the new features of Vue.js 3 and how to use them effectively...</p> </div> </div> <div class="article-card" data-category="fullstack"> <div class="article-card-content"> <h2 class="article-title">Next.js Full Stack Development</h2> <div class="article-meta"> <div class="article-date">2025/03/12</div> <div class="article-category fullstack">Fullstack</div> </div> <p>Best practices and case studies for full stack development using Next.js...</p> </div> </div> <div class="article-card" data-category="backend"> <div class="article-card-content"> <h2 class="article-title">Database Sharding Strategies</h2> <div class="article-meta"> <div class="article-date">2025/03/11</div> <div class="article-category backend">Backend</div> </div> <p>Advanced database scaling techniques with practical implementations of horizontal partitioning for high-traffic applications...</p> </div> </div> <div class="article-card" data-category="frontend"> <div class="article-card-content"> <h2 class="article-title">React Hooks Best Practices</h2> <div class="article-meta"> <div class="article-date">2025/03/10</div> <div class="article-category frontend">Frontend</div> </div> <p>In-depth exploration of React Hooks usage techniques and best practices...</p> </div> </div> <div class="article-card" data-category="backend"> <div class="article-card-content"> <h2 class="article-title">Python FastAPI in Practice</h2> <div class="article-meta"> <div class="article-date">2025/03/05</div> <div class="article-category backend">Backend</div> </div> <p>Practical tutorial on building high-performance RESTful APIs with Python FastAPI...</p> </div> </div> </div> </div>
- CSS (SCSS)
body { padding: 2rem; background-color: #f5f5f5; .container { margin: 0 auto; max-width: 800px; h1 { margin-bottom: 2rem; color: #333; font-size: 2rem; font-weight: bold; text-align: center; line-height: 1.2; } .filter-tabs { display: flex; justify-content: center; margin-bottom: 2rem; gap: 1rem; .filter-btn { padding: 0.75rem 1.5rem; border-radius: 30px; border: 2px solid #e0e0e0; background-color: #fff; font-size: 1rem; font-weight: 600; transition: all 0.3s ease; cursor: pointer; &:hover { background-color: #f0f0f0; } &.active { border-color: #3d5af1; background-color: #3d5af1; color: #fff; } } } .articles-wrapper { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem; .article-card { border-radius: 8px; background-color: #fff; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); overflow: hidden; transition: transform 0.3s ease, opacity 0.3s ease; &:hover { transform: translateY(-5px); } &-content { padding: 1.5rem; .article-title { margin-bottom: 0.5rem; color: #333; font-size: 1.25rem; font-weight: bold; text-overflow: ellipsis; line-height: 1.3; white-space: nowrap; overflow: hidden; } .article-meta { display: flex; justify-content: space-between; margin-bottom: 1rem; .article-date { color: #999; font-size: 0.875rem; } .article-category { background-color: #f0f0f0; padding: 0.25rem 0.75rem; border-radius: 30px; font-size: 0.75rem; font-weight: 600; color: #555; &.frontend { background-color: #ffebee; color: #e57373; } &.backend { background-color: #e8f5e9; color: #81c784; } &.fullstack { background-color: #e3f2fd; color: #64b5f6; } } } p { color: #666; white-space: nowrap; text-overflow: ellipsis; line-height: 1.5; overflow: hidden; } } } } } } @keyframes fade-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.9); } } @keyframes fade-in { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } ::view-transition-old(.article) { animation: fade-out 0.5s ease-out; } ::view-transition-new(.article) { animation: fade-in 0.5s ease-out; }
- JavaScript
const filterTabs = document.querySelector(".filter-tabs"); const filterBtns = filterTabs.querySelectorAll(".filter-btn"); const articles = document.querySelectorAll(".article-card"); // 檢查瀏覽器是否支援 View Transition const isSupported = !!document.startViewTransition; // 設定 view-transition-name 和 view-transition-class articles.forEach((a, i) => { a.style.viewTransitionName = `article-${i}`; a.style.viewTransitionClass = "article"; }); const updateActiveButton = (clickedBtn) => { filterTabs.querySelector(".active").classList.remove("active"); clickedBtn.classList.add("active"); }; const filterArticles = (filter) => { articles.forEach((a) => { const category = a.getAttribute("data-category"); // 根據 category 顯示或隱藏文章 if (filter === "all" || filter === category) { a.removeAttribute("hidden"); } else { a.setAttribute("hidden", ""); } }); }; filterBtns.forEach((btn) => { btn.addEventListener("click", (e) => { const filter = e.target.getAttribute("data-filter"); if (isSupported) { document.startViewTransition(() => { updateActiveButton(e.target); filterArticles(filter); }); } else { updateActiveButton(e.target); filterArticles(filter); } }); });
5. Pagination (SPA)
- HTML
<div class="slider-container"> <div class="slide-wrapper"> <div class="slide"> <h2>Slide 1</h2> <p>This is the content of the first slide. Clean and minimalist design style, with smooth left and right sliding effects. Using View Transitions API to implement more vivid transition animations.</p> </div> </div> <div class="controls-container"> <div class="controls"> <button id="prevBtn" class="btn" disabled>Previous</button> <div class="indicator-wrapper"></div> <button id="nextBtn" class="btn">Next</button> </div> </div> </div>
- CSS (SCSS)
// 設定全域變數 :root { --primary-color: #3498db; } * { box-sizing: border-box; } body { display: flex; justify-content: center; align-items: center; min-height: 100dvh; background-color: #f8f9fa; color: #333; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; .slider-container { position: relative; margin: 0 auto; max-width: 800px; width: 90%; border-radius: 8px; background-color: #fff; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); .slide-wrapper { position: relative; height: 300px; overflow: hidden; .slide { position: absolute; top: 0; left: 0; display: flex; flex-direction: column; justify-content: center; padding: 40px; width: 100%; height: 100%; background-color: white; view-transition-name: slide-content; // 設定 view-transition-name h2 { margin-bottom: 1rem; color: var(--primary-color); font-size: 2rem; } p { margin-bottom: 1.5rem; font-size: 1rem; line-height: 1.6; } } } .controls-container { padding: 16px; border-top: 1px solid #eee; background-color: #fff; .controls { display: flex; justify-content: space-between; align-items: center; .btn { padding: 8px 16px; color: white; border-radius: 4px; border: none; background-color: var(--primary-color); font-size: 1rem; transition: background-color 0.2s; cursor: pointer; &:hover { background-color: #2980b9; } &:disabled { background-color: #bdc3c7; cursor: not-allowed; } } .indicator-wrapper { display: flex; justify-content: center; gap: 8px; view-transition-name: pagination; // 設定 view-transition-name .indicator { width: 10px; height: 10px; border-radius: 50%; background-color: #ddd; transition: background-color 0.2s; cursor: pointer; &.active { background-color: var(--primary-color); } } } } } } } @keyframes slide-in-from-right { from { transform: translateX(100%); } to { transform: translateX(0); } } @keyframes slide-out-to-left { from { transform: translateX(0); } to { transform: translateX(-100%); } } @keyframes slide-in-from-left { from { transform: translateX(-100%); } to { transform: translateX(0); } } @keyframes slide-out-to-right { from { transform: translateX(0); } to { transform: translateX(100%); } } // 限制轉場動畫不超出卡片範圍 ::view-transition-group(slide-content) { overflow: hidden; } // 向下一頁滑動的轉場效果 html[data-direction="next"] { &::view-transition-old(slide-content) { animation: 0.5s cubic-bezier(0.4, 0, 0.2, 1) both slide-out-to-left; } &::view-transition-new(slide-content) { animation: 0.5s cubic-bezier(0.4, 0, 0.2, 1) both slide-in-from-right; } } // 向上一頁滑動的轉場效果 html[data-direction="prev"] { &::view-transition-old(slide-content) { animation: 0.5s cubic-bezier(0.4, 0, 0.2, 1) both slide-out-to-right; } &::view-transition-new(slide-content) { animation: 0.5s cubic-bezier(0.4, 0, 0.2, 1) both slide-in-from-left; } }
- JavaScript
const slides = [ { title: "Slide 1", content: "This is the content of the first slide. Clean and minimalist design style, with smooth left and right sliding effects. Using View Transitions API to implement more vivid transition animations." }, { title: "Slide 2", content: "This is the content of the second slide. View Transitions API makes page transitions smooth, enhancing the user experience." }, { title: "Slide 3", content: "This is the content of the third slide. Through simple CSS and JavaScript, we can create professional slide effects." }, { title: "Slide 4", content: "This is the content of the last slide. Hope this simple example has been helpful to you!" } ]; const slide = document.querySelector(".slide"); const prevBtn = document.getElementById("prevBtn"); const nextBtn = document.getElementById("nextBtn"); const indicatorWrapper = document.querySelector(".indicator-wrapper"); let currentIndex = 0; // 渲染 indicator const renderIndicator = () => { indicatorWrapper.innerHTML = ""; for (let i = 0; i < slides.length; i++) { const indicator = document.createElement("div"); indicator.classList.add("indicator"); if (i === currentIndex) { indicator.classList.add("active"); } indicator.addEventListener("click", () => { goToSlide(i); }); indicatorWrapper.appendChild(indicator); } }; // 更新 indicator 狀態 const updateIndicator = () => { const indicators = document.querySelectorAll(".indicator"); indicators.forEach((i, index) => { if (index === currentIndex) { i.classList.add("active"); } else { i.classList.remove("active"); } }); }; // 更新按鈕狀態(啟用/禁用) const updateButtons = () => { prevBtn.disabled = currentIndex === 0; nextBtn.disabled = currentIndex === slides.length - 1; }; // 渲染當前幻燈片內容 const renderSlide = () => { const currentSlide = slides[currentIndex]; slide.innerHTML = ` <h2>${currentSlide.title}</h2> <p>${currentSlide.content}</p> `; updateButtons(); updateIndicator(); }; // 切換到指定幻燈片 const goToSlide = (index, direction) => { if (index < 0 || index >= slides.length || index === currentIndex) return; // 設定滑動方向 const dir = direction || (index > currentIndex ? "next" : "prev"); // 如果瀏覽器不支援 View Transition 則執行 fallback if (!document.startViewTransition) { currentIndex = index; renderSlide(); return; } // 設定轉場滑動方向 document.documentElement.setAttribute("data-direction", dir); // 開始轉場動畫 const transition = document.startViewTransition(() => { currentIndex = index; renderSlide(); }); // 轉場結束後移除方向屬性 transition.finished.then(() => { document.documentElement.removeAttribute("data-direction"); }); }; // 初始化顯示 renderIndicator(); renderSlide(); // 監聽按鈕點擊事件 prevBtn.addEventListener("click", () => { goToSlide(currentIndex - 1, "prev"); }); nextBtn.addEventListener("click", () => { goToSlide(currentIndex + 1, "next"); }); // 監聽鍵盤事件(左右方向鍵) document.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") { goToSlide(currentIndex - 1, "prev"); } else if (e.key === "ArrowRight") { goToSlide(currentIndex + 1, "next"); } });
本文轉載自筆者於 Medium 發表的文章,欲了解更多詳情,歡迎點擊連結前往閱讀。