Layr Components

Components / Images

Look Compare

A before and after slider with tabs of looks above it; each look shows its own graded photo or imitates the grade with CSS filters.

Props
50%
20 px
1
#0E0E0D
#FFFFFF
#E6E5E1
#FF4A1C

Set in code: Before, Looks. The preview fills these in with demo content.

Pick a look, then drag the divider or use the arrow keys.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Look Compare shows up in the Insert menu, and its controls appear in the right panel.
// Look Compare · Layr Components
//
// A before and after slider for one photo, with a row of looks above it. Pick a
// look and drag the divider, or use the arrow keys, to compare it with the plain
// photo. Each look can use its own graded photo; without one, the look is imitated
// on the before photo with CSS filters, so it works with a single picture. It
// nudges the divider once when it first comes into view.
// Paste into a new code file in Framer and give it a fixed size: the tabs sit on
// top and the photo fills the rest. Without a photo it compares plain tones.

import { addPropertyControls, ControlType, RenderTarget } from "framer"
import { animate, useInView, useMotionValue, useReducedMotion } from "framer-motion"
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"

type Img = { src: string; srcSet?: string; alt?: string }
type Look = { name?: string; image?: Img; style?: string }

interface LookCompareProps {
    font: string
    image?: Img
    flatten: boolean
    single: boolean
    lookName: string
    lookStyle: string
    looks: Look[]
    chips: boolean
    beforeLabel: string
    start: number
    hint: boolean
    radius: number
    scale: number
    ink: string
    surface: string
    line: string
    accent: string
    style?: CSSProperties
}

