Components / Images
Compare
Two photos of the same scene with a line you drag, or move with the arrow keys, to show more of one or the other.
Props
#F4F2EC
Set in code: First photo, Second photo. The preview fills these in with demo content.
Drag the line, or use the arrow keys.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Compare shows up in the Insert menu, and its controls appear in the right panel.
// Compare · Layr Components
//
// Two photos of the same scene with a line you drag, or move with the arrow
// keys, to show more of one or the other.
// Paste into a new code file in Framer. Without photos it shows two plain tones.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"
type Img = { src: string; srcSet?: string; alt?: string }
interface CompareProps {
before?: Img
after?: Img
beforeLabel: string
afterLabel: string
start: number
imageWidth: string
fontFamily: string
onPhoto: string
cornerRadius: number
style?: CSSProperties
}
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const SHEET = `
.lyr-cmp{position:relative;width:100%;height:100%;overflow:hidden;isolation:isolate;touch-action:pan-y;cursor:ew-resize;user-select:none;-webkit-user-select:none;outline:none}
.lyr-cmp-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;-webkit-user-drag:none;pointer-events:none}
.lyr-cmp-a{clip-path:inset(0 0 0 calc(var(--x) * 1%))}
.lyr-cmp-line{position:absolute;top:0;bottom:0;left:calc(var(--x) * 1%);width:1px;margin-left:-.5px;background:var(--onphoto);pointer-events:none}
.lyr-cmp-knob{position:absolute;top:50%;left:calc(var(--x) * 1%);width:46px;height:46px;margin:-23px 0 0 -23px;border-radius:50%;border:1px solid var(--onphoto);background:rgba(10,10,10,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:grid;place-items:center;color:var(--onphoto);pointer-events:none;transition:transform .2s}
.lyr-cmp:active .lyr-cmp-knob{transform:scale(.92)}
.lyr-cmp:focus-visible .lyr-cmp-knob{outline:1px solid var(--onphoto);outline-offset:4px}
.lyr-cmp-lab{position:absolute;bottom:16px;font-size:14px;letter-spacing:-.005em;color:var(--onphoto);text-shadow:0 1px 14px rgba(0,0,0,.5);pointer-events:none;transition:opacity .25s}
.lyr-cmp-l{left:18px;opacity:clamp(0,calc((var(--x) - 14) / 10),1)}
.lyr-cmp-r{right:18px;opacity:clamp(0,calc((86 - var(--x)) / 10),1)}
`
// Shown when a photo is not set yet: two quiet tones, so the line still reads.
const EMPTY = ["linear-gradient(160deg, #c9c4ba, #8f897e)", "linear-gradient(160deg, #6f7a7d, #3b4447)"]
/**
* @framerIntrinsicWidth 1120
* @framerIntrinsicHeight 620
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight fixed
*/
export default function Compare(props: CompareProps) {
const {
before,
after,
beforeLabel = "Before",
afterLabel = "After",
start = 50,
imageWidth = "92vw",
fontFamily = "Inter",
onPhoto = "#F4F2EC",
cornerRadius = 2,
} = props
const isStatic = useIsStaticRenderer()
const root = useRef<HTMLDivElement>(null)
const x = useRef(start)
const dragging = useRef(false)
const set = (value: number) => {
x.current = clamp(value, 0, 100)
const host = root.current
if (!host) return
host.style.setProperty("--x", x.current.toFixed(2))
host.setAttribute("aria-valuenow", String(Math.round(x.current)))
}
const fromPointer = (e: { clientX: number }) => {
const host = root.current
if (!host) return
const r = host.getBoundingClientRect()
if (r.width > 0) set(((e.clientX - r.left) / r.width) * 100)
}
useEffect(() => {
set(start)
}, [start])
const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
const vars = { "--x": String(start), "--onphoto": onPhoto } as CSSProperties
const picture = (img: Img | undefined, label: string, k: 0 | 1) =>
img?.src ? (
<img className={k ? "lyr-cmp-img lyr-cmp-a" : "lyr-cmp-img"} srcSet={img.srcSet} sizes={imageWidth} src={img.src} alt={img.alt || label} loading="lazy" decoding="async" draggable={false} />
) : (
<div className={k ? "lyr-cmp-img lyr-cmp-a" : "lyr-cmp-img"} role="img" aria-label={label} style={{ background: EMPTY[k] }} />
)
return (
<div
ref={root}
className="lyr-cmp"
role="slider"
tabIndex={0}
aria-label={`${beforeLabel} and ${afterLabel}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(start)}
style={{ ...props.style, ...vars, fontFamily: font, borderRadius: cornerRadius }}
onPointerDown={
isStatic
? undefined
: (e) => {
dragging.current = true
e.currentTarget.setPointerCapture(e.pointerId)
fromPointer(e)
}
}
onPointerMove={(e) => {
if (dragging.current) fromPointer(e)
}}
onPointerUp={() => {
dragging.current = false
}}
onPointerCancel={() => {
dragging.current = false
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") set(x.current - 4)
else if (e.key === "ArrowRight") set(x.current + 4)
else if (e.key === "Home") set(0)
else if (e.key === "End") set(100)
else return
e.preventDefault()
}}
>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
{picture(before, beforeLabel, 0)}
{picture(after, afterLabel, 1)}
<div className="lyr-cmp-line" />
<div className="lyr-cmp-knob" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2">
<path d="M6 4L2 8l4 4M10 4l4 4-4 4" />
</svg>
</div>
<span className="lyr-cmp-lab lyr-cmp-l">{beforeLabel}</span>
<span className="lyr-cmp-lab lyr-cmp-r">{afterLabel}</span>
</div>
)
}
addPropertyControls(Compare, {
before: { type: ControlType.ResponsiveImage, title: "First photo" },
after: { type: ControlType.ResponsiveImage, title: "Second photo" },
beforeLabel: { type: ControlType.String, title: "First label", defaultValue: "Before" },
afterLabel: { type: ControlType.String, title: "Second label", defaultValue: "After" },
start: {
type: ControlType.Number,
title: "Starts at",
defaultValue: 50,
min: 0,
max: 100,
step: 1,
unit: "%",
description: "Where the line sits before anyone drags it.",
},
imageWidth: {
type: ControlType.String,
title: "Image width",
defaultValue: "92vw",
description: "How wide the photos are on screen, for example 92vw. Used to load the right image size.",
},
fontFamily: {
type: ControlType.String,
title: "Font family",
defaultValue: "Inter",
description: "Use the family name of your text styles.",
},
onPhoto: { type: ControlType.Color, title: "On photo", defaultValue: "#F4F2EC" },
cornerRadius: { type: ControlType.Number, title: "Radius", defaultValue: 2, min: 0, max: 40, step: 1, unit: "px" },
})
In this preview
The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.