Layr Components

Components / Utility

Smooth Scroll

Nothing to see: a notched mouse wheel scrolls the page with a short glide, while trackpads, touch, keys and reduced motion are left alone.

Scroll insideFull screen
Props
0.14 s
3 screens

Scroll with a notched mouse wheel to feel it. Trackpads are left alone.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Smooth Scroll shows up in the Insert menu, and its controls appear in the right panel.
// Smooth Scroll · Layr Components
//
// Draws nothing. A notched mouse wheel scrolls the page with a short glide;
// trackpads, touch, the keyboard, the scrollbar, nested scroll areas and
// visitors who ask for reduced motion are left alone. Animated links to a
// section glide for short hops only; far ones jump.
// Paste into a new code file in Framer. Place one instance in the layout
// template (any size, it is invisible). One engine runs per page however many
// copies are mounted.

import { addPropertyControls, ControlType, RenderTarget, useIsStaticRenderer } from "framer"
import { useEffect, type CSSProperties } from "react"

interface SmoothScrollProps {
    enabled: boolean
    glide: number
    trackpad: boolean
    glideLinks: number
    style?: CSSProperties
}

// One engine per page, however many copies Framer mounts (one per breakpoint).
const KEY = "__lyrSmoothScroll"

type Settings = { on: boolean; glide: number; trackpad: boolean; links: number }
type Engine = { sync: () => void; destroy: () => void }
type Store = { users: Settings[]; engine: Engine }

const GLIDE = 0.14
const LINKS = 3
const IDLE: Settings = { on: false, glide: GLIDE, trackpad: false, links: LINKS }

const noSlash = (path: string) => path.replace(/\/+$/, "")
const instant = (top: number) => ({ top, behavior: "instant" }) as unknown as ScrollToOptions

// What is known about the visitor's wheel. Kept outside the engine so a new page does not forget it.
let seen = false // the last thing used was a trackpad

