Components / Scroll scene
Picture in Picture
Pinned scenes: the next one waits as a small inset in the corner and grows to fill the window as you scroll, then its text appears.
Scroll insideFull screen
Props
#FFFFFF
#B8B3A9
#F5F4F1
Set in code: Scenes. 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. Picture in Picture shows up in the Insert menu, and its controls appear in the right panel.
// Picture in Picture · Layr Components
//
// Pinned scenes, one per item. The first fills the window with its text over
// the picture; the next waits as a small inset in the corner, labelled Up
// next, and grows to take the whole window as you scroll, then its text
// appears. After the last scene the page moves on.
// Paste into a new code file in Framer. Place it in the page flow at full width; it is taller than the window and pins itself while the scenes play. Without a picture a scene shows a plain tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"
type Picture = { src?: string; srcSet?: string; alt?: string }
type Scene = { image?: Picture; title?: string; summary?: string; detail?: string; note?: string; link?: string }
interface PictureInPictureProps {
items: Scene[]
label: string
linkLabel: string
nextLabel: string
corner: string
phoneCorner: string
insetSize: number
edge: number
topGap: number
step: number
radius: number
imageWidth: string
fontFamily: string
textColour: string
softColour: string
groundColour: string
style?: CSSProperties
}
const SCENES: Scene[] = [
{ title: "Start small", summary: "One short session a day for a week, to find a pace you can keep.", detail: "Beginner", note: "1 week" },
{ title: "Build the habit", summary: "Four weeks that add a little each time, so the routine holds on busy days.", detail: "Intermediate", note: "4 weeks" },
{ title: "Go further", summary: "Longer sessions for when the basics feel easy and you want more range.", detail: "Advanced", note: "6 weeks" },
]
// Shown when a scene has no picture yet.
const EMPTY = ["linear-gradient(160deg, #5d5a54, #26241f)", "linear-gradient(160deg, #4f5659, #1f2325)", "linear-gradient(160deg, #6a5f55, #2a2520)"]
const SHEET = `
.lyr-pip{position:relative;width:100%;isolation:isolate}
.lyr-pip-track{position:relative;height:100%;pointer-events:none}
.lyr-pip-pin{position:sticky;top:0;height:100vh;height:100dvh;overflow:hidden}
.lyr-pip-scene{position:absolute;inset:0}
.lyr-pip-layer{position:absolute;inset:0;overflow:hidden;background:var(--lyr-pip-ground);transform-origin:100% 100%;will-change:transform;transition:opacity .35s ease}
.lyr-pip-top .lyr-pip-layer{transform-origin:100% 0}
.lyr-pip-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.lyr-pip-shade{position:absolute;inset:0;background:linear-gradient(0deg,rgba(13,12,11,.86) 0%,rgba(13,12,11,.42) 34%,rgba(13,12,11,0) 62%),linear-gradient(180deg,rgba(13,12,11,.5) 0%,rgba(13,12,11,0) 22%)}
.lyr-pip-text{position:absolute;left:var(--lyr-pip-edge);right:var(--lyr-pip-edge);bottom:calc(var(--lyr-pip-edge) + 24px);max-width:640px;color:var(--lyr-pip-text);opacity:0;transition:opacity .45s ease .12s}
.lyr-pip-on .lyr-pip-text{opacity:1;pointer-events:auto}
.lyr-pip-label{font-size:15px;line-height:1.3;letter-spacing:-0.01em;margin:0 0 18px}
.lyr-pip-title{font-size:56px;line-height:1.04;letter-spacing:-0.04em;font-weight:400;margin:0;text-wrap:balance}
.lyr-pip-sum{font-size:17px;line-height:1.5;letter-spacing:-0.012em;margin:20px 0 0;max-width:460px;color:var(--lyr-pip-soft);text-wrap:pretty}
.lyr-pip-row{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 22px;margin-top:26px;font-size:15px;line-height:1.3;letter-spacing:-0.01em}
.lyr-pip-row span:nth-child(2){color:var(--lyr-pip-soft)}
.lyr-pip-link{color:inherit;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:5px;font-weight:500;transition:opacity .2s}
.lyr-pip-link:hover{opacity:.62}
.lyr-pip-next{position:absolute;right:var(--lyr-pip-edge);font-size:15px;line-height:1.3;letter-spacing:-0.01em;color:var(--lyr-pip-text);display:flex;justify-content:space-between;gap:12px;opacity:0;transition:opacity .35s ease}
.lyr-pip-next span:last-child{color:var(--lyr-pip-soft);text-align:right}
.lyr-pip-wait .lyr-pip-next{opacity:1}
.lyr-pip-static .lyr-pip-pin{position:relative;height:640px}
@media (max-width:809.98px){.lyr-pip-next{flex-direction:column;gap:2px;font-size:14px}.lyr-pip-next span:last-child{text-align:left}.lyr-pip-title{font-size:34px}.lyr-pip-sum{font-size:16px;margin-top:14px}.lyr-pip-row{margin-top:18px}.lyr-pip-label{margin-bottom:12px}.lyr-pip-text{bottom:calc(var(--lyr-pip-edge) + 12px)}}
@media (min-width:810px) and (max-width:1199.98px){.lyr-pip-title{font-size:44px}}
@media (prefers-reduced-motion:reduce){.lyr-pip-text,.lyr-pip-next{transition:none}}
`
const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v))
const mix = (a: number, b: number, t: number) => a + (b - a) * t
const ease = (t: number) => t * t * (3 - 2 * t)
type Inset = { s0: number; edge: number; topGap: number; top: boolean; radius: number }
// Where the inset sits before it grows (t = 0) and once it fills the window (t = 1).
function pose(t: number, k: Inset) {
const s = mix(k.s0, 1, t)
return { tx: -k.edge * (1 - t), ty: (k.top ? k.topGap : -k.edge) * (1 - t), s, r: ((1 - t) * k.radius) / s }
}
function css(t: number, k: Inset) {
const p = pose(t, k)
return { transform: `translate3d(${p.tx.toFixed(2)}px,${p.ty.toFixed(2)}px,0) scale(${p.s.toFixed(5)})`, borderRadius: `${p.r.toFixed(2)}px` }
}
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 700
*
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight auto
*/
export default function PictureInPicture(props: PictureInPictureProps) {
const {
items = SCENES,
label = "Course",
linkLabel = "See the course",
nextLabel = "Up next",
corner = "Bottom right",
phoneCorner = "Top right",
insetSize = 23,
edge = 32,
topGap = 96,
step = 90,
radius = 8,
imageWidth = "100vw",
fontFamily = "Inter",
textColour = "#FFFFFF",
softColour = "#B8B3A9",
groundColour = "#171614",
style,
} = props
const isStatic = useIsStaticRenderer()
const scenes = (items || []).filter(Boolean)
const n = Math.max(1, scenes.length)
const top = String(corner).toLowerCase().includes("top")
const phoneTop = String(phoneCorner).toLowerCase().includes("top")
const s0 = clamp(insetSize / 100, 0.1, 0.6)
const rootRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const sceneRefs = useRef<Array<HTMLDivElement | null>>([])
const layerRefs = useRef<Array<HTMLDivElement | null>>([])
const nextRefs = useRef<Array<HTMLDivElement | null>>([])
useEffect(() => {
if (isStatic) return
const st = { g: -1, raf: 0, last: 0 }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)")
const paint = (now: number) => {
st.raf = 0
const track = trackRef.current
if (!track) return
const r = track.getBoundingClientRect()
const vh = window.innerHeight
if (r.bottom < -vh || r.top > vh * 2) return
const pinH = (track.firstElementChild as HTMLElement).offsetHeight || vh
const stepPx = n > 1 ? (r.height - pinH) / (n - 1) : 1
let goal = clamp(-r.top / Math.max(1, stepPx), 0, n - 1)
if (reduce.matches) goal = Math.round(goal)
if (st.g < 0) {
st.g = goal
st.last = now
}
const dt = Math.min(64, Math.max(0, now - st.last))
st.last = now
st.g += (goal - st.g) * (reduce.matches ? 1 : 1 - Math.exp(-dt / 120))
if (Math.abs(goal - st.g) < 0.0008) st.g = goal
const g = st.g
// phones keep the text full width, so the inset may wait in another corner there
const topNow = window.innerWidth < 810 ? phoneTop : top
const k: Inset = { s0, edge, topGap, top: topNow, radius }
rootRef.current?.classList.toggle("lyr-pip-top", topNow)
for (let i = 0; i < n; i++) {
const scene = sceneRefs.current[i]
const layer = layerRefs.current[i]
if (!scene || !layer) continue
const t = i === 0 ? 1 : ease(clamp((g - (i - 1) - 0.28) / 0.72))
const shown = i === 0 || g >= i - 1 - 0.001
const full = t >= 0.995
const c = css(full ? 1 : t, k)
layer.style.transform = full ? "none" : c.transform
layer.style.borderRadius = c.borderRadius
layer.style.opacity = shown ? "1" : "0"
layer.style.visibility = shown && g < i + 1.02 ? "visible" : "hidden"
scene.classList.toggle("lyr-pip-on", full && g < i + 0.55)
scene.classList.toggle("lyr-pip-wait", shown && t < 0.04 && i > 0)
const next = nextRefs.current[i]
if (next) {
const p = pose(0, k)
next.style.width = `${layer.offsetWidth * p.s}px`
const off = `${(topNow ? topGap : edge) + layer.offsetHeight * p.s + 10}px`
next.style.top = topNow ? off : ""
next.style.bottom = topNow ? "" : off
}
}
if (g !== goal) st.raf = requestAnimationFrame(paint)
}
const kick = () => {
if (!st.raf) st.raf = requestAnimationFrame(paint)
}
window.addEventListener("scroll", kick, { passive: true })
window.addEventListener("resize", kick)
kick()
return () => {
window.removeEventListener("scroll", kick)
window.removeEventListener("resize", kick)
if (st.raf) cancelAnimationFrame(st.raf)
}
}, [isStatic, n, s0, edge, topGap, top, phoneTop, radius])
const h = `calc(100vh + ${(n - 1) * step}vh)`
const vars = {
"--lyr-pip-edge": `${edge}px`,
"--lyr-pip-ground": groundColour,
"--lyr-pip-text": textColour,
"--lyr-pip-soft": softColour,
} as CSSProperties
const offset = (top ? topGap : edge) + 10
return (
<div ref={rootRef} className={`lyr-pip${top ? " lyr-pip-top" : ""}${isStatic ? " lyr-pip-static" : ""}`} style={{ ...style, ...vars, height: isStatic ? 640 : h, fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif` }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div ref={trackRef} className="lyr-pip-track">
<div className="lyr-pip-pin">
{scenes.map((s, i) => {
const at = css(0, { s0, edge, topGap, top, radius })
const start: CSSProperties = i === 0 ? {} : i === 1 ? at : { ...at, opacity: 0 }
return (
<div
key={i}
ref={(el) => {
sceneRefs.current[i] = el
}}
className={`lyr-pip-scene${i === 0 ? " lyr-pip-on" : ""}${i === 1 ? " lyr-pip-wait" : ""}`}
style={{ zIndex: i + 1 }}
>
<div
ref={(el) => {
layerRefs.current[i] = el
}}
className="lyr-pip-layer"
style={start}
>
{s.image?.src ? (
<img className="lyr-pip-img" loading="lazy" decoding="async" srcSet={s.image.srcSet} sizes={s.image.srcSet ? imageWidth || "100vw" : undefined} src={s.image.src} alt={s.image.alt || ""} />
) : (
<div className="lyr-pip-img" style={{ background: EMPTY[i % EMPTY.length] }} />
)}
<div className="lyr-pip-shade" />
<div className="lyr-pip-text">
<p className="lyr-pip-label">{label ? `${label} ${i + 1} of ${scenes.length}` : ""}</p>
<h3 className="lyr-pip-title">{s.title}</h3>
{s.summary ? <p className="lyr-pip-sum">{s.summary}</p> : null}
<div className="lyr-pip-row">
{s.detail ? <span>{s.detail}</span> : null}
{s.note ? <span>{s.note}</span> : null}
{linkLabel && s.link ? (
<a className="lyr-pip-link" href={s.link}>
{linkLabel}
</a>
) : null}
</div>
</div>
</div>
{i > 0 ? (
<div
ref={(el) => {
nextRefs.current[i] = el
}}
className="lyr-pip-next"
style={
isStatic
? { width: `${s0 * 100}%`, [top ? "top" : "bottom"]: `${offset + 640 * s0}px` }
: { width: `${s0 * 100}vw`, [top ? "top" : "bottom"]: `calc(${offset}px + ${s0 * 100}vh)` }
}
>
<span>{nextLabel}</span>
<span>{s.title}</span>
</div>
) : null}
</div>
)
})}
</div>
</div>
</div>
)
}
addPropertyControls(PictureInPicture, {
items: {
type: ControlType.Array,
title: "Scenes",
maxCount: 8,
description: "One pinned scene per item, in order.",
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Picture" },
title: { type: ControlType.String, title: "Title", defaultValue: "" },
summary: { type: ControlType.String, title: "Summary", defaultValue: "", displayTextArea: true },
detail: { type: ControlType.String, title: "Detail", defaultValue: "", description: "A short detail, such as a price or a level." },
note: { type: ControlType.String, title: "Note", defaultValue: "" },
link: { type: ControlType.Link, title: "Link" },
},
},
defaultValue: [
{ title: "Start small", summary: "One short session a day for a week, to find a pace you can keep.", detail: "Beginner", note: "1 week" },
{ title: "Build the habit", summary: "Four weeks that add a little each time, so the routine holds on busy days.", detail: "Intermediate", note: "4 weeks" },
{ title: "Go further", summary: "Longer sessions for when the basics feel easy and you want more range.", detail: "Advanced", note: "6 weeks" },
],
},
label: { type: ControlType.String, title: "Label", defaultValue: "Course", description: "The word before the count, as in Course 1 of 3." },
linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "See the course", description: "Shown on scenes that have a link." },
nextLabel: { type: ControlType.String, title: "Inset label", defaultValue: "Up next", description: "Shown beside the title while the picture waits in the corner." },
corner: { type: ControlType.Enum, title: "Inset corner", defaultValue: "Bottom right", options: ["Bottom right", "Top right"], optionTitles: ["Bottom right", "Top right"] },
phoneCorner: { type: ControlType.Enum, title: "On phones", defaultValue: "Top right", options: ["Top right", "Bottom right"], optionTitles: ["Top right", "Bottom right"], description: "Where the next picture waits on windows under 810 wide, where the text runs the full width." },
insetSize: { type: ControlType.Number, title: "Inset size", defaultValue: 23, min: 10, max: 60, step: 1, unit: "%", description: "The waiting picture's size as a share of the window." },
edge: { type: ControlType.Number, title: "Edge gap", defaultValue: 32, min: 0, max: 80, step: 2, unit: "px" },
topGap: { type: ControlType.Number, title: "Top gap", defaultValue: 96, min: 0, max: 240, step: 4, unit: "px", description: "Room kept for the navigation when the inset sits at the top." },
step: { type: ControlType.Number, title: "Scroll per scene", defaultValue: 90, min: 40, max: 200, step: 5, unit: "vh" },
radius: { type: ControlType.Number, title: "Corner", defaultValue: 8, min: 0, max: 40, step: 1, unit: "px" },
imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "100vw", description: "One length, such as 100vw." },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#FFFFFF" },
softColour: { type: ControlType.Color, title: "Soft colour", defaultValue: "#B8B3A9" },
groundColour: { type: ControlType.Color, title: "Ground colour", defaultValue: "#171614" },
})
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.