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
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.