Layr Components

Components / Scroll scene

Breakpoints

A pinned project frame under a ruler shrinks from desktop to tablet to phone width as you scroll, swapping screenshots while notes appear.

Scroll insideFull screen
Props
1440 px
810 px
390 px
80%
#0E0E0D
#6E6E6B
#A3A3A0
#E6E5E1
#F5F4F1
76 px
48 px

Set in code: Desktop screen, Tablet screen, Phone screen, Link, Facts. 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. Breakpoints shows up in the Insert menu, and its controls appear in the right panel.
// Breakpoints · Layr Components
//
// One project shown in a frame under a ruler. Scrolling drags the frame from
// desktop width to tablet to phone, the ruler's number rolls to each width,
// the screenshot swaps at each stop, and the project notes arrive in the space
// the frame leaves. On a narrow screen the frame keeps its width and changes
// height instead.
// 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 frame narrows. Without screenshots the frame 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 Fact = { label?: string; value?: string }

interface BreakpointsProps {
    desktop?: Picture
    tablet?: Picture
    phone?: Picture
    desktopWidth: number
    tabletWidth: number
    phoneWidth: number
    heading: string
    quiet: string
    title: string
    summary: string
    link?: string
    linkLabel: string
    facts: Fact[]
    scrollLength: number
    fontFamily: string
    ink: string
    mute: string
    quietTone: string
    hairline: string
    ground: string
    topSpace: number
    sideSpace: number
    style?: CSSProperties
}

const FACTS: Fact[] = [
    { label: "Role", value: "Lead engineer, interface and API" },
    { label: "Year", value: "2025" },
    { label: "Built with", value: "TypeScript, Go, SQL" },
    { label: "Result", value: "Page load at 96 ms" },
]

