You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

如何实现平滑过渡的卡片轮播并复刻指定网站的轮播效果?

复刻平滑轮播过渡效果的解决方案

你的问题核心在于直接用innerHTML替换DOM元素会打断过渡动画的触发流程,而且布局规则没处理好过渡期间的元素状态。下面我会一步步帮你调整代码,实现和目标网站一样的平滑滑动/淡入效果:

一、核心思路调整

  1. 不再直接替换slot的innerHTML,而是先添加新的幻灯片元素,给它设置初始过渡状态
  2. 触发浏览器重排,让过渡动画生效
  3. 动画结束后,移除旧的幻灯片元素,只保留当前激活的元素
  4. 统一slot内所有元素的定位规则,避免过渡期间出现布局偏移

二、修改后的完整代码

1. JavaScript 逻辑重构

function initCarousel(root) {
  const articles = Array.from(root.querySelectorAll(".slides > article"));
  const prevSlot = root.querySelector('[data-slot="prev"]');
  const currSlot = root.querySelector('[data-slot="current"]');
  const nextSlot = root.querySelector('[data-slot="next"]');
  const prevBtn = root.querySelector("[data-prev]");
  const nextBtn = root.querySelector("[data-next]");
  const dotsWrap = root.querySelector("[data-dots]");

  const slides = articles.map(a => ({
    title: a.dataset.title || "Slide",
    html: a.innerHTML
  }));

  const mod = (n, m) => ((n % m) + m) % m;
  const wrapSlot = (html) => `<div class="slot-inner">${html}</div>`;
  const wrapPeek = (html) => `<div class="slot-inner"><div class="peek-inner">${html}</div></div>`;

  let index = 0;
  let isAnimating = false; // 防止动画期间重复触发导航

  // 初始化渲染点按钮
  function renderDots() {
    dotsWrap.innerHTML = slides.map((s, i) => `
      <button type="button" data-dot="${i}" aria-selected="${i === index}" ${isAnimating ? 'disabled' : ''}>
        <span class="sr-only">${s.title}</span>
      </button>
    `).join("");

    dotsWrap.querySelectorAll("[data-dot]").forEach(btn => {
      btn.addEventListener("click", () => {
        if (isAnimating) return;
        const targetIndex = Number(btn.dataset.dot);
        // 判断导航方向,用于设置动画方向
        const direction = targetIndex > index ? 'next' : 'prev';
        index = targetIndex;
        update(direction);
      });
    });
  }

  // 更新幻灯片,direction用于指定动画方向(prev/next)
  function update(direction = null) {
    isAnimating = true;
    const prevIndex = mod(index - 1, slides.length);
    const nextIndex = mod(index + 1, slides.length);

    // 更新中间卡片(带过渡动画)
    updateCurrentSlot(direction);
    // 更新左右预览卡片(直接替换,因为预览区不需要过渡)
    prevSlot.innerHTML = wrapPeek(slides[prevIndex].html);
    nextSlot.innerHTML = wrapPeek(slides[nextIndex].html);
    renderDots();
  }

  // 处理中间卡片的过渡动画
  function updateCurrentSlot(direction) {
    const currentInner = currSlot.querySelector('.slot-inner');
    const newInner = document.createElement('div');
    newInner.innerHTML = wrapSlot(slides[index].html);
    const newElement = newInner.firstElementChild;

    // 设置初始动画状态
    if (direction === 'next') {
      newElement.classList.add('slide-in-from-right');
    } else if (direction === 'prev') {
      newElement.classList.add('slide-in-from-left');
    } else {
      // 初始化时直接显示,无动画
      currSlot.innerHTML = wrapSlot(slides[index].html);
      isAnimating = false;
      return;
    }

    // 添加新元素到slot
    currSlot.appendChild(newElement);
    // 触发浏览器重排,让过渡生效
    void newElement.offsetWidth;

    // 切换到结束状态,触发动画
    newElement.classList.remove('slide-in-from-right', 'slide-in-from-left');
    newElement.classList.add('active');
    // 旧元素添加退出动画
    if (currentInner) {
      currentInner.classList.add(direction === 'next' ? 'slide-out-to-left' : 'slide-out-to-right');
    }

    // 动画结束后清理旧元素
    setTimeout(() => {
      if (currentInner) currSlot.removeChild(currentInner);
      isAnimating = false;
    }, 900); // 和CSS过渡时间保持一致
  }

  prevBtn.addEventListener("click", () => {
    if (isAnimating) return;
    index = mod(index - 1, slides.length);
    update('prev');
  });

  nextBtn.addEventListener("click", () => {
    if (isAnimating) return;
    index = mod(index + 1, slides.length);
    update('next');
  });

  // 初始化
  update();
}

