Side By Side Slide
An image comparison slider where a spring-animated divider follows the cursor on hover, revealing before/after images.
Installation
File Structure
Usage
import { SideBySideSlide } from "@/components/unlumen-ui/side-by-side-slide";
<SideBySideSlide
beforeImage="/images/before.jpg"
afterImage="/images/after.jpg"
className="aspect-video rounded-xl"
/>;Vertical orientation
<SideBySideSlide
beforeImage="/images/before.jpg"
afterImage="/images/after.jpg"
orientation="vertical"
className="aspect-video rounded-xl"
/>Custom divider
<SideBySideSlide
beforeImage="/images/before.jpg"
afterImage="/images/after.jpg"
dividerColor="#3b82f6"
dividerWidth={4}
dividerShadow="0 0 16px rgba(59,130,246,0.6)"
handleColor="#3b82f6"
className="aspect-video rounded-xl"
/>No handle
<SideBySideSlide
beforeImage="/images/before.jpg"
afterImage="/images/after.jpg"
showHandle={false}
className="aspect-video rounded-xl"
/>Custom spring
<SideBySideSlide
beforeImage="/images/before.jpg"
afterImage="/images/after.jpg"
springOptions={{ stiffness: 500, damping: 40 }}
className="aspect-video rounded-xl"
/>API Reference
SideBySideSlide
beforeImagestring—Source URL for the "before" image (left or top half).
afterImagestring—Source URL for the "after" image (right or bottom half).
beforeAlt?string"Before"Alt text for the before image.
afterAlt?string"After"Alt text for the after image.
orientation?"horizontal" | "vertical""horizontal"Direction of the divider. Horizontal splits left/right; vertical splits top/bottom.
initialPosition?number50Starting position of the divider as a percentage (0–100). The divider springs back to this value on mouse leave.
dividerColor?string"white"CSS color value for the divider line.
dividerWidth?number2Thickness of the divider line in pixels.
dividerShadow?string"0 0 8px rgba(0,0,0,0.3)"CSS box-shadow applied to the divider line.
showHandle?booleantrueWhether to render the circular drag handle on the divider.
handleSize?number40Diameter of the circular handle in pixels.
handleColor?string"white"Background color of the circular handle.
cursor?"none" | "col-resize" | "row-resize" | "pointer""none"CSS cursor style applied when hovering over the component. Use `none` to hide the cursor entirely.
springOptions?SpringOptions{ stiffness: 300, damping: 30 }Motion spring config forwarded to `useSpring`. Controls how the divider accelerates and settles.
className?string—Extra CSS classes applied to the root container.
Notes
- The divider position is driven by
useMotionValue+useSpring, bypassing React's render cycle for smooth 60 fps tracking. - Both images are sized
object-cover— they always fill the container regardless of intrinsic dimensions. Wrap the component in anaspect-*class to control the ratio. - On mouse leave the divider springs back to
initialPosition, giving users a clear preview of the default split. clip-path: inset()is used to reveal the before image rather than resizing it, so both images remain full-size at all times.- Set
cursor="none"(the default) for the cleanest look; switch to"col-resize"or"row-resize"to hint that the area is interactive.
Credits
Built by Léo. Inspired by the Side By Side Slide Framer component by Alexander Ghavas.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useSpring,
useTransform,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
export type SideBySideSlideProps = {
/** Source URL for the "before" image (left / top). */
beforeImage: string;
/** Source URL for the "after" image (right / bottom). */
afterImage: string;
/** Alt text for the before image. */
beforeAlt?: string;
/** Alt text for the after image. */
afterAlt?: string;
/** Divider direction. */
orientation?: "horizontal" | "vertical";
/** Initial divider position as a percentage (0–100). */
initialPosition?: number;
/** CSS color of the divider line. */
dividerColor?: string;
/** Width (or height for vertical) of the divider in px. */
dividerWidth?: number;
/** Box-shadow applied to the divider. */
dividerShadow?: string;
/** Whether to show the circular handle on the divider. */
showHandle?: boolean;
/** Diameter of the handle circle in px. */
handleSize?: number;
/** Background color of the handle. */
handleColor?: string;
/** Cursor style when hovering over the component. */
cursor?: "none" | "col-resize" | "row-resize" | "pointer";
/** Spring configuration for the divider animation. */
springOptions?: SpringOptions;
/** Extra CSS classes on the root container. */
className?: string;
};
export function SideBySideSlide({
beforeImage,
afterImage,
beforeAlt = "Before",
afterAlt = "After",
orientation = "horizontal",
initialPosition = 50,
dividerColor = "white",
dividerWidth = 2,
dividerShadow = "0 0 8px rgba(0,0,0,0.3)",
showHandle = true,
handleSize = 40,
handleColor = "white",
cursor = "none",
springOptions = { stiffness: 300, damping: 30 },
className,
}: SideBySideSlideProps) {
const ref = React.useRef<HTMLDivElement>(null);
const isHorizontal = orientation === "horizontal";
const raw = useMotionValue(initialPosition);
const position = useSpring(raw, springOptions);
const clipPath = useTransform(position, (v) =>
isHorizontal ? `inset(0 ${100 - v}% 0 0)` : `inset(0 0 ${100 - v}% 0)`,
);
const dividerPos = useTransform(position, (v) => `${v}%`);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const pct = isHorizontal
? ((e.clientX - rect.left) / rect.width) * 100
: ((e.clientY - rect.top) / rect.height) * 100;
raw.set(Math.max(0, Math.min(100, pct)));
};
const handleMouseLeave = () => {
raw.set(initialPosition);
};
return (
<div
ref={ref}
className={cn("relative select-none overflow-hidden", className)}
style={{ cursor }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
{/* After image (bottom layer — in flow to give the container intrinsic height) */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={afterImage}
alt={afterAlt}
draggable={false}
className="block h-full w-full object-cover"
/>
{/* Before image (clipped top layer) */}
<motion.div className="absolute inset-0" style={{ clipPath }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={beforeImage}
alt={beforeAlt}
draggable={false}
className="block h-full w-full object-cover"
/>
</motion.div>
{/* Divider line */}
<motion.div
className="absolute"
style={
isHorizontal
? {
left: dividerPos,
top: 0,
bottom: 0,
width: dividerWidth,
x: "-50%",
backgroundColor: dividerColor,
boxShadow: dividerShadow,
}
: {
top: dividerPos,
left: 0,
right: 0,
height: dividerWidth,
y: "-50%",
backgroundColor: dividerColor,
boxShadow: dividerShadow,
}
}
>
{/* Handle */}
{showHandle && (
<div
className="absolute rounded-full flex items-center justify-center"
style={
isHorizontal
? {
width: handleSize,
height: handleSize,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
backgroundColor: handleColor,
boxShadow: dividerShadow,
}
: {
width: handleSize,
height: handleSize,
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
backgroundColor: handleColor,
boxShadow: dividerShadow,
}
}
></div>
)}
</motion.div>
</div>
);
}