const CSS = `
.lyr-bp{position:relative;width:100%;color:var(--ink);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;height:calc(100vh + 2 * var(--step) * 1vh);height:calc(100svh + 2 * var(--step) * 1svh)}
.lyr-bp *{box-sizing:border-box}
.lyr-bp-pin{position:sticky;top:0;width:100%;height:100vh;height:100svh;container-type:size;isolation:isolate;overflow:clip}
.lyr-bp.lyr-bp-static{height:820px}
.lyr-bp.lyr-bp-static .lyr-bp-pin{position:relative;height:820px}
.lyr-bp-box{position:absolute;inset:0;padding:var(--top) var(--side) clamp(20px,5cqh,48px);display:flex;flex-direction:column;--gap:clamp(24px,3.9cqw,56px);--sw:calc(100cqw - 2 * var(--side))}
.lyr-bp-h{margin:clamp(10px,3.4cqh,32px) 0 0;max-width:19em;font-weight:400;font-size:clamp(24px,min(2.8cqw,5.4cqh),42px);line-height:1.08;letter-spacing:-0.035em;text-wrap:balance}
.lyr-bp-h span{color:var(--quiet)}
.lyr-bp-ruler{position:relative;height:34px;margin-top:clamp(14px,3.6cqh,34px);flex:none}
.lyr-bp-ruler::after{content:"";position:absolute;left:0;right:0;bottom:8px;height:1px;background:var(--hair)}
.lyr-bp-tick{position:absolute;bottom:4px;width:1px;height:9px;background:var(--quiet)}
.lyr-bp-read{position:absolute;left:0;bottom:17px;width:var(--sw);display:flex;justify-content:flex-end;transform-origin:left center;pointer-events:none}
.lyr-bp-read div{display:flex;font-size:15px;line-height:1;letter-spacing:-0.01em;font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1;white-space:nowrap}
.lyr-bp-col{display:block;height:1em;overflow:hidden}
.lyr-bp-col span{display:block;height:1em;will-change:transform}
.lyr-bp-col i{display:block;height:1em;font-style:normal;text-align:right}
.lyr-bp-px{color:var(--mute);margin-left:.3em}
.lyr-bp-stage{position:relative;flex:1 1 0;min-height:120px;margin-top:10px}
.lyr-bp-well{display:contents}
.lyr-bp-frame{position:absolute;left:0;top:0;bottom:0;width:var(--sw);border-radius:8px;overflow:hidden;background:var(--ground);box-shadow:0 0 0 1px var(--hair);will-change:width}
.lyr-bp-frame img,.lyr-bp-frame .lyr-bp-ph{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:cover;object-position:left top;display:block;will-change:opacity}
.lyr-bp-info{position:absolute;top:0;bottom:0;display:flex;flex-direction:column;opacity:0;transition:opacity .4s ease;pointer-events:none}
.lyr-bp-info.lyr-bp-show{opacity:1;transition-delay:.1s;pointer-events:auto}
.lyr-bp-a{right:0;width:calc(var(--sw) * (1 - var(--f1)) - var(--gap));justify-content:flex-start}
.lyr-bp-b{left:calc(var(--sw) * var(--f2) + var(--gap));width:calc(var(--sw) * (var(--f1) - var(--f2)) - var(--gap));justify-content:flex-end}
.lyr-bp-t{margin:0;font-weight:400;font-size:clamp(28px,min(3.9cqw,7cqh),56px);line-height:1.04;letter-spacing:-0.038em}
.lyr-bp-s{margin:clamp(10px,2.4cqh,20px) 0 0;max-width:26em;font-size:clamp(15px,1.25cqw,18px);line-height:1.45;letter-spacing:-0.012em;color:var(--mute)}
.lyr-bp-link{align-self:flex-start;margin-top:clamp(14px,3.4cqh,30px);color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:9px;border-bottom:1px solid currentColor;padding-bottom:4px;font-size:15px;letter-spacing:-0.01em;transition:opacity .2s}
.lyr-bp-link:hover{opacity:.62}
.lyr-bp-link svg{width:13px;height:10px;display:block}
.lyr-bp-facts{margin:0;border-top:1px solid var(--hair);font-size:15px;line-height:1.35;letter-spacing:-0.01em}
.lyr-bp-facts div{display:flex;flex-direction:column;gap:2px;padding:12px 0;border-bottom:1px solid var(--hair)}
.lyr-bp-facts dt{color:var(--mute)}
.lyr-bp-facts dd{margin:0}
.lyr-bp-stops{display:none}
@container (max-aspect-ratio: 1/1) or (max-width: 700px){
.lyr-bp-box{padding-bottom:clamp(14px,3cqh,32px);--narrow:1}
.lyr-bp-h{font-size:clamp(24px,min(6.4cqw,4.6cqh),40px);margin-top:clamp(6px,2cqh,24px)}
.lyr-bp-ruler{height:26px;margin-top:clamp(10px,2.4cqh,24px)}
.lyr-bp-ruler::after{bottom:0}
.lyr-bp-tick{display:none}
.lyr-bp-read{bottom:9px;transform:none!important;justify-content:flex-start}
.lyr-bp-stops{display:block;position:absolute;right:0;bottom:9px;font-size:15px;line-height:1;letter-spacing:-0.01em;color:var(--mute)}
.lyr-bp-stage{flex:1 1 0;min-height:140px;margin-top:12px;display:flex;flex-direction:column}
.lyr-bp-well{display:block;position:relative;flex:1 1 0;min-height:110px}
.lyr-bp-frame{width:100%!important;bottom:auto;height:var(--fh,62%);will-change:height}
.lyr-bp-info{position:static;opacity:1;pointer-events:auto;width:auto;flex:none}
.lyr-bp-a{margin-top:clamp(12px,2.4cqh,22px)}
.lyr-bp-b{display:none}
.lyr-bp-t{font-size:clamp(26px,min(7.4cqw,4.4cqh),40px)}
.lyr-bp-s{font-size:15px;margin-top:8px}
.lyr-bp-link{margin-top:14px}
}
@media (prefers-reduced-motion: reduce){.lyr-bp-info{transition:none}}
`

// Shown in the frame when no screenshot is set.
const EMPTY = "linear-gradient(160deg, #2c2925, #171512)"

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const ease = (k: number) => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2)
const lerp = (a: number, b: number, k: number) => a + (b - a) * k
const smooth = (a: number, b: number, v: number) => {
    const k = clamp((v - a) / (b - a), 0, 1)
    return k * k * (3 - 2 * k)
}

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 820
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight any
 */
