Layr Components

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
810 px
#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.