Layr Components

Components / Images

Opening Photo

A hairline between the two lines of a closing heading opens into a wide photo as you scroll to the end of the page.

Scroll insideFull screen
Props
330 px
50%
32 px
20 px
#E6E5E1

Set in code: Photo. The preview fills these in with demo content.

Scroll to the end of the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Opening Photo shows up in the Insert menu, and its controls appear in the right panel.
// Opening Photo · Layr Components
//
// A hairline that opens into a wide photo as you scroll to the end of the page.
// Set between the two lines of a closing heading, the line reaches the full width
// early on, then the photo grows to its full height and settles into place. The
// opening is timed to finish by the time the page ends.
// Paste into a new code file in Framer. Place it in the page flow at full width
// with its height set to Fit, near the end of a page; it grows from a 1px line to
// its Height as you scroll. Without a photo it opens onto a plain tone.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"

type Img = { src: string; srcSet?: string; alt?: string }

interface OpeningPhotoProps {
    image?: Img
    altText: string
    openHeight: number
    focus: number
    inset: number
    radius: number
    line: string
    sizesHint: string
    style?: CSSProperties
}

// Shown when no photo is set.
const EMPTY = "linear-gradient(170deg, #b9b6ad, #6f6d66)"

const SHEET = `
.lyr-op{position:relative;width:100%;height:var(--op-h)}
.lyr-op-win{position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;background:var(--op-line);border-radius:var(--op-r)}
.lyr-op-win img,.lyr-op-win .lyr-op-e{position:absolute;left:0;top:50%;width:100%;max-width:none;height:calc(var(--op-h) * 1.12);object-fit:cover;display:block;transform:translate3d(0,-50%,0);will-change:transform,opacity;user-select:none;-webkit-user-drag:none}
`

const useSafeLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect

/**
 * @framerIntrinsicWidth 1168
 * @framerIntrinsicHeight 330
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function OpeningPhoto(props: OpeningPhotoProps) {
    // Framer passes its own width and height to every component, so this one is named openHeight.
    const { image, altText = "", openHeight = 330, focus = 50, inset = 32, radius = 20, line = "rgba(17,20,24,0.13)", sizesHint = "100vw" } = props
    const height = Math.max(1, Number(openHeight) || 330)
    const isStatic = useIsStaticRenderer()
    const rootRef = useRef<HTMLDivElement>(null)
    const winRef = useRef<HTMLDivElement>(null)
    const imgRef = useRef<HTMLElement | null>(null)
    const [reduce, setReduce] = useState(false)

    useEffect(() => {
        if (typeof window === "undefined" || !window.matchMedia) return
        setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches)
    }, [])

    const still = isStatic || reduce
    const hasPhoto = Boolean(image?.src)

    useSafeLayoutEffect(() => {
        const root = rootRef.current
        const win = winRef.current
        const img = imgRef.current
        if (!root || !win || typeof window === "undefined") return
        const set = (p: number) => {
            const h = 1 + (height - 1) * p
            root.style.height = `${h.toFixed(1)}px`
            // The line starts in from the sides and reaches the full width early in the opening.
            const side = inset * (1 - Math.min(1, p * 3))
            const r = Math.min(radius, h / 2)
            win.style.clipPath = `inset(0px ${side.toFixed(1)}px round ${r.toFixed(1)}px)`
            win.style.borderRadius = "0px"
            if (img) {
                img.style.opacity = `${Math.min(1, p * 6).toFixed(3)}`
                img.style.transform = `translate3d(0,calc(-50% + ${((1 - p) * 14).toFixed(1)}px),0)`
            }
        }
        if (still) {
            root.style.height = ""
            win.style.clipPath = ""
            win.style.borderRadius = ""
            if (img) {
                img.style.opacity = ""
                img.style.transform = ""
            }
            return
        }
        let raf = 0
        const paint = () => {
            raf = 0
            const vh = window.innerHeight
            const box = root.getBoundingClientRect()
            if (!box.width) return
            const doc = document.documentElement
            // Where the top of the photo would sit with the page scrolled to its very end, once open.
            // On a tall window the page can end before the usual point, so the opening has to finish by then.
            const below = doc.scrollHeight - (window.scrollY + box.top) + (height - box.height)
            const reach = vh - below
            const openAt = Math.max(vh * 0.4, reach + 24)
            const startAt = Math.max(vh * 0.94, reach + height + 48, openAt + 120)
            let p = (startAt - box.top) / (startAt - openAt)
            if (doc.scrollHeight + (height - box.height) - vh < 32) p = 1
            set(Math.max(0, Math.min(1, p)))
        }
        const onScroll = () => {
            if (!raf) raf = window.requestAnimationFrame(paint)
        }
        paint()
        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("resize", onScroll)
        return () => {
            window.removeEventListener("scroll", onScroll)
            window.removeEventListener("resize", onScroll)
            if (raf) window.cancelAnimationFrame(raf)
        }
    }, [still, height, inset, radius, hasPhoto])

    const vars = {
        "--op-h": `${height}px`,
        "--op-r": `${radius}px`,
        "--op-line": line,
    } as CSSProperties

    return (
        <div ref={rootRef} className="lyr-op" style={{ ...props.style, ...vars }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div className="lyr-op-win" ref={winRef}>
                {image?.src ? (
                    <img
                        ref={(el) => {
                            imgRef.current = el
                        }}
                        srcSet={image.srcSet}
                        sizes={sizesHint || "100vw"}
                        src={image.src}
                        alt={image.alt || altText || ""}
                        loading="lazy"
                        decoding="async"
                        draggable={false}
                        style={{ objectPosition: `50% ${focus}%` }}
                    />
                ) : (
                    <div
                        ref={(el) => {
                            imgRef.current = el
                        }}
                        className="lyr-op-e"
                        role="img"
                        aria-label={altText || undefined}
                        style={{ background: EMPTY }}
                    />
                )}
            </div>
        </div>
    )
}

addPropertyControls(OpeningPhoto, {
    image: {
        type: ControlType.ResponsiveImage,
        title: "Photo",
        description: "The photo that opens as the page scrolls. Any wide photo works.",
    },
    altText: {
        type: ControlType.String,
        title: "Alt text",
        defaultValue: "",
        description: "A few words that describe the photo, for screen readers and search engines.",
    },
    openHeight: {
        type: ControlType.Number,
        title: "Height",
        defaultValue: 330,
        min: 120,
        max: 720,
        step: 10,
        unit: "px",
        displayStepper: true,
        description: "How tall the photo is once it has opened.",
    },
    focus: {
        type: ControlType.Number,
        title: "Focus",
        defaultValue: 50,
        min: 0,
        max: 100,
        step: 1,
        unit: "%",
        description: "Which part of the photo shows: 0 is the top, 100 is the bottom.",
    },
    inset: {
        type: ControlType.Number,
        title: "Line inset",
        defaultValue: 32,
        min: 0,
        max: 200,
        step: 1,
        unit: "px",
        description: "How far the closed line sits in from each side, so it lines up with the text.",
    },
    radius: { type: ControlType.Number, title: "Corner radius", defaultValue: 20, min: 0, max: 60, step: 1, unit: "px" },
    line: { type: ControlType.Color, title: "Line", defaultValue: "rgba(17,20,24,0.13)" },
    sizesHint: {
        type: ControlType.String,
        title: "Image width",
        defaultValue: "100vw",
        description: "The width the photo is shown at, so the right file size loads. 100vw is the full window.",
    },
})

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.