UI Components Medium
Image Comparison Slider
A smooth, interactive before-and-after image comparison slider. Perfect for showcasing edits, redesigns, or transformations.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
.comparison-container {
max-width: 800px;
width: 100%;
aspect-ratio: 16 / 9;
position: relative;
overflow: hidden;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
user-select: none;
margin: 0 auto;
}
.image-after,
.image-before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.image-after img,
.image-before img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-before {
width: 50%;
overflow: hidden;
z-index: 2;
border-right: 2px solid white;
}
.image-before img {
/* ensure the image in the clipped div stays full width */
width: 800px;
max-width: none;
}
.label {
position: absolute;
bottom: 20px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.5);
color: white;
font-family: sans-serif;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
border-radius: 4px;
backdrop-filter: blur(4px);
}
.label.before {
left: 20px;
}
.label.after {
right: 20px;
}
.slider-handle {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4px;
background: white;
z-index: 3;
cursor: ew-resize;
transform: translateX(-50%);
}
.handle-circle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
.handle-arrows {
font-size: 1.25rem;
color: #333;
}const container = document.getElementById("comparison-slider");
const beforeImg = container.querySelector(".image-before");
const beforeImgContent = beforeImg.querySelector("img");
const handle = container.querySelector(".slider-handle");
let isResizing = false;
function setPosition(x) {
const containerRect = container.getBoundingClientRect();
let pos = ((x - containerRect.left) / containerRect.width) * 100;
// Bounds
pos = Math.max(0, Math.min(100, pos));
beforeImg.style.width = `${pos}%`;
handle.style.left = `${pos}%`;
// Keep the inner image full container width to avoid scaling
beforeImgContent.style.width = `${containerRect.width}px`;
}
function startResizing() {
isResizing = true;
}
function stopResizing() {
isResizing = false;
}
function handleResize(e) {
if (!isResizing) return;
const x = e.type.includes("touch") ? e.touches[0].clientX : e.clientX;
setPosition(x);
}
// Event Listeners
handle.addEventListener("mousedown", startResizing);
window.addEventListener("mouseup", stopResizing);
window.addEventListener("mousemove", handleResize);
handle.addEventListener("touchstart", startResizing);
window.addEventListener("touchend", stopResizing);
window.addEventListener("touchmove", handleResize);
// Update inner image width on window resize
window.addEventListener("resize", () => {
beforeImgContent.style.width = `${container.offsetWidth}px`;
});
// Initial Position
setPosition(container.getBoundingClientRect().left + container.offsetWidth / 2);<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Comparison</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="comparison-container" id="comparison-slider">
<div class="image-after">
<img src="https://images.unsplash.com/photo-1541963463532-d68292c34b19?w=800&q=80" alt="After" />
<span class="label after">After</span>
</div>
<div class="image-before">
<img src="https://images.unsplash.com/photo-1541963463532-d68292c34b19?w=800&q=80&sepia=1" alt="Before" />
<span class="label before">Before</span>
</div>
<div class="slider-handle">
<div class="handle-line"></div>
<div class="handle-circle">
<span class="handle-arrows">⇄</span>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useCallback } from "react";
const PAIRS = [
{
label: "Dark / Light Mode",
before: { label: "Dark", bg: "#0d1117", items: ["#58a6ff", "#bc8cff", "#7ee787"] },
after: { label: "Light", bg: "#ffffff", items: ["#0969da", "#8250df", "#1a7f37"] },
},
{
label: "Blur / Sharp",
before: { label: "Blurred", bg: "#1c1c2e", items: ["#bc8cff", "#58a6ff", "#ff6b6b"] },
after: { label: "Sharp", bg: "#1c1c2e", items: ["#e040fb", "#29b6f6", "#ef5350"] },
},
];
function CompareSlider({ pair }: { pair: (typeof PAIRS)[0] }) {
const [pos, setPos] = useState(50);
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const updatePos = useCallback((clientX: number) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setPos(Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100)));
}, []);
function onMouseMove(e: React.MouseEvent) {
if (dragging) updatePos(e.clientX);
}
function onTouchMove(e: React.TouchEvent) {
updatePos(e.touches[0].clientX);
}
function MockUI({ side }: { side: typeof pair.before }) {
return (
<div className="w-full h-full flex flex-col p-4 gap-3" style={{ background: side.bg }}>
<div className="flex items-center gap-2">
{side.items.map((c, i) => (
<div
key={i}
className="h-2 rounded-full"
style={{ background: c, width: `${[40, 25, 35][i]}%` }}
/>
))}
</div>
<div className="grid grid-cols-2 gap-2 flex-1">
{side.items.map((c, i) => (
<div
key={i}
className="rounded-lg border border-white/10 p-2 flex items-end"
style={{ background: `${c}20` }}
>
<div className="h-1.5 rounded-full w-full" style={{ background: c, opacity: 0.7 }} />
</div>
))}
</div>
<div
className="h-1 rounded-full w-3/4"
style={{ background: side.items[0], opacity: 0.4 }}
/>
</div>
);
}
return (
<div>
<p className="text-[#8b949e] text-xs mb-2 text-center">{pair.label}</p>
<div
ref={containerRef}
className="relative h-36 rounded-xl overflow-hidden cursor-col-resize select-none border border-[#30363d]"
onMouseMove={onMouseMove}
onMouseUp={() => setDragging(false)}
onMouseLeave={() => setDragging(false)}
onTouchMove={onTouchMove}
onTouchEnd={() => setDragging(false)}
>
{/* Before (full width) */}
<div className="absolute inset-0">
<MockUI side={pair.before} />
</div>
{/* After (clipped) */}
<div className="absolute inset-0" style={{ clipPath: `inset(0 0 0 ${pos}%)` }}>
<MockUI side={pair.after} />
</div>
{/* Divider */}
<div className="absolute top-0 bottom-0 w-px bg-white z-10" style={{ left: `${pos}%` }}>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-7 h-7 bg-white rounded-full flex items-center justify-center shadow-lg cursor-col-resize"
onMouseDown={() => setDragging(true)}
onTouchStart={() => setDragging(true)}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#333"
strokeWidth="2.5"
>
<polyline points="8 4 4 12 8 20" />
<polyline points="16 4 20 12 16 20" />
</svg>
</div>
</div>
{/* Labels */}
<span className="absolute top-2 left-2 text-[10px] bg-black/50 text-white px-1.5 py-0.5 rounded">
{pair.before.label}
</span>
<span className="absolute top-2 right-2 text-[10px] bg-black/50 text-white px-1.5 py-0.5 rounded">
{pair.after.label}
</span>
</div>
</div>
);
}
export default function ImageComparisonRC() {
return (
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center p-6">
<div className="w-full max-w-sm space-y-5">
<h2 className="text-[#e6edf3] font-bold text-lg">Image Comparison</h2>
{PAIRS.map((pair) => (
<CompareSlider key={pair.label} pair={pair} />
))}
<p className="text-[11px] text-center text-[#484f58]">Drag the handle to compare</p>
</div>
</div>
);
}<script setup>
import { ref } from "vue";
const PAIRS = [
{
label: "Dark / Light Mode",
before: { label: "Dark", bg: "#0d1117", items: ["#58a6ff", "#bc8cff", "#7ee787"] },
after: { label: "Light", bg: "#ffffff", items: ["#0969da", "#8250df", "#1a7f37"] },
},
{
label: "Blur / Sharp",
before: { label: "Blurred", bg: "#1c1c2e", items: ["#bc8cff", "#58a6ff", "#ff6b6b"] },
after: { label: "Sharp", bg: "#1c1c2e", items: ["#e040fb", "#29b6f6", "#ef5350"] },
},
];
const widths = [40, 25, 35];
const positions = ref(PAIRS.map(() => 50));
const draggingIndex = ref(-1);
function updatePos(index, clientX, container) {
const rect = container.getBoundingClientRect();
positions.value[index] = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
}
function onMouseMove(e, index) {
if (draggingIndex.value === index) {
updatePos(index, e.clientX, e.currentTarget);
}
}
function onTouchMove(e, index) {
updatePos(index, e.touches[0].clientX, e.currentTarget);
}
function startDrag(index) {
draggingIndex.value = index;
}
function stopDrag() {
draggingIndex.value = -1;
}
</script>
<template>
<div class="image-comparison">
<div class="inner">
<h2 class="title">Image Comparison</h2>
<div v-for="(pair, index) in PAIRS" :key="pair.label">
<p class="pair-label">{{ pair.label }}</p>
<div
class="slider-container"
@mousemove="(e) => onMouseMove(e, index)"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@touchmove="(e) => onTouchMove(e, index)"
@touchend="stopDrag"
>
<!-- Before -->
<div class="layer">
<div class="mock-ui" :style="{ background: pair.before.bg }">
<div class="bar-row">
<div v-for="(c, i) in pair.before.items" :key="i" class="bar" :style="{ background: c, width: widths[i] + '%' }" />
</div>
<div class="grid-row">
<div v-for="(c, i) in pair.before.items" :key="i" class="grid-cell" :style="{ background: c + '20', borderColor: 'rgba(255,255,255,0.1)' }">
<div class="cell-bar" :style="{ background: c, opacity: 0.7 }" />
</div>
</div>
<div class="bottom-bar" :style="{ background: pair.before.items[0], opacity: 0.4 }" />
</div>
</div>
<!-- After -->
<div class="layer" :style="{ clipPath: `inset(0 0 0 ${positions[index]}%)` }">
<div class="mock-ui" :style="{ background: pair.after.bg }">
<div class="bar-row">
<div v-for="(c, i) in pair.after.items" :key="i" class="bar" :style="{ background: c, width: widths[i] + '%' }" />
</div>
<div class="grid-row">
<div v-for="(c, i) in pair.after.items" :key="i" class="grid-cell" :style="{ background: c + '20', borderColor: 'rgba(255,255,255,0.1)' }">
<div class="cell-bar" :style="{ background: c, opacity: 0.7 }" />
</div>
</div>
<div class="bottom-bar" :style="{ background: pair.after.items[0], opacity: 0.4 }" />
</div>
</div>
<!-- Divider -->
<div class="divider" :style="{ left: positions[index] + '%' }">
<div class="handle" @mousedown="startDrag(index)" @touchstart="startDrag(index)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2.5">
<polyline points="8 4 4 12 8 20" /><polyline points="16 4 20 12 16 20" />
</svg>
</div>
</div>
<!-- Labels -->
<span class="label-before">{{ pair.before.label }}</span>
<span class="label-after">{{ pair.after.label }}</span>
</div>
</div>
<p class="hint">Drag the handle to compare</p>
</div>
</div>
</template>
<style scoped>
.image-comparison {
min-height: 100vh;
background: #0d1117;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.inner {
width: 100%;
max-width: 24rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.title {
color: #e6edf3;
font-weight: 700;
font-size: 1.125rem;
margin: 0;
}
.pair-label {
color: #8b949e;
font-size: 0.75rem;
margin: 0 0 0.5rem;
text-align: center;
}
.slider-container {
position: relative;
height: 9rem;
border-radius: 0.75rem;
overflow: hidden;
cursor: col-resize;
user-select: none;
border: 1px solid #30363d;
}
.layer {
position: absolute;
inset: 0;
}
.mock-ui {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 1rem;
gap: 0.75rem;
}
.bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bar {
height: 0.5rem;
border-radius: 9999px;
}
.grid-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
flex: 1;
}
.grid-cell {
border-radius: 0.5rem;
border: 1px solid;
padding: 0.5rem;
display: flex;
align-items: flex-end;
}
.cell-bar {
height: 0.375rem;
border-radius: 9999px;
width: 100%;
}
.bottom-bar {
height: 0.25rem;
border-radius: 9999px;
width: 75%;
}
.divider {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: white;
z-index: 10;
}
.handle {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 1.75rem;
height: 1.75rem;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
cursor: col-resize;
}
.label-before {
position: absolute;
top: 0.5rem;
left: 0.5rem;
font-size: 10px;
background: rgba(0,0,0,0.5);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.label-after {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-size: 10px;
background: rgba(0,0,0,0.5);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.hint {
font-size: 11px;
text-align: center;
color: #484f58;
margin: 0;
}
</style><script>
const PAIRS = [
{
label: "Dark / Light Mode",
before: { label: "Dark", bg: "#0d1117", items: ["#58a6ff", "#bc8cff", "#7ee787"] },
after: { label: "Light", bg: "#ffffff", items: ["#0969da", "#8250df", "#1a7f37"] },
},
{
label: "Blur / Sharp",
before: { label: "Blurred", bg: "#1c1c2e", items: ["#bc8cff", "#58a6ff", "#ff6b6b"] },
after: { label: "Sharp", bg: "#1c1c2e", items: ["#e040fb", "#29b6f6", "#ef5350"] },
},
];
let positions = PAIRS.map(() => 50);
let draggingIndex = -1;
function updatePos(index, clientX, container) {
const rect = container.getBoundingClientRect();
positions[index] = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
positions = positions;
}
function onMouseMove(e, index) {
if (draggingIndex === index) {
updatePos(index, e.clientX, e.currentTarget);
}
}
function onTouchMove(e, index) {
updatePos(index, e.touches[0].clientX, e.currentTarget);
}
function startDrag(index) {
draggingIndex = index;
}
function stopDrag() {
draggingIndex = -1;
}
const widths = [40, 25, 35];
</script>
<div class="image-comparison">
<div class="inner">
<h2 class="title">Image Comparison</h2>
{#each PAIRS as pair, index}
<div>
<p class="pair-label">{pair.label}</p>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="slider-container"
on:mousemove={(e) => onMouseMove(e, index)}
on:mouseup={stopDrag}
on:mouseleave={stopDrag}
on:touchmove={(e) => onTouchMove(e, index)}
on:touchend={stopDrag}
>
<!-- Before -->
<div class="layer">
<div class="mock-ui" style="background: {pair.before.bg};">
<div class="bar-row">
{#each pair.before.items as c, i}
<div class="bar" style="background: {c}; width: {widths[i]}%;" />
{/each}
</div>
<div class="grid-row">
{#each pair.before.items as c, i}
<div class="grid-cell" style="background: {c}20; border-color: rgba(255,255,255,0.1);">
<div class="cell-bar" style="background: {c}; opacity: 0.7;" />
</div>
{/each}
</div>
<div class="bottom-bar" style="background: {pair.before.items[0]}; opacity: 0.4;" />
</div>
</div>
<!-- After (clipped) -->
<div class="layer" style="clip-path: inset(0 0 0 {positions[index]}%);">
<div class="mock-ui" style="background: {pair.after.bg};">
<div class="bar-row">
{#each pair.after.items as c, i}
<div class="bar" style="background: {c}; width: {widths[i]}%;" />
{/each}
</div>
<div class="grid-row">
{#each pair.after.items as c, i}
<div class="grid-cell" style="background: {c}20; border-color: rgba(255,255,255,0.1);">
<div class="cell-bar" style="background: {c}; opacity: 0.7;" />
</div>
{/each}
</div>
<div class="bottom-bar" style="background: {pair.after.items[0]}; opacity: 0.4;" />
</div>
</div>
<!-- Divider -->
<div class="divider" style="left: {positions[index]}%;">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="handle"
on:mousedown={() => startDrag(index)}
on:touchstart={() => startDrag(index)}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2.5">
<polyline points="8 4 4 12 8 20" /><polyline points="16 4 20 12 16 20" />
</svg>
</div>
</div>
<!-- Labels -->
<span class="label-before">{pair.before.label}</span>
<span class="label-after">{pair.after.label}</span>
</div>
</div>
{/each}
<p class="hint">Drag the handle to compare</p>
</div>
</div>
<style>
.image-comparison {
min-height: 100vh;
background: #0d1117;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.inner {
width: 100%;
max-width: 24rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.title {
color: #e6edf3;
font-weight: 700;
font-size: 1.125rem;
margin: 0;
}
.pair-label {
color: #8b949e;
font-size: 0.75rem;
margin: 0 0 0.5rem;
text-align: center;
}
.slider-container {
position: relative;
height: 9rem;
border-radius: 0.75rem;
overflow: hidden;
cursor: col-resize;
user-select: none;
border: 1px solid #30363d;
}
.layer {
position: absolute;
inset: 0;
}
.mock-ui {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 1rem;
gap: 0.75rem;
}
.bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bar {
height: 0.5rem;
border-radius: 9999px;
}
.grid-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
flex: 1;
}
.grid-cell {
border-radius: 0.5rem;
border: 1px solid;
padding: 0.5rem;
display: flex;
align-items: flex-end;
}
.cell-bar {
height: 0.375rem;
border-radius: 9999px;
width: 100%;
}
.bottom-bar {
height: 0.25rem;
border-radius: 9999px;
width: 75%;
}
.divider {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: white;
z-index: 10;
}
.handle {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 1.75rem;
height: 1.75rem;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
cursor: col-resize;
}
.label-before {
position: absolute;
top: 0.5rem;
left: 0.5rem;
font-size: 10px;
background: rgba(0,0,0,0.5);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.label-after {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-size: 10px;
background: rgba(0,0,0,0.5);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.hint {
font-size: 11px;
text-align: center;
color: #484f58;
margin: 0;
}
</style>Image Comparison Slider
An intuitive slider component that allows users to compare two images side-by-side. Featuring a touch-friendly handle and smooth transitions.
Features
- Side-by-side comparison with a draggable divider
- Mobile/Touch support
- Customizable labels (Before/After)
- Smooth CSS-driven interactions
- Responsive container handling