下面给你升级版:可视区域固定 5 张卡片,不足 5 张循环补全,超出 5 张只显示 5 张,样式完全不变。实现方式是卡片池固定 5 张,通过“槽位循环复用”来显示任意数量的图片,而不是之前那样渲染 N 张卡片。
一、核心思路
5 个槽位:[-2] [-1] [ 0] [+1] [+2]
↑ ↑ ↑ ↑ ↑
图片 图片 当前 图片 图片
槽位 s 显示图片 = (current + s + N) % N
- N < 5:槽位会重复出现同一张图片,视觉上循环补全
- N = 5:正好 5 张
- N > 5:只有 5 张同时可见,其它通过槽位旋转进出卡片不再按图片数量渲染,而是固定 5 个 DOM 元素。 切换时:
- 每张卡片的槽位
k减delta。 k超出[-2, 2]的卡片绕回另一端,并重新绑定图片。- 从屏幕外滑入,形成循环。
二、完整预览文件
保存为 .html 双击打开即可。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>叠加灯箱画廊 · 5 槽位循环</title>
<style>
:root {
--card-w: 220px;
--card-h: 320px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
}
body {
background: radial-gradient(ellipse at center, #1b1f2a 0%, #0a0c12 70%);
color: #fff;
font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
.gallery {
position: relative;
width: 100%;
max-width: 860px;
padding: 60px 0 100px;
perspective: 1400px;
}
.stage {
position: relative;
width: 100%;
height: calc(var(--card-h) + 70px);
touch-action: none;
cursor: grab;
user-select: none;
-webkit-user-select: none;
transform-style: preserve-3d;
}
.stage.dragging { cursor: grabbing; }
.card {
position: absolute;
left: 50%;
top: 50%;
width: var(--card-w);
height: var(--card-h);
margin: calc(var(--card-h) / -2) 0 0 calc(var(--card-w) / -2);
border-radius: 16px;
overflow: hidden;
cursor: pointer;
background: #1a1e28;
box-shadow: 0 20px 40px rgba(0,0,0,.55), 0 4px 12px rgba(0,0,0,.4);
transition: transform .55s cubic-bezier(.22,1,.36,1),
opacity .45s ease,
box-shadow .35s ease;
will-change: transform, opacity;
-webkit-tap-highlight-color: transparent;
backface-visibility: hidden;
}
.stage.dragging .card {
transition: none !important;
}
.card img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}
.card .cap {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: 50px 14px 12px;
font-size: 13px;
line-height: 1.4;
background: linear-gradient(to top, rgba(0,0,0,.9), transparent);
opacity: 0;
transition: opacity .3s ease;
pointer-events: none;
}
.card.is-front .cap { opacity: 1; }
.card.is-front:hover {
box-shadow: 0 28px 56px rgba(0,0,0,.7), 0 0 0 2px rgba(255,255,255,.2);
}
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px; height: 48px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,.18);
background: rgba(255,255,255,.06);
color: #fff;
font-size: 24px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: background .25s, transform .25s, border-color .25s;
z-index: 100;
padding-bottom: 3px;
}
.arrow:hover {
background: rgba(255,255,255,.16);
border-color: rgba(255,255,255,.4);
}
.arrow:active { transform: translateY(-50%) scale(.92); }
.arrow.prev { left: 8px; }
.arrow.next { right: 8px; }
.dots {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 100;
max-width: 90%;
overflow-x: auto;
padding: 4px 8px;
}
.dots::-webkit-scrollbar { display: none; }
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: rgba(255,255,255,.25);
border: 0; padding: 0;
cursor: pointer;
transition: background .3s, width .3s;
flex: 0 0 auto;
}
.dot.is-on {
background: #fff;
width: 22px;
border-radius: 4px;
}
.lb-backdrop {
position: fixed;
inset: 0;
background: rgba(5,6,10,.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
opacity: 0;
transition: opacity .45s ease;
z-index: 900;
}
.lb-backdrop.is-on { opacity: 1; }
.lb-copy {
position: fixed;
z-index: 1000;
border-radius: 16px;
overflow: hidden;
cursor: zoom-out;
box-shadow: 0 40px 100px rgba(0,0,0,.8);
background: #1a1e28;
transition: left .5s cubic-bezier(.22,1,.36,1),
top .5s cubic-bezier(.22,1,.36,1),
width .5s cubic-bezier(.22,1,.36,1),
height .5s cubic-bezier(.22,1,.36,1),
border-radius .5s ease;
}
.lb-copy img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
transition: opacity .25s ease;
}
.lb-copy .cap {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: 60px 20px 16px;
background: linear-gradient(to top, rgba(0,0,0,.9), transparent);
font-size: 14px;
}
.lb-nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
width: 52px; height: 52px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,.18);
background: rgba(255,255,255,.08);
color: #fff;
font-size: 26px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 1100;
opacity: 0;
pointer-events: none;
padding-bottom: 3px;
transition: opacity .35s ease, background .25s ease;
}
.lb-nav.is-on { opacity: 1; pointer-events: auto; }
.lb-nav:hover { background: rgba(255,255,255,.2); }
.lb-nav:active { transform: translateY(-50%) scale(.94); }
.lb-nav.prev { left: 24px; }
.lb-nav.next { right: 24px; }
@media (max-width: 640px) {
:root { --card-w: 170px; --card-h: 250px; }
.arrow { width: 42px; height: 42px; font-size: 20px; }
.arrow.prev { left: 0; }
.arrow.next { right: 0; }
.dots { bottom: 30px; }
.lb-nav { width: 44px; height: 44px; font-size: 22px; }
.lb-nav.prev { left: 10px; }
.lb-nav.next { right: 10px; }
}
</style>
</head>
<body>
<div class="gallery">
<div class="stage" id="stage"></div>
<button class="arrow prev" id="prev" aria-label="上一张">‹</button>
<button class="arrow next" id="next" aria-label="下一张">›</button>
<div class="dots" id="dots"></div>
</div>
<script>
(() => {
/* =========================================================
数据:换成你自己的图片
- 少于 5 张:自动循环补全 5 个可见槽位
- 等于 5 张:正好 5 张
- 多于 5 张:可视区域只显示 5 张,其余通过循环进出
========================================================= */
const DATA = [
{ src: 'https://picsum.photos/seed/a1/600/900', cap: '雾中山谷' },
{ src: 'https://picsum.photos/seed/b2/600/900', cap: '暮色海岸' },
{ src: 'https://picsum.photos/seed/c3/600/900', cap: '林间小径' },
{ src: 'https://picsum.photos/seed/d4/600/900', cap: '雪后屋顶' },
{ src: 'https://picsum.photos/seed/e5/600/900', cap: '夜行车灯' },
];
const N = DATA.length;
const HALF = 2; // 中心左右各 2 张
const VISIBLE = 5; // 固定 5 张可见
const stage = document.getElementById('stage');
const dotsBox = document.getElementById('dots');
let current = 0;
let expanded = null;
let busy = false;
let drag = null;
let dragX = 0;
let justDragged = false;
/* 槽位坐标:0 居中,±1 两侧,±2 最外;±3 用于从屏幕外滑入 */
const POS = {
'-3': { x: -340, y: 40, rot: -22, scale: .62, z: 20, op: 0 },
'-2': { x: -255, y: 32, rot: -16, scale: .74, z: 30, op: .45 },
'-1': { x: -145, y: 16, rot: -9, scale: .87, z: 40, op: 1 },
'0': { x: 0, y: 0, rot: 0, scale: 1, z: 50, op: 1 },
'1': { x: 145, y: 16, rot: 9, scale: .87, z: 40, op: 1 },
'2': { x: 255, y: 32, rot: 16, scale: .74, z: 30, op: .45 },
'3': { x: 340, y: 40, rot: 22, scale: .62, z: 20, op: 0 },
};
/* ---------- 固定创建 5 张卡片(不随图片数量变) ---------- */
const cards = [];
for (let i = 0; i < VISIBLE; i++) {
const el = document.createElement('div');
el.className = 'card';
el.innerHTML = '<img draggable="false" alt=""><div class="cap"></div>';
const card = { el, k: i - HALF, imgIdx: -1 };
el.addEventListener('click', () => {
if (busy || expanded || justDragged) return;
if (card.k === 0) expand();
else go(current + card.k);
});
stage.appendChild(el);
cards.push(card);
}
/* ---------- 圆点:按图片数量生成 ---------- */
DATA.forEach((_, i) => {
const b = document.createElement('button');
b.className = 'dot';
b.setAttribute('aria-label', `第 ${i + 1} 张`);
b.addEventListener('click', () => go(i));
dotsBox.appendChild(b);
});
/* ---------- 工具 ---------- */
const mod = (a, b) => ((a % b) + b) % b;
function imgIdxFor(k) {
return mod(current + k, N);
}
function setCardContent(card, idx) {
if (card.imgIdx === idx) return;
card.imgIdx = idx;
const img = card.el.querySelector('img');
const cap = card.el.querySelector('.cap');
img.src = DATA[idx].src;
img.alt = DATA[idx].cap;
cap.textContent = DATA[idx].cap;
}
function transformString(k, extraX = 0) {
const cfg = POS[String(k)] || POS['0'];
const absK = Math.abs(k);
const weight = 1 - absK * 0.28;
const x = cfg.x + extraX * (weight + 0.22);
const rotY = -extraX * 0.05 * (1 - absK * 0.15);
return `translate3d(${x}px, ${cfg.y}px, 0) rotateZ(${cfg.rot}deg) rotateY(${rotY}deg) scale(${cfg.scale})`;
}
function applyTransform(el, k, extraX = 0) {
const cfg = POS[String(k)] || POS['0'];
el.style.transform = transformString(k, extraX);
el.style.zIndex = cfg.z;
el.style.opacity = cfg.op;
}
function updateDots() {
[...dotsBox.children].forEach((d, i) => {
d.classList.toggle('is-on', i === current);
});
}
/* ---------- 初始化:把 5 张卡片绑定到槽位 ---------- */
function initCards() {
cards.forEach(card => {
setCardContent(card, imgIdxFor(card.k));
applyTransform(card.el, card.k, 0);
card.el.classList.toggle('is-front', card.k === 0);
});
updateDots();
}
/* ---------- 单步切换:所有卡片槽位平移 ---------- */
function step(delta) {
if (!delta) return;
current = mod(current + delta, N);
cards.forEach(card => {
const oldK = card.k;
const rawNewK = oldK - delta;
let newK = rawNewK;
let wrapped = false;
if (rawNewK > HALF) { newK = rawNewK - VISIBLE; wrapped = true; }
if (rawNewK < -HALF) { newK = rawNewK + VISIBLE; wrapped = true; }
const newImgIdx = imgIdxFor(newK);
if (wrapped) {
// 从屏幕外滑入:先瞬移到范围外,再动画到目标位置
const fromK = rawNewK > HALF ? HALF + 1 : -HALF - 1;
const cfgFrom = POS[String(fromK)] || POS['0'];
card.el.style.transition = 'none';
card.el.style.transform = transformString(fromK, 0);
card.el.style.opacity = '0';
card.el.style.zIndex = cfgFrom.z;
setCardContent(card, newImgIdx);
card.k = newK;
void card.el.offsetWidth; // 强制 reflow
card.el.style.transition = '';
requestAnimationFrame(() => {
applyTransform(card.el, newK, 0);
});
} else {
card.k = newK;
setCardContent(card, newImgIdx);
applyTransform(card.el, newK, 0);
}
card.el.classList.toggle('is-front', newK === 0);
});
updateDots();
}
/* ---------- 跳转:计算最短 delta,单步或跨步 ---------- */
function go(idx) {
if (busy) return;
idx = mod(idx, N);
if (idx === current) return;
let delta = idx - current;
if (delta > N / 2) delta -= N;
if (delta < -N / 2) delta += N;
step(delta);
}
document.getElementById('prev').addEventListener('click', () => step(-1));
document.getElementById('next').addEventListener('click', () => step(1));
document.addEventListener('keydown', e => {
if (expanded) {
if (e.key === 'Escape') { collapse(); return; }
if (e.key === 'ArrowLeft') { step(-1); switchExpanded(); return; }
if (e.key === 'ArrowRight') { step(1); switchExpanded(); return; }
return;
}
if (e.key === 'ArrowLeft') step(-1);
if (e.key === 'ArrowRight') step(1);
});
/* ================= 拖拽 ================= */
stage.addEventListener('pointerdown', e => {
if (expanded || busy) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
drag = { id: e.pointerId, startX: e.clientX, startY: e.clientY, moved: false };
dragX = 0;
});
document.addEventListener('pointermove', e => {
if (!drag || e.pointerId !== drag.id) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
if (!drag.moved && Math.hypot(dx, dy) > 6) {
drag.moved = true;
stage.classList.add('dragging');
}
if (drag.moved) {
dragX = dx * 0.9;
cards.forEach(card => applyTransform(card.el, card.k, dragX));
e.preventDefault();
}
}, { passive: false });
document.addEventListener('pointerup', endDrag);
document.addEventListener('pointercancel', endDrag);
function endDrag(e) {
if (!drag || e.pointerId !== drag.id) return;
const moved = drag.moved;
const dx = dragX;
drag = null;
if (!moved) {
dragX = 0;
stage.classList.remove('dragging');
return;
}
justDragged = true;
setTimeout(() => { justDragged = false; }, 60);
stage.classList.remove('dragging');
void stage.offsetWidth;
const threshold = Math.min(60, window.innerWidth * 0.12);
dragX = 0;
// 拖拽后先把卡片还原到当前位置,再执行 step
cards.forEach(card => applyTransform(card.el, card.k, 0));
if (dx < -threshold) step(1);
else if (dx > threshold) step(-1);
}
/* ================= 展开 ================= */
function getCenterCard() {
return cards.find(c => c.k === 0) || cards[HALF];
}
function expand() {
if (expanded || busy) return;
const center = getCenterCard();
const rect = center.el.getBoundingClientRect();
const data = DATA[imgIdxFor(0)];
const back = document.createElement('div');
back.className = 'lb-backdrop';
document.body.appendChild(back);
const copy = document.createElement('div');
copy.className = 'lb-copy';
copy.innerHTML = `<img src="${data.src}" alt="${data.cap}"><div class="cap">${data.cap}</div>`;
Object.assign(copy.style, {
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
borderRadius: '16px',
});
document.body.appendChild(copy);
const navPrev = document.createElement('button');
navPrev.className = 'lb-nav prev';
navPrev.setAttribute('aria-label', '上一张');
navPrev.textContent = '‹';
const navNext = document.createElement('button');
navNext.className = 'lb-nav next';
navNext.setAttribute('aria-label', '下一张');
navNext.textContent = '›';
document.body.appendChild(navPrev);
document.body.appendChild(navNext);
navPrev.addEventListener('click', e => { e.stopPropagation(); step(-1); switchExpanded(); });
navNext.addEventListener('click', e => { e.stopPropagation(); step(1); switchExpanded(); });
expanded = { copy, back, navPrev, navNext };
center.el.style.opacity = '0';
void copy.offsetWidth;
requestAnimationFrame(() => {
back.classList.add('is-on');
navPrev.classList.add('is-on');
navNext.classList.add('is-on');
const vw = window.innerWidth;
const vh = window.innerHeight;
const ratio = rect.width / rect.height;
let w = vw * 0.9;
let h = w / ratio;
if (h > vh * 0.86) { h = vh * 0.86; w = h * ratio; }
const l = (vw - w) / 2;
const t = (vh - h) / 2;
Object.assign(copy.style, {
left: l + 'px',
top: t + 'px',
width: w + 'px',
height: h + 'px',
borderRadius: '10px',
});
});
copy.addEventListener('click', collapse);
back.addEventListener('click', collapse);
}
/* ---------- 大图内容切换(淡入淡出) ---------- */
function switchExpanded() {
if (!expanded) return;
const img = expanded.copy.querySelector('img');
const cap = expanded.copy.querySelector('.cap');
const data = DATA[imgIdxFor(0)];
const tmp = new Image();
tmp.onload = () => {
if (!expanded) return;
img.src = data.src;
img.alt = data.cap;
cap.textContent = data.cap;
img.style.opacity = '1';
};
img.style.opacity = '0';
tmp.src = data.src;
}
/* ================= 还原 ================= */
function collapse() {
if (!expanded || busy) return;
busy = true;
const { copy, back, navPrev, navNext } = expanded;
expanded = null;
const center = getCenterCard();
center.el.style.opacity = '';
const rect = center.el.getBoundingClientRect();
Object.assign(copy.style, {
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
borderRadius: '16px',
});
back.classList.remove('is-on');
navPrev.classList.remove('is-on');
navNext.classList.remove('is-on');
setTimeout(() => {
copy.remove();
back.remove();
navPrev.remove();
navNext.remove();
busy = false;
}, 520);
}
/* ---------- 启动 ---------- */
initCards();
})();
</script>
</body>
</html>三、行为说明
| 图片数量 | 表现 |
|---|---|
| 1 张 | 5 个槽位全部显示同一张,切换无变化,点击中间可展开 |
| 2~4 张 | 5 个槽位循环排列,切换时循环滚动 |
| 5 张 | 正好 5 张,与上一版一致 |
| 6 张以上 | 可视区域始终 5 张,其余通过左右循环进出 |
四、关键实现说明
- 固定 5 张卡片:
for (let i = 0; i < VISIBLE; i++)只创建 5 个 DOM,不再随N变化。 - 槽位
k:每张卡片有一个k ∈ [-2, 2],表示它在扇形中的位置。图片索引永远等于(current + k) % N,所以N < 5时会出现重复图片,N > 5时只有 5 张同时可见。 - 循环进出:
step(delta)里每张卡片的k减delta,超出[-2, 2]的卡片绕到另一端,并从±3(屏幕外)滑入,形成无限循环。 - 图片懒切换:
setCardContent()检查card.imgIdx !== idx才更新src,避免N = 5时同一张图被反复重载而闪烁。 - 大图与扇形同步:
step()改current并更新扇形;switchExpanded()读同一个current更新大图,两者天然一致。 - 关闭对齐:
collapse()用getCenterCard()找到当前k = 0的卡片作为缩回目标,保证大图还原后落在扇形正中。
五、可调参数
| 想改什么 | 改哪里 |
|---|---|
| 卡片大小 | :root 的 --card-w / --card-h |
| 叠加幅度、旋转 | POS 里的 x / y / rot / scale |
| 外层透明度 | POS 里的 op |
| 屏幕外滑入位置 | POS['-3'] / POS['3'] |
| 拖拽层次感 | transformString() 里的 0.28 / 0.22 |
| 拖拽切换阈值 | endDrag() 里的 Math.min(60, innerWidth * 0.12) |
| 拖拽阻尼 | pointermove 里的 dx * 0.9 |
六、验收点
- 5 张图:和之前完全一样。
- 3 张图:扇形里能看到重复图片,切换循环滚动。
- 10 张图:始终只显示 5 张,左右切换时新图从屏幕外滑入。
- 拖拽、箭头、圆点、键盘、大图放大、大图切换、关闭对齐:全部正常。
如果你希望圆点在图片超过 10 张时折叠成滚动条,或者少于 5 张时不显示重复、只显示实际数量,告诉我,我再加一个开关。