function createEngine(read: () => Settings): Engine {
    const win = window
    const doc = document
    const root = (doc.scrollingElement || doc.documentElement) as HTMLElement
    const fine = win.matchMedia("(hover: hover) and (pointer: fine)")
    const reduce = win.matchMedia("(prefers-reduced-motion: reduce)")

    let alive = true
    let watching = false // the wheel is being watched
    let gripping = false // and can be taken
    let target = 0 // where the wheel has asked the page to go
    let current = 0 // where the glide has got to
    let written = 0 // the last position this engine set
    let raf = 0
    let last = 0
    let lambda = 7
    let lastWheel = -1e9
    let lastSize = 0
    let pad = false // the wheel events arriving now come from a trackpad
    let lockAt = -1e9
    let locked = false
    let jumpTimer = 0
    let waiting: ((e: Event) => void) | null = null // a jump that has not happened yet

    const limit = () => Math.max(0, root.scrollHeight - root.clientHeight)
    const halt = () => {
        if (raf) cancelAnimationFrame(raf)
        raf = 0
    }

    // An open menu or modal that blocks page scroll sets overflow on <html> or <body>.
    const isLocked = (now: number) => {
        if (now - lockAt > 250) {
            lockAt = now
            const a = getComputedStyle(root).overflowY
            const b = getComputedStyle(doc.body).overflowY
            locked = a === "hidden" || a === "clip" || b === "hidden" || b === "clip"
        }
        return locked
    }

    // One axis of an element under the pointer. True when the element keeps the wheel: it can
    // still scroll that way, or its overscroll-behavior stops the page behind it, which holds
    // for every scroll container, one with nothing to scroll included.
    const keeps = (overflow: string, chain: string, room: () => boolean) => {
        const stops = !!chain && chain !== "auto" // a browser without overscroll-behavior stops nothing
        return overflow === "hidden" ? stops : (overflow === "auto" || overflow === "scroll") && (stops || room())
    }

    // True when something between the pointer and the page takes this wheel itself.
    const nested = (e: WheelEvent, dy: number) => {
        const dx = e.deltaX
        const path = (e.composedPath ? e.composedPath() : []) as HTMLElement[]
        for (const n of path) {
            if (n === root || n === doc.body) break
            if (n.nodeType !== 1) continue
            if (n.hasAttribute("data-native-scroll")) return true
            const s = getComputedStyle(n)
            const roomY = () => {
                const max = n.scrollHeight - n.clientHeight
                return max > 1 && (dy < 0 ? n.scrollTop > 0 : n.scrollTop < max - 1)
            }
            // Sideways the position runs up from 0, or down from 0 in a right-to-left area, so
            // at 0 there may be room either way and the browser decides.
            const roomX = () => {
                const max = n.scrollWidth - n.clientWidth
                const at = n.scrollLeft
                return max > 1 && (Math.abs(at) < 1 || dx * at < 0 || Math.abs(at) < max - 1)
            }
            if (keeps(s.overflowY, s.overscrollBehaviorY, roomY)) return true
            // A step with a sideways part belongs to a sideways area under the pointer.
            if (dx && keeps(s.overflowX, s.overscrollBehaviorX, roomX)) return true
        }
        return false
    }

    const tick = (t: number) => {
        raf = 0
        const y = win.scrollY
        // Keyboard, scrollbar, find-in-page, an anchor or a new page moved the scroll: follow it.
        if (Math.abs(y - written) > 2) {
            current = target = written = y
            return
        }
        const dt = Math.min(0.064, Math.max(0.001, (t - last) / 1000))
        last = t
        current += (target - current) * (1 - Math.exp(-lambda * dt))
        if (Math.abs(target - current) < 0.5) current = target
        written = current
        win.scrollTo(instant(current))
        if (current !== target) raf = requestAnimationFrame(tick)
    }

    // The vertical size of one wheel event in pixels, or 0 when it is not for the page:
    // zoom, sideways scrolling and anything a component already handled stay native.
    const step = (e: WheelEvent) => {
        if (e.defaultPrevented || e.ctrlKey || e.shiftKey) return 0
        let dy = e.deltaY
        if (e.deltaMode === 1) dy *= 18
        else if (e.deltaMode === 2) dy *= win.innerHeight
        return Math.abs(e.deltaX) > Math.abs(dy) ? 0 : dy
    }

    // Glides one step of a mouse wheel.
    const take = (e: WheelEvent) => {
        const dy = step(e)
        if (!dy) return
        const now = performance.now()
        const s = read()
        // Left alone, and a glide in progress stops: a trackpad, a small step while the page is
        // still (only a full notch starts a glide), a step with a sideways part on a page that
        // scrolls sideways, and a locked page.
        const small = !raf && Math.abs(dy) < 36
        const wide = e.deltaX !== 0 && root.scrollWidth > root.clientWidth + 1
        if ((!s.trackpad && (pad || small)) || wide || isLocked(now)) return halt()
        // Left alone, and a glide in progress carries on, so what the wheel has already asked
        // of the page still arrives: a step for a scroll area under the pointer, and a scroll
        // the browser has already started (it can no longer be cancelled, and it may be moving
        // that area and not the page). If the page is what moves, the frame loop gives way.
        if (!e.cancelable || nested(e, dy)) return
        if (!raf) current = target = written = win.scrollY
        const max = limit()
        // At either end, leave the wheel alone so the browser can rubber-band.
        if (!raf && ((dy < 0 && current <= 0) || (dy > 0 && current >= max))) return
        e.preventDefault()
        // With Trackpads set to Glide, a trackpad is followed closely instead of left alone.
        lambda = pad ? 26 : 1 / Math.max(0.04, s.glide)
        target = Math.min(max, Math.max(0, target + dy))
        if (!raf) {
            last = now
            raf = requestAnimationFrame(tick)
        }
    }

    // Any key that could move the page stops a glide. A modifier pressed alone does not.
    const onKey = (e: KeyboardEvent) => {
        if (!/^(Shift|Control|Alt|AltGraph|Meta|OS|CapsLock|Fn)$/.test(e.key)) halt()
    }

    // A listener that can cancel the wheel makes the browser wait for this script before it
    // scrolls. It is attached only while a mouse wheel may need it, never while a trackpad is
    // in use: then the page scrolls exactly as it would without this component.
    const grip = () => {
        const want = watching && !(seen && !read().trackpad)
        if (want === gripping) return
        gripping = want
        if (want) win.addEventListener("wheel", take, { passive: false })
        else win.removeEventListener("wheel", take)
    }

    // Works out what is turning the page. A trackpad already glides by itself. It shows as
    // steps under 4px, which no wheel sends, or as a quick run with a small step in it.
    const watch = (e: WheelEvent) => {
        const size = Math.abs(step(e))
        if (!size) return
        const now = performance.now()
        const gap = now - lastWheel
        const was = seen
        if (gap > 1000) pad = false // a second of quiet: look again
        // A full notch after a pause: a wheel is back. This first run is still left alone.
        if (seen && gap > 200 && size >= 36) {
            seen = false
            pad = true
        }
        // A glide with a long way still to go is not cut short by one small step.
        if (!raf || Math.abs(target - current) <= 50) {
            if (size < 4) seen = true
            if (seen || (gap < 34 && (size < 36 || lastSize < 36))) pad = true
        }
        lastWheel = now
        lastSize = size
        if (seen !== was) grip()
    }

    const disarm = () => {
        clearTimeout(jumpTimer)
        if (!waiting) return
        win.removeEventListener("scroll", waiting)
        win.removeEventListener("wheel", waiting)
        win.removeEventListener("keydown", waiting, true)
        win.removeEventListener("pointerdown", waiting, true)
        win.removeEventListener("popstate", disarm)
        waiting = null
    }

    // Framer can animate a link to a section. That is kept for a short hop on the same page.
    // A far target, a target on another page, or a visitor who asked for reduced motion gets
    // a plain jump instead.
    const onClick = (e: MouseEvent) => {
        halt()
        lockAt = -1e9 // a click may have opened or closed a menu: look at the page lock again
        if (e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return // opens elsewhere
        const path = (e.composedPath ? e.composedPath() : []) as HTMLAnchorElement[]
        const a = path.find((n) => n && n.tagName === "A")
        if (!a) return
        disarm() // the newest link wins
        if (!a.hash || a.host !== location.host || a.target === "_blank") return
        let id = a.hash.slice(1)
        try {
            id = decodeURIComponent(id)
        } catch {
            // keep the id as written
        }
        const same = noSlash(a.pathname) === noSlash(location.pathname)
        const here = doc.getElementById(id)
        let jump = !same || reduce.matches // another page, or reduced motion: never glide
        if (!jump) {
            if (!here) return
            const top = here.getBoundingClientRect().top - (parseFloat(getComputedStyle(here).scrollMarginTop) || 0)
            jump = Math.abs(top) > read().links * win.innerHeight // too far: jump
        }
        if (!jump) return
        // Wait for the scroll Framer starts, then replace it with a plain jump. A target on
        // another page does not exist yet, so this stays armed until it does. Once the target
        // is there, a visitor who starts to scroll by hand keeps the page: the jump is dropped.
        // Back and Forward drop it too.
        const old = same ? null : here
        const hold = (ev: Event) => {
            const el = doc.getElementById(id)
            if (!el || el === old) return
            disarm()
            if (ev.type === "scroll") el.scrollIntoView({ behavior: "instant", block: "start" } as unknown as ScrollIntoViewOptions)
        }
        waiting = hold
        win.addEventListener("scroll", hold, { passive: true })
        win.addEventListener("keydown", hold, true)
        win.addEventListener("pointerdown", hold, true)
        if (fine.matches) win.addEventListener("wheel", hold, { passive: true })
        win.addEventListener("popstate", disarm)
        jumpTimer = win.setTimeout(disarm, same ? 600 : 4000)
    }

    const sync = () => {
        const want = alive && read().on && fine.matches && !reduce.matches
        if (want !== watching) {
            watching = want
            halt()
            if (want) {
                win.addEventListener("wheel", watch, { passive: true })
                // Any other way of moving the page wins at once.
                win.addEventListener("keydown", onKey, true)
                win.addEventListener("pointerdown", halt, true)
                win.addEventListener("popstate", halt)
                win.addEventListener("hashchange", halt)
            } else {
                win.removeEventListener("wheel", watch)
                win.removeEventListener("keydown", onKey, true)
                win.removeEventListener("pointerdown", halt, true)
                win.removeEventListener("popstate", halt)
                win.removeEventListener("hashchange", halt)
            }
        }
        grip()
    }

    win.addEventListener("click", onClick, true)
    fine.addEventListener("change", sync)
    reduce.addEventListener("change", sync)
    sync()

    return {
        sync,
        destroy() {
            alive = false
            sync()
            disarm()
            win.removeEventListener("click", onClick, true)
            fine.removeEventListener("change", sync)
            reduce.removeEventListener("change", sync)
        },
    }
}

/**
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 * @framerIntrinsicWidth 24
 * @framerIntrinsicHeight 24
 */
export default function SmoothScroll(props: SmoothScrollProps) {
    const { enabled = true, glide: glideValue = 0.14, trackpad: trackpadValue = false, glideLinks = 3, style } = props
    const on = enabled !== false
    const glide = Number(glideValue) > 0 ? Number(glideValue) : GLIDE
    const trackpad = trackpadValue === true
    const links = Number(glideLinks) >= 0 ? Number(glideLinks) : LINKS
    const isStatic = useIsStaticRenderer()

    useEffect(() => {
        if (isStatic || typeof window === "undefined") return
        if (RenderTarget.current() === RenderTarget.canvas) return
        const w = window as unknown as Record<string, Store | null | undefined>
        const mine: Settings = { on, glide, trackpad, links }
        let store = w[KEY]
        if (!store) {
            const users: Settings[] = []
            const made: Store = { users, engine: { sync() {}, destroy() {} } }
            made.engine = createEngine(() => made.users[made.users.length - 1] || IDLE)
            w[KEY] = made
            store = made
        }
        const own = store
        own.users.push(mine)
        own.engine.sync()
        return () => {
            own.users = own.users.filter((u) => u !== mine)
            if (own.users.length) own.engine.sync()
            else {
                own.engine.destroy()
                w[KEY] = null
            }
        }
    }, [on, glide, trackpad, links, isStatic])

    return <div aria-hidden="true" style={{ ...style, width: "100%", height: "100%", pointerEvents: "none" }} />
}

addPropertyControls(SmoothScroll, {
    enabled: { type: ControlType.Boolean, title: "Smooth scroll", defaultValue: true, enabledTitle: "On", disabledTitle: "Off" },
    glide: {
        type: ControlType.Number,
        title: "Glide",
        defaultValue: 0.14,
        min: 0.06,
        max: 0.4,
        step: 0.01,
        unit: "s",
        description: "How long the page keeps moving after the wheel stops. 0.14 is calm, 0.3 is slow.",
        hidden: (p: Partial<SmoothScrollProps>) => p.enabled === false,
    },
    trackpad: {
        type: ControlType.Boolean,
        title: "Trackpads",
        defaultValue: false,
        enabledTitle: "Glide",
        disabledTitle: "Leave",
        description: "Trackpads already glide. Leave them alone unless you want the extra weight.",
        hidden: (p: Partial<SmoothScrollProps>) => p.enabled === false,
    },
    glideLinks: {
        type: ControlType.Number,
        title: "Glide links",
        defaultValue: 3,
        min: 0,
        max: 10,
        step: 0.5,
        unit: " screens",
        description: "In-page links glide when the target is nearer than this. Further, they jump. 0 never glides.",
    },
})

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.