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
#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.