Components / Scroll scene
Iris Image
A picture seen through a round window between two large words; as you scroll the circle opens to fill the screen and a heading fades in.
Scroll insideFull screen
Props
#FFFFFF
#0E0E0D
#FFF6EE
Set in code: Image. The preview fills these in with demo content.
Scroll inside the preview.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Iris Image shows up in the Insert menu, and its controls appear in the right panel.
// Iris Image · Layr Components
//
// A picture seen through a round window between two large words. As the page
// scrolls the circle opens until the picture fills the screen, and a heading
// fades in over it. The component is taller than the screen and pins itself
// while it opens.
// Paste into a new code file in Framer. Without a picture it shows a plain tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useMotionValueEvent, useReducedMotion, useScroll } from "framer-motion"
import { useCallback, useEffect, useRef, type CSSProperties } from "react"
interface IrisImageProps {
image?: { src: string; srcSet?: string; alt?: string }
wordLeft: string
wordRight: string
heading: string
caption: string
startSize: number
scrollLength: number
wordSize: number
headingSize: number
backgroundColor: string
wordColor: string
overlayColor: string
fontFamily: string
displayFamily: string
displayWeight: number
style?: CSSProperties
}
const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v))
const ease = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2)
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 800
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function IrisImage(props: IrisImageProps) {
const {
image,
wordLeft = "The",
wordRight = "view",
heading = "A window that opens as you scroll.",
caption = "Keep going: the circle widens until the picture fills the screen.",
startSize = 46,
scrollLength = 130,
wordSize = 9,
headingSize = 5,
backgroundColor = "#F2EDE3",
wordColor = "#1B1A17",
overlayColor = "#FFF6EE",
fontFamily = "Inter",
displayFamily = "",
displayWeight = 400,
} = props
const isStatic = useIsStaticRenderer()
const reduced = useReducedMotion()
const root = useRef<HTMLDivElement>(null)
const stage = useRef<HTMLDivElement>(null)
const win = useRef<HTMLDivElement>(null)
const img = useRef<HTMLImageElement>(null)
const left = useRef<HTMLSpanElement>(null)
const right = useRef<HTMLSpanElement>(null)
const over = useRef<HTMLDivElement>(null)
const shade = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({ target: root, offset: ["start start", "end end"] })
const paint = useCallback(
(raw: number) => {
const s = stage.current
if (!s || !win.current) return
const w = s.clientWidth
const h = s.clientHeight
const t = isStatic ? 0.3 : reduced ? 1 : clamp(raw / 0.82)
const r0 = (Math.min(w, h) * startSize) / 200
const r1 = Math.hypot(w, h) / 2 + 4
const r = r0 + (r1 - r0) * ease(t)
win.current.style.clipPath = `circle(${r.toFixed(1)}px at 50% 50%)`
if (img.current) img.current.style.transform = `scale(${(1.16 - 0.16 * ease(t)).toFixed(4)})`
const gap = r + Math.max(18, w * 0.02)
const fade = clamp(1 - (r - r0) / Math.max(1, w * 0.22))
if (left.current) {
left.current.style.transform = `translate(${(-gap).toFixed(1)}px, -50%) translateX(-100%)`
left.current.style.opacity = fade.toFixed(3)
}
if (right.current) {
right.current.style.transform = `translate(${gap.toFixed(1)}px, -50%)`
right.current.style.opacity = fade.toFixed(3)
}
const show = isStatic ? 0 : clamp((t - 0.72) / 0.28)
if (over.current) {
over.current.style.opacity = show.toFixed(3)
over.current.style.transform = `translateY(${((1 - show) * 28).toFixed(1)}px)`
}
if (shade.current) shade.current.style.opacity = (0.25 + 0.75 * show).toFixed(3)
},
[isStatic, reduced, startSize]
)
useMotionValueEvent(scrollYProgress, "change", paint)
useEffect(() => {
paint(scrollYProgress.get())
const s = stage.current
if (!s || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(() => paint(scrollYProgress.get()))
ro.observe(s)
return () => ro.disconnect()
}, [paint, scrollYProgress])
const family = `"${fontFamily}", "Inter", system-ui, sans-serif`
const display = displayFamily ? `"${displayFamily}", ${family}` : family
const word: CSSProperties = { position: "absolute", left: "50%", top: "50%", fontFamily: display, fontSize: `clamp(34px, ${wordSize}vw, 150px)`, fontWeight: displayWeight, letterSpacing: "-0.04em", lineHeight: 1, color: wordColor, whiteSpace: "nowrap", willChange: "transform, opacity" }
return (
<div ref={root} style={{ position: "relative", width: "100%", height: isStatic ? 800 : `calc(100svh + ${scrollLength}vh)`, background: backgroundColor, fontFamily: family }}>
<div ref={stage} style={{ position: isStatic ? "relative" : "sticky", top: 0, width: "100%", height: isStatic ? 800 : "100svh", overflow: "hidden" }}>
<span ref={left} style={word}>
{wordLeft}
</span>
<span ref={right} style={word}>
{wordRight}
</span>
<div ref={win} style={{ position: "absolute", inset: 0, willChange: "clip-path" }}>
{image?.src ? (
<img ref={img} src={image.src} srcSet={image.srcSet} sizes="100vw" alt={image.alt || ""} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block", willChange: "transform" }} />
) : (
<div style={{ position: "absolute", inset: 0, background: "linear-gradient(170deg, #b9b2a5, #6e675d)" }} />
)}
<div ref={shade} style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(20,14,8,0.10) 30%, rgba(20,14,8,0.62) 100%)" }} />
</div>
<div ref={over} style={{ position: "absolute", left: 0, right: 0, bottom: 0, padding: "0 clamp(20px, 3.4vw, 48px) clamp(28px, 5vw, 64px)", color: overlayColor, display: "flex", flexWrap: "wrap", alignItems: "flex-end", justifyContent: "space-between", gap: 24, opacity: 0 }}>
<h2 style={{ margin: 0, fontFamily: display, fontSize: `clamp(30px, ${headingSize}vw, 76px)`, fontWeight: displayWeight, letterSpacing: "-0.035em", lineHeight: 1.02, maxWidth: "12em", textWrap: "balance" } as CSSProperties}>{heading}</h2>
<p style={{ margin: 0, fontSize: "clamp(16px, 1.4vw, 20px)", lineHeight: 1.45, maxWidth: "22em", opacity: 0.86 }}>{caption}</p>
</div>
</div>
</div>
)
}
addPropertyControls(IrisImage, {
image: { type: ControlType.ResponsiveImage, title: "Image" },
wordLeft: { type: ControlType.String, title: "Word left", defaultValue: "The" },
wordRight: { type: ControlType.String, title: "Word right", defaultValue: "view" },
heading: { type: ControlType.String, title: "Heading", defaultValue: "A window that opens as you scroll." },
caption: { type: ControlType.String, title: "Caption", defaultValue: "Keep going: the circle widens until the picture fills the screen.", displayTextArea: true },
startSize: { type: ControlType.Number, title: "Start size", defaultValue: 46, min: 10, max: 90, unit: "%", step: 1, description: "Width of the round window before it opens, against the shorter side of the screen." },
scrollLength: { type: ControlType.Number, title: "Scroll length", defaultValue: 130, min: 40, max: 300, unit: "vh", step: 10 },
wordSize: { type: ControlType.Number, title: "Word size", defaultValue: 9, min: 3, max: 16, unit: "vw", step: 0.5 },
headingSize: { type: ControlType.Number, title: "Heading size", defaultValue: 5, min: 2, max: 10, unit: "vw", step: 0.5 },
backgroundColor: { type: ControlType.Color, title: "Background", defaultValue: "#F2EDE3" },
wordColor: { type: ControlType.Color, title: "Words", defaultValue: "#1B1A17" },
overlayColor: { type: ControlType.Color, title: "Text on photo", defaultValue: "#FFF6EE" },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
displayFamily: { type: ControlType.String, title: "Display font", defaultValue: "", placeholder: "Same as font family", description: "Family for the large type. Leave empty to use the font family." },
displayWeight: { type: ControlType.Number, title: "Display weight", defaultValue: 400, min: 300, max: 800, step: 100 },
})
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.