Retour au catalogue
Before After Fade Overlay
Transition en fondu entre avant/apres controlee par un slider vertical. Overlay progressif avec opacite.
before-aftermedium Both Responsive a11y
elegantminimalbeautyreal-estateagencyuniversalcentered
Theme
"use client";
import { useState, useCallback, useRef } from "react";
import { motion } from "framer-motion";
import { GripHorizontal } from "lucide-react";
interface BeforeAfterFadeOverlayProps {
title?: string;
description?: string;
beforeLabel?: string;
afterLabel?: string;
beforeImage?: string;
afterImage?: string;
beforeColor?: string;
afterColor?: string;
}
const EASE = [0.16, 1, 0.3, 1] as const;
export default function BeforeAfterFadeOverlay({
title = "Transition en fondu",
description = "Glissez verticalement pour reveler le resultat",
beforeLabel = "Avant",
afterLabel = "Apres",
beforeImage,
afterImage,
beforeColor = "var(--color-background-alt)",
afterColor = "var(--color-accent)",
}: BeforeAfterFadeOverlayProps) {
const [opacity, setOpacity] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const handleMove = useCallback((clientY: number) => {
if (!containerRef.current || !isDragging.current) return;
const rect = containerRef.current.getBoundingClientRect();
const y = Math.max(0, Math.min(clientY - rect.top, rect.height));
setOpacity(y / rect.height);
}, []);
const onMouseDown = useCallback(() => {
isDragging.current = true;
const onMove = (e: MouseEvent) => handleMove(e.clientY);
const onUp = () => {
isDragging.current = false;
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, [handleMove]);
const onTouchStart = useCallback(() => {
isDragging.current = true;
const onMove = (e: TouchEvent) => handleMove(e.touches[0].clientY);
const onEnd = () => {
isDragging.current = false;
window.removeEventListener("touchmove", onMove);
window.removeEventListener("touchend", onEnd);
};
window.addEventListener("touchmove", onMove);
window.addEventListener("touchend", onEnd);
}, [handleMove]);
const sliderPosition = opacity * 100;
return (
<section style={{ padding: "var(--section-padding-y) 0", background: "var(--color-background)" }}>
<div style={{ maxWidth: "var(--container-max-width)", margin: "0 auto", padding: "0 var(--container-padding-x)" }}>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: EASE }}
style={{ textAlign: "center", marginBottom: "2.5rem" }}
>
<h2 style={{ fontFamily: "var(--font-sans)", fontSize: "clamp(1.75rem, 3.5vw, 2.75rem)", fontWeight: 700, color: "var(--color-foreground)", marginBottom: "0.75rem" }}>
{title}
</h2>
<p style={{ fontSize: "1.0625rem", color: "var(--color-foreground-muted)" }}>{description}</p>
</motion.div>
<motion.div
ref={containerRef}
initial={{ opacity: 0, scale: 0.98 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: EASE }}
style={{
position: "relative",
width: "100%",
aspectRatio: "16 / 10",
borderRadius: "var(--radius-xl)",
overflow: "hidden",
cursor: "ns-resize",
border: "1px solid var(--color-border)",
userSelect: "none",
}}
>
{/* Before layer */}
<div style={{ position: "absolute", inset: 0, background: beforeImage ? `url(${beforeImage}) center/cover` : beforeColor, display: "flex", alignItems: "center", justifyContent: "center" }}>
{!beforeImage && <span style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-foreground-muted)", opacity: 0.5 }}>{beforeLabel}</span>}
</div>
{/* After layer with fade */}
<div style={{ position: "absolute", inset: 0, background: afterImage ? `url(${afterImage}) center/cover` : afterColor, opacity, transition: "opacity 0.05s ease", display: "flex", alignItems: "center", justifyContent: "center" }}>
{!afterImage && <span style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-foreground-muted)", opacity: 0.5 }}>{afterLabel}</span>}
</div>
{/* Horizontal divider line */}
<div style={{ position: "absolute", left: 0, right: 0, top: `${sliderPosition}%`, height: 2, background: "var(--color-foreground)", transform: "translateY(-50%)", pointerEvents: "none" }} />
{/* Handle */}
<div
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
style={{
position: "absolute",
left: "50%",
top: `${sliderPosition}%`,
transform: "translate(-50%, -50%)",
width: 40,
height: 40,
borderRadius: "var(--radius-full)",
background: "var(--color-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "ns-resize",
zIndex: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
}}
>
<GripHorizontal style={{ width: 18, height: 18, color: "var(--color-background)" }} />
</div>
{/* Labels */}
<span style={{ position: "absolute", top: 12, left: 12, fontSize: "0.75rem", fontWeight: 600, padding: "0.25rem 0.75rem", borderRadius: "var(--radius-full)", background: "var(--color-background)", color: "var(--color-foreground)", border: "1px solid var(--color-border)" }}>
{beforeLabel}
</span>
<span style={{ position: "absolute", bottom: 12, right: 12, fontSize: "0.75rem", fontWeight: 600, padding: "0.25rem 0.75rem", borderRadius: "var(--radius-full)", background: "var(--color-background)", color: "var(--color-foreground)", border: "1px solid var(--color-border)" }}>
{afterLabel}
</span>
</motion.div>
</div>
</section>
);
}