前后对比滑块
在图片上拖动分隔线,对比修改后的版本和下面的原图。
修改前修改后
用 React 制作一个无障碍的前后对比图片组件。叠放两张同尺寸的图片;“修改后”的图片在上层,并用 clip-path: inset(0 calc(100% - var(--pos) * 1%) 0 0) 裁切。在整张图片上覆盖一个 opacity 0 的原生 <input type="range">(0–100,aria-label 为“对比修改前后”),由它处理拖动、触摸和方向键,并把数值写入 --pos。在 left: calc(var(--pos) * 1%) 处画一条 2px 的分隔线和一个 40px 的圆形手柄,手柄内放两个小箭头。在两个角落显示“修改前”和“修改后”标签,被分隔线遮住时淡出。用键盘聚焦滑块时,在手柄上显示焦点环。初始位置为 50%。裁切本身不加过渡,让它精确跟随指针。
源代码
'use client'
import { useState } from 'react'
import s from './demo.module.css'
export default function BeforeAfter({ labels }: { labels: Record<string, string> }) {
const [pos, setPos] = useState(50)
return (
<div className={s.frame} style={{ '--pos': pos } as React.CSSProperties}>
<div className={`${s.scene} ${s.before}`} aria-hidden="true" />
<div className={`${s.scene} ${s.after}`} aria-hidden="true" />
<span className={`${s.tag} ${s.tagBefore}`} data-hidden={pos < 22 || undefined}>
{labels.before}
</span>
<span className={`${s.tag} ${s.tagAfter}`} data-hidden={pos > 78 || undefined}>
{labels.after}
</span>
<span className={s.divider} aria-hidden="true">
<span className={s.handle}>‹ ›</span>
</span>
<input
type="range"
min={0}
max={100}
value={pos}
aria-label={labels.label}
className={s.input}
onChange={(e) => setPos(Number(e.target.value))}
/>
</div>
)
}
.frame {
/* scene palette: a CSS-drawn landscape stands in for a photo */
--sky-top: oklch(78% 0.08 70);
--sky-low: oklch(70% 0.13 50);
--sun: oklch(92% 0.09 85);
--hill-far: oklch(48% 0.07 40);
--hill-near: oklch(30% 0.04 45);
position: relative;
width: min(300px, 100%);
aspect-ratio: 3 / 2;
max-width: 100%;
overflow: hidden;
border-radius: 14px;
user-select: none;
}
.scene {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 70% 40% at 25% 105%, var(--hill-near) 98%, transparent 100%),
radial-gradient(ellipse 80% 45% at 85% 100%, var(--hill-far) 98%, transparent 100%),
radial-gradient(circle at 68% 42%, var(--sun) 0 11%, transparent 11.5%),
linear-gradient(180deg, var(--sky-top), var(--sky-low));
}
.before { filter: grayscale(1) contrast(0.85) brightness(0.9); }
.after { clip-path: inset(0 calc(100% - var(--pos) * 1%) 0 0); }
.tag {
position: absolute;
top: 10px;
padding: 4px 8px;
border-radius: 6px;
background: var(--stage-bg);
color: var(--stage-ink);
font: 500 var(--text-xs) / 1 var(--font-mono);
transition: opacity 150ms linear;
}
.tagAfter { left: 10px; }
.tagBefore { right: 10px; }
.tag[data-hidden] { opacity: 0; }
.divider {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--pos) * 1%);
width: 2px;
margin-left: -1px;
background: var(--stage-ink);
pointer-events: none;
}
.handle {
position: absolute;
top: 50%;
left: 50%;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--stage-ink);
color: var(--stage-bg);
font: 700 var(--text-base) / 1 var(--font-body);
transform: translate(-50%, -50%);
}
.input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: ew-resize;
}
.input:focus-visible ~ .divider .handle,
.frame:has(.input:focus-visible) .handle { outline: 2px solid var(--color-focus); outline-offset: 3px; }