Layr Components

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
46%
130 vh
9 vw
5 vw
#FFFFFF
#0E0E0D
#FFF6EE
400

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.