Layr Components

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
23%
32 px
96 px
90 vh
8 px
#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.