document.querySelectorAll(".carousel").forEach(initCarousel);

2. CSS 过渡规则优化

.carousel-row {
  display: grid;
  grid-template-columns: 2fr 6fr 2fr;
  gap: 16px;
  align-items: stretch;
}

/* slots clip their contents */
.peek, .center {
  position: relative;
  overflow: hidden;
  min-height: 1px;
}

/* 所有slot-inner都设置为绝对定位,避免过渡期间布局偏移 */
.peek .slot-inner, .center .slot-inner {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%; /* 强制高度继承父容器 */
}

/* 激活状态的元素保持可见,同时维持容器高度 */
.center .slot-inner.active {
  position: relative;
  opacity: 1;
  transform: translateX(0);
}

/* 动画初始状态 */
.slide-in-from-right {
  opacity: 0;
  transform: translateX(100%);
}

.slide-in-from-left {
  opacity: 0;
  transform: translateX(-100%);
}

/* 退出动画状态 */
.slide-out-to-left {
  opacity: 0;
  transform: translateX(-100%);
}

.slide-out-to-right {
  opacity: 0;
  transform: translateX(100%);
}

/* 统一过渡规则,使用更自然的曲线 */
.slot-inner {
  transition: transform 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.9s ease;
}

/* side peek masks */
.peek-inner {
  width: 300%;
}

.peek-left .peek-inner {
  transform: translateX(-66.6667%);
}

.peek-right .peek-inner {
  transform: translateX(0);
}

.card {
  background: white;
  padding: 16px;
  border-radius: 16px;
  box-shadow: 0 0 8px rgba(0,0,0,.15);
  text-align: center;
}

.card-green { color: #2b6b45; }
.card-blue { color: #3aa7c9; }
.card-yellow { color: #f1953a; }

.quote {
  font-size: 18px;
  line-height: 1.6;
}

.meta::before {
  content: "";
  display: block;
  width: 180px;
  height: 4px;
  background: currentColor;
  margin: 16px auto;
  border-radius: 2px;
}

/* 控制按钮禁用状态 */
.controls button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

3. HTML 保持不变(无需修改)

<section class="carousel" aria-label="Testimonials">
  <div class="carousel-row">
    <div class="peek peek-left" data-slot="prev" aria-hidden="true"></div>
    <div class="center" data-slot="current"></div>
    <div class="peek peek-right" data-slot="next" aria-hidden="true"></div>
  </div>
  <div class="controls">
    <button type="button" data-prev aria-label="Previous">‹</button>
    <div class="dots" role="tablist" aria-label="Choose slide" data-dots></div>
    <button type="button" data-next aria-label="Next">›</button>
  </div>
  <!-- Source slides (hidden) -->
  <div class="slides" hidden>
    <article data-title="Slide 1">
      <div class="card card-green">
        <p class="quote">“Generic quote text for slide one.”</p>
        <div class="meta">
          <div class="name">Person One</div>
          <div class="sub">Category A</div>
        </div>
      </div>
    </article>
    <article data-title="Slide 2">
      <div class="card card-blue">
        <p class="quote">“Generic quote text for slide two.”</p>
        <div class="meta">
          <div class="name">Person Two</div>
          <div class="sub">Category B</div>
        </div>
      </div>
    </article>
    <article data-title="Slide 3">
      <div class="card card-yellow">
        <p class="quote">“Generic quote text for slide three.”</p>
        <div class="meta">
          <div class="name">Person Three</div>
          <div class="sub">Category C</div>
        </div>
      </div>
    </article>
  </div>
</section>

三、关键改进点说明

  1. 动画状态管理:新增isAnimating变量,防止用户在动画期间重复点击按钮,避免布局混乱
  2. DOM操作优化:不再直接替换innerHTML,而是先添加新元素,设置初始状态后触发过渡,动画结束再清理旧元素,确保浏览器能捕捉到状态变化执行平滑过渡
  3. 布局修复:所有.slot-inner都设置为绝对定位,过渡期间新旧元素完全重叠,不会出现“下方多余卡片”的问题
  4. 方向感知动画:根据导航方向(上一页/下一页)设置不同的进入/退出动画,和目标网站的滑动逻辑一致
  5. 过渡曲线优化:使用cubic-bezier(0.25, 0.46, 0.45, 0.94)曲线,让滑动更接近目标网站的自然质感

这样调整后,你的轮播组件就能实现平滑的滑动过渡效果,解决之前的跳变和布局问题了。

内容的提问来源于stack exchange,提问作者newuser

火山引擎 最新活动