# 科技新知

View Transition:SPA 範例

作者:Oliver Xiong

日期:2025-05-05

| 應用案例

1. Dynamic Text Transition (SPA)

Dynamic Text Transition (SPA)

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);
    

Image Gallery I (SPA)

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);
          });
        }
      }
    });
    

Image Gallery II (SPA)

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)

Tabs Filter (SPA)

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)

Pagination (SPA)

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 發表的文章,欲了解更多詳情,歡迎點擊連結前往閱讀。

更多資訊公告