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