export default function Breakpoints(props: BreakpointsProps) {
    const {
        desktop,
        tablet,
        phone,
        desktopWidth = 1440,
        tabletWidth = 810,
        phoneWidth = 390,
        heading = "Built to hold at any width.",
        quiet = "Keep scrolling and this one folds down to a phone.",
        title = "Shop ledger",
        summary = "A payments ledger for small shops. One codebase from the till to the back office.",
        link,
        linkLabel = "Read the case study",
        facts: factList = FACTS,
        scrollLength = 80,
        fontFamily = "Inter",
        ink = "#F0ECE4",
        mute = "#918D84",
        quietTone = "#625F58",
        hairline = "rgba(240,236,228,0.14)",
        ground = "#171512",
        topSpace = 76,
        sideSpace = 48,
    } = props
    const isStatic = useIsStaticRenderer()
    const widths = [Math.max(1, desktopWidth), Math.max(1, tabletWidth), Math.max(1, phoneWidth)]
    const f1 = clamp(widths[1] / widths[0], 0.2, 0.9)
    const f2 = clamp(widths[2] / widths[0], 0.1, 0.6)
    const f = [1, f1, f2]
    const root = useRef<HTMLDivElement>(null)
    const cols = useRef<(HTMLSpanElement | null)[]>([])
    const labels = widths.map((w) => String(Math.round(w)))
    const digits = Math.max(...labels.map((l) => l.length))
    const padded = labels.map((l) => l.padStart(digits, " "))
    const facts = (factList || []).filter((x) => x && x.label && x.value)
    const shots = [desktop, tablet || desktop, phone || tablet || desktop]

    useEffect(() => {
        if (isStatic) return
        const el = root.current
        if (!el) return
        const stops = [1, f1, f2]
        const pin = el.querySelector(".lyr-bp-pin") as HTMLElement
        const stage = el.querySelector(".lyr-bp-stage") as HTMLElement
        const frame = el.querySelector(".lyr-bp-frame") as HTMLElement
        const well = el.querySelector(".lyr-bp-well") as HTMLElement
        const read = el.querySelector(".lyr-bp-read") as HTMLElement
        const imgs = Array.from(el.querySelectorAll(".lyr-bp-frame img")) as HTMLElement[]
        const a = el.querySelector(".lyr-bp-a") as HTMLElement
        const b = el.querySelector(".lyr-bp-b") as HTMLElement
        if (!pin || !stage || !frame) return
        const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
        let shown = 0
        let goal = 0
        let raw = 0
        let raf = 0
        let last = 0
        let idle: ReturnType<typeof setTimeout> | undefined
        let painted = NaN
        let sw = 0
        let sh = 0
        let narrow = false

        const measure = () => {
            narrow = getComputedStyle(frame.closest(".lyr-bp-box") as HTMLElement).getPropertyValue("--narrow").trim() === "1"
            const box = narrow && well ? well : stage
            sw = box.offsetWidth
            sh = box.offsetHeight
            painted = NaN
        }
        const paint = (p: number) => {
            if (p === painted) return
            painted = p
            const seg = Math.min(1, Math.floor(p))
            const k = ease(clamp(p - seg, 0, 1))
            const frac = lerp(stops[seg], stops[seg + 1], k)
            if (narrow) {
                // the frame keeps its width and changes shape: a low desktop window, then tablet, then phone
                const tallest = sh
                const wide = Math.min(tallest, (sw * 900) / 1440)
                const hs = [wide, Math.min(tallest, wide + (tallest - wide) * 0.7), tallest]
                frame.style.width = ""
                frame.style.setProperty("--fh", `${lerp(hs[seg], hs[seg + 1], k).toFixed(1)}px`)
                if (read) read.style.transform = ""
            } else {
                frame.style.width = `${(sw * frac).toFixed(1)}px`
                if (read) read.style.transform = `translate3d(${(-(1 - frac) * sw).toFixed(1)}px,0,0)`
            }
            imgs.forEach((im, s) => {
                im.style.opacity = String(s === 0 ? 1 : smooth(s - 0.62, s - 0.38, p))
            })
            cols.current.forEach((c, n) => {
                if (!c) return
                const d = digits - 1 - n
                const q = Math.min(1, Math.floor(p))
                const kk = ease(clamp((p - q) * (1 + 0.12 * (digits - 1)) - 0.12 * d, 0, 1))
                c.style.transform = `translate3d(0,${(-(q + kk) * 100).toFixed(2)}%,0)`
            })
            if (a) a.classList.toggle("lyr-bp-show", p > 0.72)
            if (b) b.classList.toggle("lyr-bp-show", p > 1.72)
        }
        const where = () => {
            const r = el.getBoundingClientRect()
            const range = r.height - pin.offsetHeight
            if (r.width === 0 || range <= 0) return 0
            return clamp(-r.top / range, 0, 1) * 2
        }
        const tick = (now: number) => {
            const dt = Math.min(64, now - last || 16)
            last = now
            shown += (goal - shown) * (1 - Math.exp(-dt / 110))
            if (Math.abs(goal - shown) < 0.0015) shown = goal
            paint(shown)
            raf = shown === goal ? 0 : requestAnimationFrame(tick)
        }
        const kick = () => {
            if (!raf) {
                last = performance.now()
                raf = requestAnimationFrame(tick)
            }
        }
        const onScroll = () => {
            raw = where()
            if (reduced) {
                shown = goal = Math.round(raw)
                paint(shown)
                return
            }
            goal = raw
            kick()
            clearTimeout(idle)
            // let go between stops: settle on the nearest width
            idle = setTimeout(() => {
                goal = Math.round(raw)
                kick()
            }, 170)
        }
        const onResize = () => {
            measure()
            onScroll()
            paint(shown)
        }
        measure()
        raw = where()
        shown = goal = Math.round(raw)
        paint(shown)
        const ro = typeof ResizeObserver === "function" ? new ResizeObserver(onResize) : null
        if (ro) ro.observe(stage)
        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("resize", onResize)
        return () => {
            window.removeEventListener("scroll", onScroll)
            window.removeEventListener("resize", onResize)
            if (ro) ro.disconnect()
            cancelAnimationFrame(raf)
            clearTimeout(idle)
        }
    }, [isStatic, f1, f2, digits])

    const vars = {
        "--step": clamp(scrollLength || 80, 30, 300),
        "--f1": f1.toFixed(4),
        "--f2": f2.toFixed(4),
        "--top": `${topSpace}px`,
        "--side": `${sideSpace}px`,
        "--ink": ink,
        "--mute": mute,
        "--quiet": quietTone,
        "--hair": hairline,
        "--ground": ground,
        fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
    } as CSSProperties
    const arrow = (
        <svg viewBox="0 0 13 10" fill="none" stroke="currentColor" strokeWidth="1.2">
            <path d="M0 5h12M8 1l4 4-4 4" />
        </svg>
    )

    return (
        <div ref={root} className={`lyr-bp${isStatic ? " lyr-bp-static" : ""}`} style={vars}>
            <style dangerouslySetInnerHTML={{ __html: CSS }} />
            <div className="lyr-bp-pin">
                <div className="lyr-bp-box">
                    <h2 className="lyr-bp-h">
                        {heading} <span>{quiet}</span>
                    </h2>
                    <div className="lyr-bp-ruler" aria-hidden="true">
                        {f.map((x, n) => (
                            <span className="lyr-bp-tick" key={n} style={{ left: `calc(${(x * 100).toFixed(3)}% - ${n === 0 ? 1 : 0}px)` }} />
                        ))}
                        <div className="lyr-bp-read">
                            <div>
                                {Array.from({ length: digits }).map((_, n) => (
                                    <span className="lyr-bp-col" key={n}>
                                        <span
                                            ref={(node) => {
                                                cols.current[n] = node
                                            }}
                                        >
                                            {padded.map((l, s) => (
                                                <i key={s}>{l[n] === " " ? " " : l[n]}</i>
                                            ))}
                                        </span>
                                    </span>
                                ))}
                                <span className="lyr-bp-px">px</span>
                            </div>
                        </div>
                        <span className="lyr-bp-stops">{labels.join(" / ")}</span>
                    </div>
                    <div className="lyr-bp-stage">
                        <div className="lyr-bp-well">
                            <div className="lyr-bp-frame">
                                {desktop && desktop.src ? null : <div className="lyr-bp-ph" role="img" aria-label={`${title} at desktop width`} style={{ background: EMPTY }} />}
                                {shots.map((s, n) =>
                                    s && s.src ? (
                                        <img
                                            key={n}
                                            srcSet={s.srcSet}
                                            sizes={s.srcSet ? (n === 0 ? "94vw" : n === 1 ? "56vw" : "40vw") : undefined}
                                            src={s.src}
                                            alt={n === 0 ? s.alt || `${title} at desktop width` : ""}
                                            aria-hidden={n === 0 ? undefined : true}
                                            loading="lazy"
                                            decoding="async"
                                            draggable={false}
                                            style={{ opacity: n === 0 ? 1 : 0 }}
                                        />
                                    ) : null
                                )}
                            </div>
                        </div>
                        <div className="lyr-bp-info lyr-bp-a">
                            <h3 className="lyr-bp-t">{title}</h3>
                            <p className="lyr-bp-s">{summary}</p>
                            {link ? (
                                <a className="lyr-bp-link" href={link}>
                                    {linkLabel}
                                    {arrow}
                                </a>
                            ) : null}
                        </div>
                        <div className="lyr-bp-info lyr-bp-b">
                            <dl className="lyr-bp-facts">
                                {facts.map((x, n) => (
                                    <div key={n}>
                                        <dt>{x.label}</dt>
                                        <dd>{x.value}</dd>
                                    </div>
                                ))}
                            </dl>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    )
}

