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
#0E0E0D
#6E6E6B
#A3A3A0
#E6E5E1
#F5F4F1
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.