Layr Components

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
50%
#F4F2EC
2 px

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.