addPropertyControls(Breakpoints, {
    desktop: { type: ControlType.ResponsiveImage, title: "Desktop screen", description: "A screenshot taken at the desktop width." },
    tablet: { type: ControlType.ResponsiveImage, title: "Tablet screen", description: "Optional. Without it the desktop screen is reused." },
    phone: { type: ControlType.ResponsiveImage, title: "Phone screen", description: "Optional. Without it the tablet screen is reused." },
    desktopWidth: { type: ControlType.Number, title: "Desktop width", defaultValue: 1440, min: 600, max: 3000, step: 1, unit: "px", description: "The window width each screenshot was taken at. It sets the frame's width and the number above it." },
    tabletWidth: { type: ControlType.Number, title: "Tablet width", defaultValue: 810, min: 300, max: 2000, step: 1, unit: "px" },
    phoneWidth: { type: ControlType.Number, title: "Phone width", defaultValue: 390, min: 200, max: 1000, step: 1, unit: "px" },
    heading: { type: ControlType.String, title: "Heading", defaultValue: "Built to hold at any width.", displayTextArea: true },
    quiet: { type: ControlType.String, title: "Heading, quiet part", defaultValue: "Keep scrolling and this one folds down to a phone.", displayTextArea: true },
    title: { type: ControlType.String, title: "Project", defaultValue: "Shop ledger" },
    summary: { type: ControlType.String, title: "Summary", defaultValue: "A payments ledger for small shops. One codebase from the till to the back office.", displayTextArea: true },
    link: { type: ControlType.Link, title: "Link" },
    linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "Read the case study" },
    facts: {
        type: ControlType.Array,
        title: "Facts",
        maxCount: 6,
        description: "Label and value pairs that arrive at the tablet stop.",
        control: {
            type: ControlType.Object,
            controls: {
                label: { type: ControlType.String, title: "Label", defaultValue: "Label" },
                value: { type: ControlType.String, title: "Fact", defaultValue: "Fact" },
            },
        },
        defaultValue: [
            { label: "Role", value: "Lead engineer, interface and API" },
            { label: "Year", value: "2025" },
            { label: "Built with", value: "TypeScript, Go, SQL" },
            { label: "Result", value: "Page load at 96 ms" },
        ],
    },
    scrollLength: { type: ControlType.Number, title: "Scroll per step", defaultValue: 80, min: 30, max: 300, step: 5, unit: "%", description: "How much scrolling moves the frame to the next width, as a share of the window height." },
    fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the site's text styles." },
    ink: { type: ControlType.Color, title: "Ink", defaultValue: "#F0ECE4" },
    mute: { type: ControlType.Color, title: "Muted", defaultValue: "#918D84" },
    quietTone: { type: ControlType.Color, title: "Quiet tone", defaultValue: "#625F58", description: "The second tone of the heading and the ruler marks." },
    hairline: { type: ControlType.Color, title: "Hairline", defaultValue: "rgba(240,236,228,0.14)" },
    ground: { type: ControlType.Color, title: "Ground", defaultValue: "#171512", description: "Shown behind the screenshot while it loads." },
    topSpace: { type: ControlType.Number, title: "Top space", defaultValue: 76, min: 0, max: 200, step: 1, unit: "px", description: "Height of the navigation above." },
    sideSpace: { type: ControlType.Number, title: "Side space", defaultValue: 48, min: 0, max: 120, step: 1, unit: "px" },
})

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.