const FONT = 'var(--lc-font, "Inter"), "Inter", system-ui, -apple-system, sans-serif'
const fontVar = (f?: string) => (!f ? undefined : /[,"']/.test(f) ? f : JSON.stringify(f))

type Recipe = { filter: string; tint?: string; blend?: CSSProperties["mixBlendMode"] }

const RECIPES: Record<string, Recipe> = {
    warm: {
        filter: "contrast(1.08) saturate(1.2) sepia(0.2) brightness(1.03)",
        tint: "linear-gradient(180deg, rgba(255,170,90,.16), rgba(120,60,20,.12))",
        blend: "soft-light",
    },
    night: {
        filter: "contrast(1.16) saturate(0.92) hue-rotate(-14deg) brightness(0.93)",
        tint: "linear-gradient(180deg, rgba(0,120,140,.34), rgba(0,40,70,.4))",
        blend: "soft-light",
    },
    mono: {
        filter: "grayscale(1) contrast(1.24) brightness(1.02)",
    },
    fade: {
        filter: "contrast(0.9) saturate(0.8) brightness(1.1)",
        tint: "linear-gradient(180deg, rgba(255,232,214,.2), rgba(190,214,226,.16))",
        blend: "screen",
    },
    none: { filter: "none" },
}

// How the before photo is washed out when "Before is" is Flattened.
const FLAT = "contrast(0.78) saturate(0.55) brightness(1.1)"

// Stands in for the photo until one is set; the looks still change it.
const EMPTY = "linear-gradient(160deg, #c9b79c 0%, #8d7f6b 46%, #3d4a50 100%)"

function recipeFor(style?: string): Recipe {
    const s = String(style || "").toLowerCase()
    if (s.includes("night") || s.includes("cool")) return RECIPES.night
    if (s.includes("mono") || s.includes("black")) return RECIPES.mono
    if (s.includes("fade") || s.includes("pastel")) return RECIPES.fade
    if (s.includes("none") || s.includes("photo")) return RECIPES.none
    return RECIPES.warm
}

const DEFAULT_LOOKS: Look[] = [
    { name: "Warm film", style: "Warm film" },
    { name: "Night", style: "Night teal" },
    { name: "Mono", style: "Mono" },
    { name: "Soft fade", style: "Fade" },
]

const fill: CSSProperties = { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block", userSelect: "none" }

/**
 * @framerIntrinsicWidth 1100
 * @framerIntrinsicHeight 680
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 */
export default function LookCompare(props: LookCompareProps) {
    const {
        font = "Inter",
        image,
        flatten = true,
        single = false,
        lookName = "After",
        lookStyle = "Warm film",
        looks = DEFAULT_LOOKS,
        chips = true,
        beforeLabel = "Straight from camera",
        start = 50,
        hint = true,
        radius = 20,
        scale: s = 1,
        ink = "#F2F1EC",
        surface = "#141413",
        line = "rgba(242,241,236,.22)",
        accent = "#FF4B1F",
        style,
    } = props

    const px = (n: number) => Math.round(n * s)
    const isCanvas = RenderTarget.current() === RenderTarget.canvas
    const reduced = useReducedMotion()
    const list: Look[] = single ? [{ name: lookName || "After", style: lookStyle || "Warm film" }] : looks && looks.length ? looks : DEFAULT_LOOKS
    const [active, setActive] = useState(0)
    const [pos, setPos] = useState(start)
    const [dragging, setDragging] = useState(false)
    const stage = useRef<HTMLDivElement>(null)
    const mv = useMotionValue(start)
    const inView = useInView(stage, { once: true, amount: 0.5 })
    const touched = useRef(false)

    useEffect(() => mv.on("change", (v) => setPos(v)), [mv])

    useEffect(() => {
        mv.set(start)
    }, [start, mv])

    // A small nudge the first time it scrolls into view, so it is clear
    // the divider can be dragged.
    useEffect(() => {
        if (!inView || !hint || isCanvas || reduced || touched.current) return
        const controls = animate(mv, [start, start + 14, start - 12, start], {
            duration: 1.9,
            ease: [0.45, 0, 0.2, 1],
            delay: 0.25,
        })
        return () => controls.stop()
    }, [inView, hint, isCanvas, reduced, start, mv])

    const setFromEvent = (clientX: number) => {
        const el = stage.current
        if (!el) return
        const rect = el.getBoundingClientRect()
        const v = ((clientX - rect.left) / rect.width) * 100
        mv.stop()
        mv.set(Math.min(100, Math.max(0, v)))
    }

    const sizes ="(min-width: 1200px) 1100px, 100vw"
    const picture = (own: Img | undefined, alt: string, filter: string): ReactNode =>
        own?.src ? (
            <img src={own.src} srcSet={own.srcSet} sizes={sizes} alt={alt} draggable={false} style={{ ...fill, filter }} />
        ) : (
            <div role={alt ? "img" : undefined} aria-label={alt || undefined} style={{ ...fill, background: EMPTY, filter }} />
        )

    const current = list[Math.min(active, list.length - 1)]
    const chipH = px(40)

    return (
        <div
            style={
                {
                    ...style,
                    width: "100%",
                    height: "100%",
                    display: "flex",
                    "--lc-font": fontVar(font),
                    flexDirection: "column",
                    gap: px(20),
                    fontFamily: FONT,
                } as CSSProperties
            }
        >
            {chips && !single ? (
                <div
                    role="tablist"
                    aria-label="Looks"
                    style={{
                        display: "flex",
                        gap: px(8),
                        flex: "0 0 auto",
                        overflowX: "auto",
                        scrollbarWidth: "none",
                        WebkitOverflowScrolling: "touch",
                        justifyContent: "safe center",
                        padding: "2px",
                    }}
                >
                    {list.map((look, i) => {
                        const on = i === active
                        return (
                            <button
                                key={i}
                                type="button"
                                role="tab"
                                aria-selected={on}
                                onClick={() => setActive(i)}
                                style={{
                                    flex: "0 0 auto",
                                    height: chipH,
                                    padding: `0 ${px(18)}px`,
                                    borderRadius: chipH,
                                    border: `1px solid ${on ? ink : line}`,
                                    background: on ? ink : "transparent",
                                    color: on ? surface : ink,
                                    fontFamily: FONT,
                                    fontSize: px(15),
                                    fontWeight: 500,
                                    letterSpacing: "-0.01em",
                                    cursor: "pointer",
                                    whiteSpace: "nowrap",
                                    transition: "background .25s ease, color .25s ease, border-color .25s ease",
                                }}
                            >
                                {look.name}
                            </button>
                        )
                    })}
                </div>
            ) : null}

            <div
                ref={stage}
                role="slider"
                tabIndex={0}
                aria-label="Before and after"
                aria-valuemin={0}
                aria-valuemax={100}
                aria-valuenow={Math.round(pos)}
                onPointerDown={(e) => {
                    touched.current = true
                    setDragging(true)
                    e.currentTarget.setPointerCapture(e.pointerId)
                    setFromEvent(e.clientX)
                }}
                onPointerMove={(e) => {
                    if (dragging) setFromEvent(e.clientX)
                }}
                onPointerUp={(e) => {
                    setDragging(false)
                    try {
                        e.currentTarget.releasePointerCapture(e.pointerId)
                    } catch {}
                }}
                onPointerCancel={() => setDragging(false)}
                onKeyDown={(e) => {
                    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
                        e.preventDefault()
                        touched.current = true
                        mv.stop()
                        mv.set(Math.min(100, Math.max(0, mv.get() + (e.key === "ArrowLeft" ? -4 : 4))))
                    }
                }}
                style={{
                    position: "relative",
                    flex: "1 1 0",
                    minHeight: 0,
                    borderRadius: radius,
                    overflow: "hidden",
                    background: "#1c1c1a",
                    cursor: dragging ? "grabbing" : "ew-resize",
                    touchAction: "pan-y",
                    outline: "none",
                    userSelect: "none",
                    WebkitUserSelect: "none",
                }}
            >
                {/* After: one layer per look, cross-faded */}
                {list.map((look, i) => {
                    const own = look.image?.src ? look.image : undefined
                    const recipe = own ? RECIPES.none : recipeFor(look.style)
                    return (
                        <div
                            key={i}
                            aria-hidden={i !== active}
                            style={{
                                position: "absolute",
                                inset: 0,
                                opacity: i === active ? 1 : 0,
                                transition: "opacity .55s ease",
                            }}
                        >
                            {picture(own || image, i === active ? `${look.name} look` : "", recipe.filter)}
                            {recipe.tint ? <div style={{ position: "absolute", inset: 0, background: recipe.tint, mixBlendMode: recipe.blend }} /> : null}
                        </div>
                    )
                })}

                {/* Before */}
                <div
                    style={{
                        position: "absolute",
                        inset: 0,
                        clipPath: `inset(0 ${100 - pos}% 0 0)`,
                    }}
                >
                    {picture(image, image?.alt || "Ungraded photo", flatten ? FLAT : "none")}
                </div>

                {/* Labels */}
                <Tag left px={px} hide={pos < 14}>
                    {beforeLabel}
                </Tag>
                <Tag px={px} hide={pos > 86}>
                    {current?.name}
                </Tag>

                {/* Divider */}
                <div
                    style={{
                        position: "absolute",
                        top: 0,
                        bottom: 0,
                        left: `${pos}%`,
                        width: 2,
                        marginLeft: -1,
                        background: "rgba(255,255,255,.92)",
                        boxShadow: "0 0 0 1px rgba(0,0,0,.08)",
                        pointerEvents: "none",
                    }}
                >
                    <div
                        style={{
                            position: "absolute",
                            top: "50%",
                            left: "50%",
                            width: px(52),
                            height: px(52),
                            marginLeft: -px(26),
                            marginTop: -px(26),
                            borderRadius: "50%",
                            background: "#fff",
                            color: "#141413",
                            display: "flex",
                            alignItems: "center",
                            justifyContent: "center",
                            boxShadow: "0 10px 30px -8px rgba(0,0,0,.45), 0 0 0 1px rgba(0,0,0,.06)",
                            transform: dragging ? "scale(.92)" : "scale(1)",
                            transition: "transform .25s cubic-bezier(.2,.7,.2,1)",
                        }}
                    >
                        <svg width={px(22)} height={px(22)} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                            <path d="M9 6l-6 6 6 6" />
                            <path d="M15 6l6 6-6 6" />
                        </svg>
                        <span
                            style={{
                                position: "absolute",
                                inset: -px(5),
                                borderRadius: "50%",
                                border: `2px solid ${accent}`,
                                opacity: dragging ? 1 : 0,
                                transition: "opacity .2s ease",
                            }}
                        />
                    </div>
                </div>
            </div>
        </div>
    )
}

function Tag({ children, left, px, hide }: { children: ReactNode; left?: boolean; px: (n: number) => number; hide?: boolean }) {
    return (
        <div
            style={{
                position: "absolute",
                bottom: px(18),
                [left ? "left" : "right"]: px(18),
                height: px(32),
                padding: `0 ${px(13)}px`,
                borderRadius: px(32),
                display: "flex",
                alignItems: "center",
                background: "rgba(20,20,19,.5)",
                backdropFilter: "blur(12px)",
                WebkitBackdropFilter: "blur(12px)",
                color: "#fff",
                fontSize: px(13),
                fontWeight: 500,
                letterSpacing: "-0.005em",
                pointerEvents: "none",
                opacity: hide ? 0 : 1,
                transition: "opacity .3s ease",
                whiteSpace: "nowrap",
            }}
        >
            {children}
        </div>
    )
}

addPropertyControls(LookCompare, {
    font: {
        type: ControlType.String,
        title: "Font",
        defaultValue: "Inter",
        description: "The font family name used by your text styles.",
    },
    image: { type: ControlType.ResponsiveImage, title: "Before" },
    flatten: {
        type: ControlType.Boolean,
        title: "Before is",
        enabledTitle: "Flattened",
        disabledTitle: "As is",
        defaultValue: true,
        description: "Flattened washes out the before photo, for when you only have a finished shot.",
    },
    single: {
        type: ControlType.Boolean,
        title: "Mode",
        enabledTitle: "One look",
        disabledTitle: "Tabs",
        defaultValue: false,
        description: "One look is meant for product pages: bind the two fields below to the CMS.",
    },
    lookName: {
        type: ControlType.String,
        title: "Look name",
        defaultValue: "After",
        hidden: (p: Partial<LookCompareProps>) => !p.single,
    },
    lookStyle: {
        type: ControlType.String,
        title: "Look grade",
        defaultValue: "Warm film",
        description: "Warm film, Night teal, Mono or Fade.",
        hidden: (p: Partial<LookCompareProps>) => !p.single,
    },
    looks: {
        type: ControlType.Array,
        title: "Looks",
        maxCount: 8,
        hidden: (p: Partial<LookCompareProps>) => !!p.single,
        defaultValue: DEFAULT_LOOKS,
        control: {
            type: ControlType.Object,
            controls: {
                name: {
                    type: ControlType.String,
                    title: "Name",
                    defaultValue: "Look",
                },
                image: {
                    type: ControlType.ResponsiveImage,
                    title: "After",
                    description: "Optional. Your real graded photo.",
                },
                style: {
                    type: ControlType.Enum,
                    title: "Simulate",
                    options: ["Warm film", "Night teal", "Mono", "Fade", "None"],
                    optionTitles: ["Warm film", "Night teal", "Mono", "Fade", "None"],
                    defaultValue: "Warm film",
                    description: "Used when no After photo is set.",
                },
            },
        },
    },
    chips: {
        type: ControlType.Boolean,
        title: "Look tabs",
        defaultValue: true,
    },
    beforeLabel: {
        type: ControlType.String,
        title: "Before label",
        defaultValue: "Straight from camera",
    },
    start: {
        type: ControlType.Number,
        title: "Starts at",
        min: 5,
        max: 95,
        step: 1,
        unit: "%",
        defaultValue: 50,
    },
    hint: {
        type: ControlType.Boolean,
        title: "Nudge",
        enabledTitle: "On view",
        disabledTitle: "Off",
        defaultValue: true,
    },
    radius: {
        type: ControlType.Number,
        title: "Radius",
        min: 0,
        max: 48,
        step: 1,
        unit: "px",
        defaultValue: 20,
    },
    scale: {
        type: ControlType.Number,
        title: "Scale",
        min: 0.6,
        max: 1.4,
        step: 0.05,
        defaultValue: 1,
        description: "Sizes the tabs, labels and handle together.",
    },
    ink: { type: ControlType.Color, title: "Ink", defaultValue: "#F2F1EC" },
    surface: { type: ControlType.Color, title: "On ink", defaultValue: "#141413" },
    line: { type: ControlType.Color, title: "Line", defaultValue: "rgba(242,241,236,.22)" },
    accent: { type: ControlType.Color, title: "Accent", defaultValue: "#FF4B1F" },
})

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.