Components / Scroll scene
Horizontal Scroll
Full-screen panels pin and slide sideways as you scroll down; the cards in them move at different depths and drift with the mouse.
Scroll insideFull screen
Props
#FFFFFF
#0E0E0D
#6E6E6B
#FF4A1C
Set in code: Cards, Button link. The preview fills these in with demo content.
Scroll inside the preview, and move the mouse over it.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Horizontal Scroll shows up in the Insert menu, and its controls appear in the right panel.
// Horizontal Scroll · Layr Components
//
// Full-screen panels that pin and slide sideways while the page scrolls down.
// The cards in them sit at different depths: near ones fly past faster and
// tilt, far ones lag and shrink back, and all of them drift a little with the
// mouse. The opening panel's text blurs and falls behind as it leaves.
// On narrow screens, and for visitors who prefer reduced motion, the panels
// stack and the cards sit in a grid.
// 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 panels slide, so no
// ancestor may clip overflow. Cards fill the panels in order: four around the
// opening text, then up to five per panel. Without photos they show plain tones.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useMotionValue, useMotionValueEvent, useReducedMotion, useScroll, useSpring } from "framer-motion"
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
type Img = { src: string; srcSet?: string; alt?: string }
type Item = { image?: Img; title?: string; note?: string; link?: string }
interface HorizontalScrollProps {
items: Item[]
heading: string
text: string
linkLabel: string
link?: string
stackBelow: number
mouseDrift: boolean
fontFamily: string
background: string
ink: string
muted: string
accent: string
style?: CSSProperties
}
const ITEMS: Item[] = [
{ title: "Night drive", note: "Short film" },
{ title: "On set", note: "Behind the scenes" },
{ title: "Light test", note: "Camera test" },
{ title: "Colour pass", note: "Grade" },
{ title: "First light", note: "Commercial" },
{ title: "The long road", note: "Documentary" },
{ title: "Studio session", note: "Music video" },
{ title: "Archive", note: "Restoration" },
]
// Shown when a card has no photo yet.
const EMPTY = ["linear-gradient(160deg, #3a3f44, #16181a)", "linear-gradient(160deg, #4a4036, #1b1714)", "linear-gradient(160deg, #36353f, #151419)", "linear-gradient(160deg, #3d4238, #161814)"]
/** How floaty the sideways movement feels. Lower stiffness = smoother, more lag. */
const SMOOTHING = { stiffness: 120, damping: 28, mass: 0.6 }
const POINTER = { stiffness: 45, damping: 16 }
/**
* Depths, near to far. shift: extra sideways travel as a share of the panel width
* (positive = faster than the page); tilt: degrees while the panel moves; shrink: how
* much it falls back while its panel is off centre; drift: pixels of mouse drift.
*/
const DEPTH = [
{ shift: 0.38, tilt: 7, shrink: 0, drift: 22 },
{ shift: 0.14, tilt: -3.5, shrink: 0.04, drift: 11 },
{ shift: -0.22, tilt: 0, shrink: 0.12, drift: 5 },
]
/** A card's place in its panel: left and top in % of the panel, width in % of its width, shape (width / height) and depth (0 near, 1 mid, 2 far). */
type Slot = { x: number; y: number; w: number; r: number; d: number }
// Far cards lag a fifth of a screen behind their panel, so they sit in its middle
// band (from 22% to 78% across): that way none is left showing in the next panel,
// or shows early in the one before, once the scroll comes to rest.
const OPENING: Slot[] = [
{ x: 4.5, y: 11, w: 18, r: 16 / 9, d: 0 },
{ x: 78, y: 13, w: 17.5, r: 16 / 9, d: 1 },
{ x: 9, y: 52, w: 13, r: 1, d: 2 },
{ x: 72, y: 56, w: 20, r: 16 / 9, d: 0 },
]
const REEL: Slot[] = [
{ x: 6, y: 16, w: 20, r: 16 / 9, d: 1 },
{ x: 36, y: 9, w: 28, r: 16 / 9, d: 0 },
{ x: 76, y: 18, w: 15, r: 4 / 5, d: 0 },
{ x: 14, y: 57, w: 25, r: 16 / 9, d: 1 },
{ x: 52, y: 60, w: 13, r: 1, d: 2 },
]
/** Which reel places a panel uses when it holds fewer than five cards. */
const PICK = [[], [1], [1, 3], [0, 1, 4], [0, 1, 2, 3], [0, 1, 2, 3, 4]]
/** Four cards around the opening text, the rest shared out evenly, up to five a panel. */
function plan(count: number) {
const cards: { panel: number; slot: Slot }[] = []
const first = Math.min(OPENING.length, count)
for (let i = 0; i < first; i++) cards.push({ panel: 0, slot: OPENING[i] })
let rest = count - first
const extra = Math.ceil(rest / REEL.length)
for (let p = 0; p < extra; p++) {
const take = Math.ceil(rest / (extra - p))
for (const k of PICK[take]) cards.push({ panel: p + 1, slot: REEL[k] })
rest -= take
}
return { cards, panels: 1 + extra }
}
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
function css(stackBelow: number) {
return `
.lyr-hs{position:relative;width:100%;container:lyr-hs/inline-size;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
.lyr-hs[data-mode=row]{height:calc(100vh + var(--travel));height:calc(100svh + var(--travel))}
.lyr-hs-stage{position:relative;width:100%}
.lyr-hs[data-mode=row] .lyr-hs-stage{position:sticky;top:0;height:100vh;height:100svh;overflow:clip}
.lyr-hs-track{display:flex;flex-direction:column}
.lyr-hs[data-mode=row] .lyr-hs-track{flex-direction:row;width:calc(var(--n) * var(--pw));height:100%;will-change:transform}
.lyr-hs-panel{position:relative;flex:none;box-sizing:border-box;width:100%;height:100vh;height:100svh;container-type:size;overflow:clip}
.lyr-hs[data-mode=row] .lyr-hs-panel{width:var(--pw);height:100%;overflow:visible}
.lyr-hs[data-static] .lyr-hs-panel{height:760px}
.lyr-hs-card{position:absolute;z-index:0;left:calc(var(--x) * 1%);top:calc(var(--y) * 1%);width:calc(var(--w) * min(1cqw, 1.6cqh));display:block;color:inherit;text-decoration:none;transition:opacity .25s ease}
.lyr-hs[data-mode=row] .lyr-hs-card{will-change:transform}
a.lyr-hs-card:hover{opacity:.78}
.lyr-hs-media{position:relative;display:block;width:100%;aspect-ratio:var(--r);border-radius:6px;overflow:hidden}
.lyr-hs-media::after{content:"";position:absolute;inset:0;border-radius:inherit;border:1px solid rgba(255,255,255,.08);pointer-events:none}
.lyr-hs-media img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;-webkit-user-drag:none}
.lyr-hs-cap{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:3px 12px;margin-top:10px;font-size:13px;line-height:1.3;letter-spacing:-.005em}
.lyr-hs-t,.lyr-hs-n{max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lyr-hs-t{color:var(--ink)}
.lyr-hs-t::before{content:"";display:inline-block;width:5px;height:5px;margin-right:7px;border-radius:50%;background:var(--accent);vertical-align:2px}
.lyr-hs-n{color:var(--muted)}
.lyr-hs-intro{position:absolute;inset:0;z-index:1;display:grid;place-items:center;padding:0 24px;pointer-events:none}
.lyr-hs-copy{width:min(560px, 42cqw);text-align:center;pointer-events:auto}
.lyr-hs-h{margin:0;font-size:clamp(34px, 5cqw, 80px);font-weight:500;line-height:1.02;letter-spacing:-.035em;text-wrap:balance}
.lyr-hs-p{margin:20px auto 0;max-width:30em;font-size:clamp(15px, 1.2cqw, 18px);line-height:1.5;color:var(--muted);text-wrap:pretty}
.lyr-hs-btn{display:inline-flex;align-items:center;gap:10px;margin-top:28px;padding:13px 20px;border-radius:999px;background:var(--accent);color:var(--bg);font-size:15px;line-height:1;letter-spacing:-.01em;text-decoration:none;transition:opacity .2s}
.lyr-hs-btn:hover{opacity:.8}
@container lyr-hs (max-width:${(stackBelow - 0.02).toFixed(2)}px){
.lyr-hs[data-mode] .lyr-hs-panel{height:auto;container-type:normal;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:28px 14px;align-content:start;padding:72px 20px 0}
.lyr-hs[data-mode] .lyr-hs-panel+.lyr-hs-panel{padding-top:28px}
.lyr-hs[data-mode] .lyr-hs-panel:last-child{padding-bottom:48px}
.lyr-hs[data-mode] .lyr-hs-intro{position:static;grid-column:1/-1;grid-row:1;display:block;padding:0 0 16px}
.lyr-hs[data-mode] .lyr-hs-copy{width:auto;text-align:left}
.lyr-hs[data-mode] .lyr-hs-h{font-size:clamp(34px, 10cqw, 56px)}
.lyr-hs[data-mode] .lyr-hs-p{margin-left:0;font-size:16px}
.lyr-hs[data-mode] .lyr-hs-card{position:relative;left:auto;top:auto;width:auto}
.lyr-hs[data-mode] .lyr-hs-media{aspect-ratio:4/3}
}
@media (prefers-reduced-motion:reduce){.lyr-hs-card,.lyr-hs-btn{transition:none}}
`
}
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 760
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function HorizontalScroll(props: HorizontalScrollProps) {
const {
items = ITEMS,
heading = "Moving pictures, made with care.",
text = "A small studio making commercials, documentaries and brand films, from the first idea to the final grade.",
linkLabel = "See the work",
link,
stackBelow = 810,
mouseDrift = true,
fontFamily = "Inter",
background = "#0A0A0A",
ink = "#F2EFE9",
muted = "#8F8C86",
accent = "#E9FFA1",
} = props
const isStatic = useIsStaticRenderer()
const reduced = !!useReducedMotion()
const rootRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const copyRef = useRef<HTMLDivElement>(null)
const [box, setBox] = useState({ wide: false, pw: 0 })
const list = items ?? []
const layout = useMemo(() => plan(list.length), [list.length])
const count = layout.panels
const on = !isStatic && !reduced && box.wide && box.pw > 0 && count > 1
const { scrollYProgress } = useScroll({ target: rootRef, offset: ["start start", "end end"] })
const smooth = useSpring(scrollYProgress, SMOOTHING)
const mx = useMotionValue(0)
const my = useMotionValue(0)
const px = useSpring(mx, POINTER)
const py = useSpring(my, POINTER)
// Sideways mode is on from the set width up; it follows the component's own width.
useEffect(() => {
const root = rootRef.current
if (isStatic || !root || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(() => {
const pw = root.clientWidth
const wide = pw >= stackBelow
setBox((b) => (b.pw === pw && b.wide === wide ? b : { wide, pw }))
})
ro.observe(root)
return () => ro.disconnect()
}, [isStatic, stackBelow])
// The mouse, as -1 to 1 across the window. Fine pointers only.
useEffect(() => {
if (isStatic || reduced || !mouseDrift || !window.matchMedia("(pointer: fine)").matches) return
const move = (event: PointerEvent) => {
mx.set((event.clientX / window.innerWidth) * 2 - 1)
my.set((event.clientY / window.innerHeight) * 2 - 1)
}
window.addEventListener("pointermove", move, { passive: true })
return () => {
window.removeEventListener("pointermove", move)
mx.set(0)
my.set(0)
}
}, [isStatic, reduced, mouseDrift, mx, my])
const paint = useCallback(() => {
const track = trackRef.current
if (!track || isStatic) return
const cards = track.querySelectorAll<HTMLElement>(".lyr-hs-card")
const copy = copyRef.current
if (reduced) {
track.style.transform = ""
cards.forEach((card) => (card.style.transform = ""))
if (copy) copy.style.transform = copy.style.opacity = copy.style.filter = ""
return
}
const w = box.pw
const steps = count - 1
const p = on ? smooth.get() : 0
track.style.transform = on ? `translate3d(${(-p * steps * w).toFixed(2)}px,0,0)` : ""
const dx = mouseDrift ? px.get() : 0
const dy = mouseDrift ? py.get() : 0
cards.forEach((card) => {
const depth = DEPTH[Number(card.dataset.d) || 0] ?? DEPTH[1]
// -1 while its panel enters, 0 when the panel fills the screen, 1 while it leaves.
const l = on ? clamp(p * steps - (Number(card.dataset.p) || 0), -1.5, 1.5) : 0
const x = -l * depth.shift * w + dx * depth.drift
const y = dy * depth.drift * 0.6
const scale = 1 - Math.min(1, Math.abs(l)) * depth.shrink
card.style.transform = `translate3d(${x.toFixed(2)}px,${y.toFixed(2)}px,0) rotate(${(l * depth.tilt).toFixed(2)}deg) scale(${scale.toFixed(4)})`
})
if (copy) {
const leave = on ? clamp(p * steps, 0, 1) : 0
copy.style.transform = leave > 0 ? `translate3d(${(leave * 0.45 * w).toFixed(2)}px,0,0) scale(${(1 - leave * 0.14).toFixed(4)})` : ""
copy.style.opacity = leave > 0 ? String(1 - Math.min(1, leave * 1.5)) : ""
copy.style.filter = leave > 0.001 ? `blur(${(leave * 12).toFixed(2)}px)` : ""
}
}, [isStatic, reduced, on, box.pw, count, mouseDrift, smooth, px, py])
useMotionValueEvent(smooth, "change", paint)
useMotionValueEvent(px, "change", paint)
useMotionValueEvent(py, "change", paint)
useEffect(() => paint(), [paint])
// In-page links (for example "#reel") that point into a panel scroll to that panel.
useEffect(() => {
if (!on) return
const onClick = (event: MouseEvent) => {
const anchor = (event.target as Element | null)?.closest?.("a")
const track = trackRef.current
const root = rootRef.current
if (!anchor || !track || !root || !anchor.href) return
const url = new URL(anchor.href, window.location.href)
if (!url.hash || url.pathname !== window.location.pathname) return
const target = document.getElementById(decodeURIComponent(url.hash.slice(1)))
if (!target || !track.contains(target)) return
const index = Array.from(track.children).findIndex((child) => child.contains(target))
if (index < 0) return
event.preventDefault()
event.stopPropagation()
window.scrollTo({ top: root.getBoundingClientRect().top + window.scrollY + index * box.pw, behavior: "smooth" })
}
document.addEventListener("click", onClick, true)
return () => document.removeEventListener("click", onClick, true)
}, [on, box.pw])
// Tabbing to a card in another panel scrolls the page to that panel.
useEffect(() => {
const track = trackRef.current
const root = rootRef.current
if (!on || !track || !root) return
const onFocus = (event: FocusEvent) => {
const index = Array.from(track.children).findIndex((child) => child.contains(event.target as Node))
if (index < 0 || index === Math.round(smooth.get() * (count - 1))) return
window.scrollTo({ top: root.getBoundingClientRect().top + window.scrollY + index * box.pw })
}
track.addEventListener("focusin", onFocus)
return () => track.removeEventListener("focusin", onFocus)
}, [on, box.pw, count, smooth])
const vars = {
"--bg": background,
"--ink": ink,
"--muted": muted,
"--accent": accent,
"--n": String(count),
"--pw": box.pw > 0 ? `${box.pw}px` : "100vw",
"--travel": `${(count - 1) * box.pw}px`,
} as CSSProperties
const card = (item: Item, index: number) => {
const { panel, slot } = layout.cards[index]
const look = { "--x": slot.x, "--y": slot.y, "--w": slot.w, "--r": slot.r.toFixed(4) } as CSSProperties
const inner = (
<>
<span className="lyr-hs-media">
{item.image?.src ? (
<img src={item.image.src} srcSet={item.image.srcSet} sizes={`(min-width: ${stackBelow}px) ${Math.ceil(slot.w)}vw, 50vw`} alt={item.image.alt || item.title || ""} loading={panel ? "lazy" : "eager"} decoding="async" draggable={false} />
) : (
<span style={{ position: "absolute", inset: 0, background: EMPTY[index % EMPTY.length] }} />
)}
</span>
{(item.title || item.note) && (
<span className="lyr-hs-cap">
<span className="lyr-hs-t">{item.title}</span>
<span className="lyr-hs-n">{item.note}</span>
</span>
)}
</>
)
return item.link ? (
<a key={index} className="lyr-hs-card" href={item.link} data-p={panel} data-d={slot.d} style={look}>
{inner}
</a>
) : (
<div key={index} className="lyr-hs-card" data-p={panel} data-d={slot.d} style={look}>
{inner}
</div>
)
}
return (
<div ref={rootRef} className="lyr-hs" data-mode={on ? "row" : "stack"} data-static={isStatic ? "" : undefined} style={{ ...props.style, ...vars, fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif` }}>
<style>{css(stackBelow)}</style>
<div className="lyr-hs-stage">
<div ref={trackRef} className="lyr-hs-track">
{Array.from({ length: count }, (_, p) => (
<div key={p} className="lyr-hs-panel">
{list.map((item, i) => (layout.cards[i]?.panel === p ? card(item, i) : null))}
{p === 0 && (heading || text || (link && linkLabel)) && (
<div className="lyr-hs-intro">
<div ref={copyRef} className="lyr-hs-copy">
{heading && <h2 className="lyr-hs-h">{heading}</h2>}
{text && <p className="lyr-hs-p">{text}</p>}
{link && linkLabel && (
<a className="lyr-hs-btn" href={link}>
{linkLabel}
</a>
)}
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
)
}
addPropertyControls(HorizontalScroll, {
items: {
type: ControlType.Array,
title: "Cards",
maxCount: 24,
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Photo" },
title: { type: ControlType.String, title: "Title", defaultValue: "" },
note: { type: ControlType.String, title: "Note", defaultValue: "" },
link: { type: ControlType.Link, title: "Link" },
},
},
defaultValue: ITEMS,
description: "In order: four around the opening text, then up to five a panel. Each new panel adds a screen of sideways scrolling.",
},
heading: { type: ControlType.String, title: "Heading", defaultValue: "Moving pictures, made with care." },
text: {
type: ControlType.String,
title: "Text",
defaultValue: "A small studio making commercials, documentaries and brand films, from the first idea to the final grade.",
displayTextArea: true,
},
linkLabel: { type: ControlType.String, title: "Button", defaultValue: "See the work" },
link: { type: ControlType.Link, title: "Button link", description: "The button shows once it has a link." },
stackBelow: {
type: ControlType.Number,
title: "Stack below",
defaultValue: 810,
min: 320,
max: 1600,
step: 10,
unit: "px",
description: "Narrower than this, the panels stack and the cards sit in a grid.",
},
mouseDrift: { type: ControlType.Boolean, title: "Mouse drift", defaultValue: true, enabledTitle: "On", disabledTitle: "Off" },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
background: { type: ControlType.Color, title: "Background", defaultValue: "#0A0A0A" },
ink: { type: ControlType.Color, title: "Text", defaultValue: "#F2EFE9" },
muted: { type: ControlType.Color, title: "Muted", defaultValue: "#8F8C86" },
accent: { type: ControlType.Color, title: "Accent", defaultValue: "#E9FFA1", description: "The button and the dot before each card title." },
})
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.