1) 纯 JS 实现(单文件,直接打开即可)

<!-- 保存为 carousel-vanilla.html 然后在浏览器打开 -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Vanilla JS Carousel</title>
<style>
  :root{--w:800px;--h:380px;}
  body{font-family:system-ui,Segoe UI,Roboto,Arial;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f7fb}
  .carousel{width:clamp(320px,80vw,var(--w)); max-width:100%; position:relative;overflow:hidden;border-radius:8px;background:#111;color:#fff}
  .carousel__track{display:flex;transition:transform .45s cubic-bezier(.22,.9,.35,1);will-change:transform}
  .carousel__slide{min-width:100%;height:var(--h);display:flex;align-items:center;justify-content:center;font-size:28px;background:#333}
  .carousel__slide img{width:100%;height:100%;object-fit:cover;display:block}
  .carousel__controls{position:absolute;inset:0;display:flex;align-items:center;justify-content:space-between;padding:0 8px;pointer-events:none}
  .carousel__btn{pointer-events:auto;background:rgba(0,0,0,.45);border:0;color:#fff;padding:10px;border-radius:6px;cursor:pointer}
  .carousel__indicators{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);display:flex;gap:8px}
  .carousel__dot{width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.45);border:0;cursor:pointer}
  .carousel__dot[aria-current="true"]{background:#fff;transform:scale(1.15)}
  .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
</style>
</head>
<body>

<div class="carousel" id="carousel1" tabindex="0" aria-roledescription="carousel" aria-label="示例轮播图">
  <div class="carousel__track" data-track>
    <div class="carousel__slide"><img src="https://picsum.photos/1200/600?random=1" alt="图片 1"></div>
    <div class="carousel__slide"><img src="https://picsum.photos/1200/600?random=2" alt="图片 2"></div>
    <div class="carousel__slide"><img src="https://picsum.photos/1200/600?random=3" alt="图片 3"></div>
    <div class="carousel__slide"><img src="https://picsum.photos/1200/600?random=4" alt="图片 4"></div>
  </div>

  <div class="carousel__controls">
    <button class="carousel__btn prev" data-prev aria-label="上一张">‹</button>
    <button class="carousel__btn next" data-next aria-label="下一张">›</button>
  </div>

  <div class="carousel__indicators" role="tablist" aria-label="轮播指示器" data-indicators></div>
  <div class="sr-only" id="carousel1-status" aria-live="polite"></div>
</div>

<script>
/*
 功能:
 - 自动播放,悬停/聚焦时暂停
 - 上一页/下一页,指示器
 - 键盘支持(左/右箭头)
 - 响应式(使用 CSS 宽度)
*/
(function(){
  class Carousel {
    constructor(root, options = {}) {
      this.root = root;
      this.track = root.querySelector('[data-track]');
      this.indicatorsWrap = root.querySelector('[data-indicators]');
      this.slides = Array.from(this.track.children);
      this.total = this.slides.length;
      this.index = 0;
      this.interval = options.interval ?? 3000;
      this.timer = null;
      this.paused = false;
      this.statusBox = root.querySelector('#' + (root.id ? root.id + '-status' : 'carousel1-status')) || null;
      this.init();
    }
    init() {
      this.buildIndicators();
      this.update();
      this.attachEvents();
      this.start();
    }
    buildIndicators() {
      this.indicatorsWrap.innerHTML = '';
      this.dots = this.slides.map((_, i) => {
        const btn = document.createElement('button');
        btn.className = 'carousel__dot';
        btn.type = 'button';
        btn.setAttribute('aria-label', `切换到第 ${i+1} 张`);
        btn.dataset.index = i;
        btn.addEventListener('click', () => this.go(i));
        this.indicatorsWrap.appendChild(btn);
        return btn;
      });
    }
    update() {
      const offset = -this.index * 100;
      this.track.style.transform = `translateX(${offset}%)`;
      this.dots.forEach((d, i) => d.setAttribute('aria-current', i===this.index ? 'true' : 'false'));
      if(this.statusBox){
        this.statusBox.textContent = `第 ${this.index+1} 张,共 ${this.total} 张`;
      }
    }
    next() { this.go((this.index + 1) % this.total); }
    prev() { this.go((this.index - 1 + this.total) % this.total); }
    go(i) { this.index = i; this.update(); }
    start() {
      this.stop();
      this.timer = setInterval(()=> { if(!this.paused) this.next(); }, this.interval);
    }
    stop() { if(this.timer) { clearInterval(this.timer); this.timer = null; } }
    attachEvents() {
      this.root.addEventListener('mouseenter', ()=> { this.paused = true; });
      this.root.addEventListener('mouseleave', ()=> { this.paused = false; });
      this.root.addEventListener('focusin', ()=> { this.paused = true; });
      this.root.addEventListener('focusout', ()=> { this.paused = false; });
      const prevBtn = this.root.querySelector('[data-prev]');
      const nextBtn = this.root.querySelector('[data-next]');
      prevBtn?.addEventListener('click', ()=> this.prev());
      nextBtn?.addEventListener('click', ()=> this.next());
      // keyboard
      this.root.addEventListener('keydown', (e)=> {
        if(e.key === 'ArrowLeft') { e.preventDefault(); this.prev(); }
        if(e.key === 'ArrowRight') { e.preventDefault(); this.next(); }
      });
      // swipe support (basic)
      let startX = null, startTime = null;
      this.root.addEventListener('pointerdown', (e)=> {
        startX = e.clientX; startTime = Date.now(); this.paused = true;
      }, {passive:true});
      this.root.addEventListener('pointerup', (e)=> {
        if (startX === null) return;
        const dx = e.clientX - startX;
        const dt = Date.now() - startTime;
        // quick swipe threshold
        if (Math.abs(dx) > 40 && dt < 500) {
          if (dx > 0) this.prev(); else this.next();
        }
        startX = null; startTime = null; this.paused = false;
      });
    }
  }

  // init
  document.addEventListener('DOMContentLoaded', ()=> {
    const el = document.getElementById('carousel1');
    if(el) new Carousel(el, { interval: 3000 });
  });
})();
</script>
</body>
</html>

说明

  • 把每张 slide 放在 .carousel__track 内,CSS 用 translateX 做切换。

  • 支持鼠标悬停或焦点时暂停、支持键盘、简单手势滑动。

  • 图片用 picsum.photos 占位,可替换为真实图片 URL。


2) Vue 3 实现(单文件组件风格,<script setup>

把代码放入 .vue 文件(如 Carousel.vue),在 Vue 3 项目中使用

<!-- Carousel.vue -->
<template>
  <div class="carousel" ref="root" tabindex="0" role="region" :aria-label="ariaLabel">
    <div class="track" :style="trackStyle" @pointerdown="onPointerDown">
      <div v-for="(item, i) in items" :key="item.id" class="slide">
        <img :src="item.src" :alt="item.alt || `Slide ${i+1}`" />
      </div>
    </div>

    <div class="controls">
      <button class="btn" @click="prev" aria-label="Previous slide">‹</button>
      <button class="btn" @click="next" aria-label="Next slide">›</button>
    </div>

    <div class="indicators" role="tablist" aria-label="Slides">
      <button v-for="(s, i) in items" :key="s.id" :aria-current="i===index" @click="go(i)" class="dot" :aria-label="`Go to slide ${i+1}`"></button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';

const props = defineProps({
  items: { type: Array, required: true }, // [{id, src, alt}]
  interval: { type: Number, default: 3000 },
  ariaLabel: { type: String, default: 'Carousel' },
});

const index = ref(0);
const root = ref(null);
let timer = null;
const paused = ref(false);

const trackStyle = computed(() => ({ transform: `translateX(${-index.value * 100}%)`, transition: 'transform .45s cubic-bezier(.22,.9,.35,1)'}));

function next(){ index.value = (index.value + 1) % props.items.length; }
function prev(){ index.value = (index.value - 1 + props.items.length) % props.items.length; }
function go(i){ index.value = i; }

function start(){
  stop();
  timer = setInterval(()=>{ if(!paused.value) next(); }, props.interval);
}
function stop(){ if(timer){ clearInterval(timer); timer = null; } }

onMounted(()=> {
  start();
  // mouse / focus pause
  root.value?.addEventListener('mouseenter', ()=> paused.value = true);
  root.value?.addEventListener('mouseleave', ()=> paused.value = false);
  root.value?.addEventListener('focusin', ()=> paused.value = true);
  root.value?.addEventListener('focusout', ()=> paused.value = false);

  root.value?.addEventListener('keydown', (e)=>{
    if(e.key === 'ArrowLeft'){ e.preventDefault(); prev(); }
    if(e.key === 'ArrowRight'){ e.preventDefault(); next(); }
  });
});

onBeforeUnmount(()=> {
  stop();
});

// simple swipe
let startX = null, startTime = null;
function onPointerDown(e){
  startX = e.clientX; startTime = Date.now();
  const up = (ev) => {
    const dx = ev.clientX - startX;
    const dt = Date.now() - startTime;
    if(Math.abs(dx) > 40 && dt < 500){
      if(dx > 0) prev(); else next();
    }
    startX = null;
    root.value.removeEventListener('pointerup', up);
  };
  root.value.addEventListener('pointerup', up, { once: true });
}
</script>

<style scoped>
.carousel{ width:100%; max-width:900px; margin:0 auto; position:relative; overflow:hidden; border-radius:8px; background:#222 }
.track{ display:flex; width:100%; }
.slide{ min-width:100%; height:420px; display:flex; align-items:center; justify-content:center; }
.slide img{ width:100%; height:100%; object-fit:cover; display:block; }
.controls{ position:absolute; inset:0; display:flex; justify-content:space-between; align-items:center; padding:0 10px; pointer-events:none; }
.btn{ pointer-events:auto; background:rgba(0,0,0,.45); border:0; color:#fff; padding:8px 12px; border-radius:6px; cursor:pointer }
.indicators{ position:absolute; left:50%; transform:translateX(-50%); bottom:14px; display:flex; gap:8px }
.dot{ width:10px; height:10px; border-radius:50%; background:rgba(255,255,255,.45); border:0; cursor:pointer }
.dot[aria-current="true"]{ background:#fff; transform:scale(1.15) }
</style>

使用

<template>
  <Carousel :items="slides" :interval="2500" aria-label="Demo carousel"/>
</template>

<script setup>
import Carousel from './Carousel.vue';
const slides = [
  { id: 1, src: 'https://picsum.photos/1200/600?random=11' },
  { id: 2, src: 'https://picsum.photos/1200/600?random=12' },
  { id: 3, src: 'https://picsum.photos/1200/600?random=13' }
];
</script>

说明(Vue)

  • 使用组合式 API(script setup),props 接收 slides 数据。

  • 支持 pause on hover/focus、键盘导航、指示点、触摸滑动。

  • 可扩展性好:你可以传动画类型、loop、lazyLoad 等。


3) React 实现(函数组件 + Hook)

一个独立组件 Carousel.jsx,以及最小 App.jsx 使用示例。把它放到 React 项目中即可

// Carousel.jsx
import React, { useEffect, useRef, useState } from 'react';
import './carousel.css'; // see CSS below or inline styles

export default function Carousel({ items = [], interval = 3000, ariaLabel = 'Carousel' }) {
  const [index, setIndex] = useState(0);
  const [paused, setPaused] = useState(false);
  const timerRef = useRef(null);
  const rootRef = useRef(null);

  useEffect(() => {
    start();
    const r = rootRef.current;
    const onKey = (e) => {
      if (e.key === 'ArrowLeft') setIndex(i => (i - 1 + items.length) % items.length);
      if (e.key === 'ArrowRight') setIndex(i => (i + 1) % items.length);
    };
    r?.addEventListener('keydown', onKey);
    return () => {
      stop();
      r?.removeEventListener('keydown', onKey);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [items.length]);

  useEffect(() => {
    if (!paused) start(); else stop();
    return () => stop();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [paused, index]);

  function start() {
    stop();
    timerRef.current = setInterval(() => {
      setIndex(i => (i + 1) % items.length);
    }, interval);
  }
  function stop() {
    if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
  }
  function prev() { setIndex(i => (i - 1 + items.length) % items.length); }
  function next() { setIndex(i => (i + 1) % items.length); }
  function go(i) { setIndex(i); }

  // swipe
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    let startX = null, startTime = null;
    const down = (e) => { startX = e.clientX; startTime = Date.now(); setPaused(true); };
    const up = (e) => {
      if (startX === null) { setPaused(false); return; }
      const dx = e.clientX - startX; const dt = Date.now() - startTime;
      if (Math.abs(dx) > 40 && dt < 500) { if (dx > 0) prev(); else next(); }
      startX = null; setPaused(false);
    };
    el.addEventListener('pointerdown', down);
    el.addEventListener('pointerup', up);
    return () => { el.removeEventListener('pointerdown', down); el.removeEventListener('pointerup', up); };
  }, [items.length]);

  return (
    <div className="carousel" ref={rootRef} tabIndex={0} aria-roledescription="carousel" aria-label={ariaLabel}
         onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}
         onFocus={() => setPaused(true)} onBlur={() => setPaused(false)}>
      <div className="track" style={{ transform: `translateX(${-index * 100}%)`, transition: 'transform .45s cubic-bezier(.22,.9,.35,1)'}}>
        {items.map((it, i) => (
          <div className="slide" key={it.id || i}><img src={it.src} alt={it.alt || `Slide ${i+1}`} /></div>
        ))}
      </div>

      <div className="controls">
        <button className="btn" onClick={prev} aria-label="Previous">‹</button>
        <button className="btn" onClick={next} aria-label="Next">›</button>
      </div>

      <div className="indicators" role="tablist" aria-label="Slides">
        {items.map((_, i) => <button key={i} className="dot" aria-current={i===index} onClick={() => go(i)} aria-label={`Go to slide ${i+1}`}></button>)}
      </div>
    </div>
  );
}
/* carousel.css */
.carousel{ width:100%; max-width:900px; margin:20px auto; position:relative; overflow:hidden; border-radius:8px; background:#222 }
.track{ display:flex; width:100%; }
.slide{ min-width:100%; height:420px; display:flex; align-items:center; justify-content:center; }
.slide img{ width:100%; height:100%; object-fit:cover; display:block; }
.controls{ position:absolute; inset:0; display:flex; justify-content:space-between; align-items:center; padding:0 10px; pointer-events:none; }
.btn{ pointer-events:auto; background:rgba(0,0,0,.45); border:0; color:#fff; padding:8px 12px; border-radius:6px; cursor:pointer }
.indicators{ position:absolute; left:50%; transform:translateX(-50%); bottom:14px; display:flex; gap:8px }
.dot{ width:10px; height:10px; border-radius:50%; background:rgba(255,255,255,.45); border:0; cursor:pointer }
.dot[aria-current="true"]{ background:#fff; transform:scale(1.15) }

使用示例(App.jsx)

// App.jsx
import React from 'react';
import Carousel from './Carousel';
import './carousel.css';

export default function App(){
  const slides = [
    { id: 1, src: 'https://picsum.photos/1200/600?random=21' },
    { id: 2, src: 'https://picsum.photos/1200/600?random=22' },
    { id: 3, src: 'https://picsum.photos/1200/600?random=23' },
  ];
  return (
    <div style={{padding:20}}>
      <h2>React Carousel Demo</h2>
      <Carousel items={slides} interval={2500} />
    </div>
  );
}

说明(React)

  • 使用 useEffect 管控定时器和事件绑定,做好 cleanup

  • 暂停逻辑使用组件内 state(hover/focus)

  • 支持键盘控制与触摸滑动

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