Important: This documentation covers Yarn 1 (Classic).
For Yarn 2+ docs and migration guide, see yarnpkg.com.

Package detail

@14islands/r3f-scroll-rig

14islands2.1kISC8.15.0TypeScript support: included

Progressively enhance any React website with WebGL using @react-three/fiber

@react-three/fiber, webgl, react, three

readme

@14islands/r3f-scroll-rig

npm

Progressively enhance a React website with WebGL using @react-three/fiber and smooth scrolling.

[ Features | Introduction | Installing | Getting Started | Examples | API | Gotchas ]

Features 🌈

  • 🔍 Tracks DOM elements and draws Three.js objects in their place using correct scale and position.
  • 🤷 Framework agnostic - works with next.js, gatsby.js, create-react-app etc.
  • 📐 Can render objects in viewports. Makes it possible for each object to have a unique camera, lights, environment map, etc.
  • 🌠 Helps load responsive images from the DOM. Supports <picture>, srset and loading="lazy"
  • 🚀 Optimized for performance. Calls getBoundingClientRect() once on mount, and uses IntersectionObserver/ResizeObserver to keep track of elements.
  • 🧈 Uses Lenis for accessible smooth scrolling
  • ♻️ 100% compatible with the @react-three ecosystem, like Drei, react-spring and react-xr

Introduction 📚

Mixing WebGL with scrolling HTML is hard. One way is to have multiple canvases, but there is a browser-specific limit to how many WebGL contexts can be active at any one time, and resources can't be shared between contexts.

The scroll-rig has only one shared <GlobalCanvas/> that stays in between page loads.

React DOM components can choose to draw things on this canvas while they are mounted using a custom hook called useCanvas() or the <UseCanvas/> tunnel component.

The library also provides means to sync WebGL objects with the DOM while scrolling. We use a technique that tracks “proxy” elements in the normal page flow and updates the WebGL scene positions to match them.

The <ScrollScene/>, <ViewportScrollScene/> or the underlying useTracker() hook will detect initial location and dimensions of the proxy elements, and update positions while scrolling.

Everything is synchronized in lockstep with the scrollbar position on the main thread.

Further reading: Progressive Enhancement with WebGL and React

Installing 💾

yarn add @14islands/r3f-scroll-rig @react-three/fiber three

Getting Started 🛫

  1. Add <GlobalCanvas> to your layout. Keep it outside of your router to keep it from unmounting when navigating between pages.

  2. Add <SmoothScrollbar/> to your layout. In order to perfectly match WebGL objects and DOM content, the browser scroll position needs to be animated on the main thread.

<summary>Next.js</summary>
import { GlobalCanvas, SmoothScrollbar } from '@14islands/r3f-scroll-rig'

// _app.jsx
function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <GlobalCanvas />
      <SmoothScrollbar />
      <Component {...pageProps} />
    </>
  )
}
<summary>Gatsby.js</summary>
// gatsby-browser.js
import { GlobalCanvas, SmoothScrollbar } from '@14islands/r3f-scroll-rig'

export const wrapRootElement = ({ element }) => (
  <>
    <GlobalCanvas />
    <SmoothScrollbar />
    {element}
  </>
)
  1. Track a DOM element and render a Three.js object in its place

This is a basic example of a component that tracks the DOM and use the canvas to render a Mesh in its place:

import { UseCanvas, ScrollScene } from '@14islands/r3f-scroll-rig'

export const HtmlComponent = () => (
  const el = useRef()
  return (
    <>
      <div ref={el}>Track me!</div>
      <UseCanvas>
        <ScrollScene track={el}>
          {(props) => (
            <mesh {...props}>
              <planeGeometry />
              <meshBasicMaterial color="turquoise" />
            </mesh>
          )}
        </ScrollScene>
      </UseCanvas>
    </>
  )
)

How it works:

  • The page layout is styled using normal HTML & CSS
  • The UseCanvas component is used to send its children to the GlobalCanvas while the component is mounted
  • A <Scrollscene> is used to track the DOM element
  • Inside the <ScrollScene> we place a mesh which will receive the correct scale as part of the passed down props

⚠️ Note: HMR might not work for the children of <UseCanvas> unless you defined them outside. Also, the props on the children are not reactive by default since the component is tunneled to the global canvas. Updated props need to be tunneled like this.

Learn more about edge cases and solutions in the gotchas section.

Examples 🎪

API ⚙️

All components & hooks are described in the API docs

Components

Hooks

Gotchas 🧐

<summary>The default camera</summary>

The default scroll-rig camera is locked to a 50 degree Field-of-View.

In order to perfectly match DOM dimensions, the camera distance will be calculated. This calculation is based on screen height since Threejs uses a vertical FoV. This means the camera position-z will change slightly based on your height.

You can override the default camera behaviour, and for instance set the distance and have a variable FoV instead:

<GlobalCanvas camera={{ position: [0, 0, 10] }} />

Or change the FoV, which would move the camera further away in this case:

<GlobalCanvas camera={{ fov: 20 }} />

If you need full control of the camera you can pass in a custom camera as a child instead.

<summary>Use relative scaling</summary> Always base your sizes on the `scale` passed down from ScrollScene/ViewportScrollScene/useTracker in order to have consistent scaling for all screen sizes.

The scale is always matching the tracked DOM element and will update based on media queries etc.

<ScrollScene track={el}>
  {{ scale }} => (
  <mesh scale={scale} />
  )}
</ScrollScene>

Scale is a 3-dimensional vector type from vecn that support swizzling and object notation. You can do things like:

position.x === position[0]
position.xy => [x,y]
scale.xy.min() => Math.min(scale.x, scale.y)
<summary>Z-Fighting on 3D objects (scaleMultiplier)</summary>

By default the scroll-rig will calculate the camera FoV so that 1 pixel = 1 viewport unit.

In some cases, this can mess up the depth sorting, leading to visual glitches in a 3D model. A 1000 pixel wide screen would make the scene 1000 viewport units wide, and by default the camera will also be positioned ~1000 units away in Z-axis (depending on the FoV and screen hight).

One way to fix this is to enable the logarithmicDepthBuffer but that can be bad for performance.

A better way to fix the issue is to change the GlobalCanvas scaleMultiplier to something like 0.01 which would make 1000px = 10 viewport units.

<GlobalCanvas scaleMultiplier={0.01} />

The scaleMultiplier setting updates all internal camera and scaling logic. Hardcoded scales and positions would need to be updated if you change this setting.

<summary>Matching exact hex colors</summary>

By default R3F uses ACES Filmic tone mapping which makes 3D scenes look great.

However, if you need to match hex colors or show editorial images, you can disable it per material like so:

<meshBasicMaterial toneMapping={false} />
<summary>Cumulative layout shift (CLS)</summary>

All items on the page should have a predictable height - always define an aspect ratio using CSS for images and other interactive elements that might impact the document height as they load.

The scroll-rig uses ResizeObserver to detect changes to the document.body height, for instance after webfonts loaded, and will automatically recalculate postions.

If this fails for some reason, you can trigger a manual reflow() to recalculate all cached positions.

const { reflow } = useScrollRig()

useEffect(() => {
  heightChanged && reflow()
}, [heightChanged])
<summary>Performance tips</summary>
<summary>How to catch events from both DOM and Canvas</summary>

This is possible in R3F by re-attaching the event system to a parent of the canvas:

const ref = useRef()
return (
  <div ref={ref}>
    <GlobalCanvas
      eventSource={ref} // rebind event source to a parent DOM element
      eventPrefix="client" // use clientX/Y for a scrolling page
      style={{
        pointerEvents: 'none', // delegate events to wrapper
      }}
    />
  </div>
)
<summary>Can I use R3F events in `ViewportScrollScene`?</summary>

Yes, events will be correctly tunneled into the viewport, if you follow the steps above to re-attach the event system to a parent of the canvas.

<summary>inViewportMargin is not working in CodeSandbox</summary>

The CodeSandbox editor runs in an iframe which breaks the IntersectionObserver's rootMargin. If you open the example outside the iframe, you'll see it's working as intended.

This is know issue.

<summary>HMR is not working with UseCanvas children</summary>

This is a known issue with the UseCanvas component.

You can either use the useCanvas() hook instead, or make HMR work again by defining your children as top level functions instead of inlining them:

// HMR will work on me since I'm defined here!
const MyScrollScene = ({ el }) => <ScrollScene track={el}>/* ... */</ScrollScene>

function MyHtmlComponent() {
  return (
    <UseCanvas>
      <MyScrollScene />
    </UseCanvas>
  )
}

A similar issue exist in tunnel-rat.

<summary>Global render loop</summary>

The scroll-rig runs a custom render loop of the global scene inside r3f. It runs with priority 1000.

You can disable the global render loop using globalRender or change the priority with the globalPriority props on the <GlobalCanvas>. You can still schedule your own render passes before or after the global pass using useFrame with your custom priority.

The main reason for running our own custom render pass instead of the default R3F render, is to be able to avoid rendering when no meshes are in the viewport. To enable this you need to set frameloop="demand" on the GlobalCanvas.

<summary>Advanced - run frameloop on demand</summary>

If the R3F frameloop is set to demand - the scroll rig will make sure global renders and viewport renders only happens if it's needed.

To request global render call requestRender() from useScrollRig on each frame. ScrollScene will do this for you when the mesh is in viewport.

This library also supports rendering separate scenes in viewports as a separate render pass by calling renderViewport(). This way we can render scenes with separate lights or different camera than the global scene. This is how ViewportScrollScene works.

In this scenario you also need to call invalidate to trigger a new R3F frame.

<summary>How to use post-processing</summary>

Post processing runs in a separate pass so you need to manually disable the global render loop to avoid double renders.

<GlobalCanvas globalRender={false} scaleMultiplier={0.01}>
  <Effects />
</GlobalCanvas>

Note: ViewportScrollScene will not be affected by global postprocessing effects since it runs in a separate render pass.

<summary>How can I wrap my UseCanvas meshes in a shared Suspense?</summary>

Please read the API docs on using children as a render function for an example.

In the wild 🐾

changelog

Changelog

v8.15.0

  • useWindowSize
    • Feat: export added
  • StickyScrollScene
    • Fix: better sticky area calculation when reloading while scrolled down

v8.14.0

  • SmoothScrollbar
    • Feat: now using lenis import
  • preloadScene
    • Feat: Switched to object literal for arguments to match other render API functions
    • Fix: scene and camera props are now optional
  • UseCanvas
    • Fix: id prop is now passed to the child
  • powerups add type declaration

v8.13.0

  • ScrollScene improved portal support
  • StickyScrollScene improvements

v8.12.0

  • Upgrade @studio-freight/lenis to v1.0.23
  • SmoothScrollbar Passing in children is now obsolete and not needed. The only reason was to set pointer-events: none on the children while scrolling. We now do this directly on doucment.documentElement. This change makes it easier to dynamically import and conditionally render the scrollbar to split the bundle.

v8.11.0

  • Upgrade @studio-freight/lenis to v1.0.16

v8.10.0

  • Upgrade @studio-freight/lenis to v1.0.10

  • GlobalCanvas

    • Add back: as props to allow changing the default R3F Canvas component. Prep work for supporting custom tree shaked canvas or perhaps react-three-offscreen in the future

v8.9.0

Simplify render logic and improve camera controls.

  • ViewportScrollScene

    • Feat: now uses portal state enclave for camera so you can use OrbitControls or pass in a custom camera as a child.
    • Feat: No longer clears depth by default
    • Feat: added hud prop to clear depth
    • Feat: aadded camera prop to allow overriding default camera settings
      • specifying fov will calculate distance to match the DOM
    • Removed renderOrder - can be set manually on children instead
  • ScrollScene

    • Removed renderOrder - can be set manually on children instead
  • GlobalCanvas

    • Fix: make sure viewport is correct after resize when using default perspective camera
    • Feat: camera prop now allows overriding fov. If fov is specified, the camera distance will be calculated to match DOM size.
    • Feat: Default camera FoV now set to 50
    • Removed: globalClearAlpha - can be controlled by other useFrames with higher or lower priority instead
    • Removed: as - always renders as a default R3F Canvas. react-xr no longer uses VRCanvas and ARCanvas.
  • useImageAsTexture

    • Fix: better support for next/image loading="lazy"
  • SmoothScrollbar

    • Fix: make sure binding an onScroll callback fires an initial scroll event

v8.8.0

Added some properties to help support having multiple SmoothScrollbar on the page at the same time. The usecase is to open a Modal on top of the current page which also needs to be smooth scrolled.

  • useTracker

    • Added wrapper option to get initial scroll offset from DOM element instead of the window object.
    • Added scroll prop to update({ scroll }) to update tracker with custom scroll state. Useful when having a secondary scrollbar mounted.
  • SmoothScrollbar

    • Added onScroll prop to register a scroll event callback.
    • Added updateGlobalState prop. True by default. Set it to false to disable updating the global scroll state. Useful when having a secondary scrollbar mounted.

v8.7.0

  • scrollInContainer

    • Feat: Added experimental scrollInContainer prop which scrolls inside the body element instead of the default window. This can be used to avoid scrolling away the URL bar on mobile. It also enables the smoothTouch setting in Lenis which emulates scroll using touch events.
  • useTracker

    • Fix: Matches height of canvas element instead of window.innerHeight if possible. (Fixes position problems on mobile where canvas is 100vh)

v8.6.0

  • All files converted to TypeScript

v8.5.0

  • Fixed SSR warnings by replacing uesLayoutEffect with useIsomorphicLayoutEffect

  • GlobalCanvas

    • removed loadingFallback
    • children can now be a render function (optional). It accepts the global canvas children from useCanvas as a single parameter. This can be used to add suspense boundaries.
    <GlobalCanvas>
      {(globalChildren) => (
        <Suspense fallback={null}>
          {globalChildren}
          <AnotherPersistentComponent />
        </Suspense>
      )}
    </GlobalCanvas>
  • useImageAsTexture

    • Added WebP Accept header to fetch request if supported by brower
    • Notifies the DefaultLoadingManager that something is loading while waiting for the DOM image load.
  • Added global css with classes that can hide DOM elements when canvas is active import "@14islands/r3f-scroll-rig/css";

  • Global export styles added to access CSS class names from Javascript.

import { styles } from '@14islands/r3f-scroll-rig'

function Component() {
  return <div className={styles.hidden}>I will be `visibility: hidden` if WebGL is supported</div>
}
  • Removed useCanvasRef - use exported classnames and global CSS to hide elements via SSR instead to avoid FOUC

  • SmoothScrollbar

    • Replaced global html classname js-has-smooth-scrollbar with two classes: js-smooth-scrollbar-enabled and js-smooth-scrollbar-disabled
  • useCanvas - improved option dispose:false to keep unused meshes mounted. Now passes an inactive prop to the component which is true if no hook is using the mesh.

  • useTracker - new call signature

    • first argument is always the DOM ref
    • second argument is the optional config settings for the IntersectionObserver

v8.4.0

  • GlobalCanvas
    • children can now be a render function which accepts all global children as a single argument. Can be used if you need to wrap all canvas children with a parent.

v8.3.0

  • useTracker hook

    • Added autoUpdate configuration which decides if the tracker automatically updates on scroll events. True by default.
    • The update callback will now always recalculate positions even if element is outside viewport in case user wants to turn off autUpdate and take control.
  • SmoothScrollbar

    • Added horizontal prop

v8.1.0

  • useTracker hook

    • Added threshold prop which can used to customize the underlying Intersection Observer of the tracked DOM element
  • ScrollScene and ViewportScrollScene

    • Added inViewportThreshold prop which is passed to useTracker as threshold

v8.0.0

Complete refactor with focus on reducing complexity.

Now uses mostly R3F defaults and <GlobalCanvas> accepts all R3F Canvas props.

Advanced use-cases are enabled only when setting frameloop="demand" - so most users won't have to worry about this.

New peer deps:

  • @react-three/fiber ">=8.0.0"
  • Three.js >=0.139.0 is now required for colorManagement

New features

  • Started adding typescript
  • Uses https://github.com/studio-freight/lenis scrollbar
  • New hook useTracker that tracks DOM elements - refactored ScrollScene and ViewportScrollScene to use this internally.
  • New hook useCanvasRef which can be used to hide tracked DOM elements when the canvas is active.
  • New hook useImageAsTexture which loads images from the DOM and suspends via useLoader. Replaces the old useImgTagAsTexture which did not suspend properly and was more of a hack.

Breaking Changes:

  • Removed useImgTagAsTexture. Use useImageAsTexture instead.
  • ScrollScene and ViewportScrollScene

    • Renamed el prop to track
    • inViewportMargin is now a string and maps to IntersectionObserver rootMargin
    • Removed lerp, lerpOffset. Uses the SmoothScrollbar position directly.
    • Removed setInViewportProp prop. Instead uses IntersectionObserver to always set inViewport prop.
    • Removed updateLayout - relac position using the reflow() method from useSrcollRig() instead.
    • Removed positionFixed - suggest implementing manually in some other way using useTracker.
    • Removed autoRender - suggest implementing manually in a custom component using useTracker.
    • Removed resizeDelay
    • Removed hiddenStyle - use useCanvasRef instead to control how tracked DOM elements are hidden.
  • VirtualScrollbar and HijackedScrollbar removed. Use SmoothScrollbar instead which is similar to the old hijacked version.

  • GlobalCanvas

    • Removed config prop and added individual props instead:
      • Added debug to turn on shader compile errors and show console.logs
      • Added scaleMultiplier to control viewport units scaling
      • Added globalRender - enable/disable built-in render loop
      • Added globalPriority - enable/disable built-in render loop
      • Added globalAutoClear?: boolean to control if gl.clearDepth() is called before render in global render loop. Default false - render as HUD on top of viewports without clearing them.
      • Added globalClearDepth?: boolean to control gl.autoClear in global render loop. Default true.
    • Renamed fallback property to loadingFallback for global Suspense fallback as R3F Canvas already has a prop with this name
  • examples/ folder removed

  • added new import target @14islands/r3f-scroll-rig/powerups with useful helpers - might become separate repo later

v7.0.0

  • update to R3f v7
  • Enables autoRender by default if frameloop="always"

v6.0.0

  • Updated to R3F v6 api.

v2.1.0

ViewportScrollScene, ScrollScene, ScrollDomPortal

  • lerpOffset is now a factor that is multiplied with the lerp instead of added. Default value is now 1 instead of 0.

v2.0.0

Breaking upgrade. Simplify and remove as much as possible.

  • requestFrame is now removed. please use invalidate to trigger useFrame
  • global render pass now run with priority 1000
  • renderFullscreen has been renamed to requestRender - use this to trigger a global render pass.
  • renderScissor and renderViewport now renders immediately. use useFrame() priority to render before or after global render
  • preloadScene now runs with priority 0
  • ScrollScene and ViewportScrollScene runs with priority 1 by default
  • ScrollScene and ViewportScrollScene now accepts a priority prop to change the useFrame priority.
  • all pause and suspend logic has been removed

v1.11.0

Added stdlib export target with the following reusable components:

  • WebGLText
  • WebGLImage
  • ParallaxScrollScene
  • StickyScrollScene

E.g. import { StickyScrollScene } from '@14islands/r3f-scroll-rig/stdlib

v1.10.0

GlobalCanvas

  • Added back Stats component. fps config and querystring now works again

HijackedScrollbar

  • New experimental scrollbar with animates window.scrollTo instead of translating sections with CSS.

v1.9.21

ScrollDom (Experimental)

  • Removed. Consider using ScrollPortal or use drei's HTML component instead.

ScrollDomPortal

  • Removed framer-motion dependency.

ViewportScrollScene

  • Removed framer-motion dependency.

VirtualScrollbar

  • Removed framer-motion dependency.

ScrollScene

  • Removed experimental softDirection
  • Removed framer-motion dependency.

v1.9.17

GlobalCanvas

  • Added config option subpixelScrolling that affects ScrollScene. If false, the scroll poition will be rounded to an integer (browsers usually do this with normal scroll)

v1.9.13

ScrollDomPortal

  • portalEl now needs to be passed as an argument. GlobalCanvas no longer provides a default portal.

v1.9.12

GlobalCanvas

  • antialias and depth are now true by default.
  • VirtualScrolbar now uses same lerp & restDelta as Canvas components

v1.9.0

GlobalRenderer

  • No more automatic switching between global vs scissor renders. To make it more predictable, scissor passes are always rendered if requested.

ScrollScene

  • scissor is now false by default

v1.8.0

VirtualScrollbar

  • New prop scrollToTop (false by default) to automatically scroll to top of page when scrollbar mounts. (used to be true by default)

v1.7.1

GlobalRenderer

  • gl.autoClear is now only turned off if we have viewports renderering before main global render call. This fixes background alpha glitch on Oculus browser and WebXR clearing issues.

v1.7.0

GlobalCanvas

  • New property as to support rendering the global canvas as a VRCanvas for instance.

v1.6.0

ViewportScrollScene

  • PerspectiveCameraScene renamed to ViewportScrollScene with optional property orthographic to switch between orthographic and perspective cameras. Both are scaled to fit the viewport exactly.

GlobalCanvas

  • Uses custom cameras for global scaleMultiplier to work properly. Bypasses all built-in @react-three/fiber camera logic. Property orthogonal is used to select which camera.
  • added fps setting to the config propery which overrides scroll-rig config Querystring value for fps and debug override this config.
  • Default pixelRatio scaling can now be turned off with config={{autoPixelRatio: false}}
  • turned stencil buffer on by default (not sure disabling did anything good for perf anyway)
  • removed gl properties preserveDrawingBuffer: false and premultipliedAlpha: true that are default in threejs anyway to simplify

v1.5.0

ScrollScene

  • Deprecated layoutOffset and layoutLerp. Should be implemented by child component.

v1.4.0

ScrollScene

  • Deprecated state prop passed to child. Replaced by scrollState

PerspectiveCameraScene

  • Deprecated state prop passed to child. Replaced by scrollState
  • Accepts scaleMultiplier prop which overrides global setting

v1.3.0

GlobalCanvas

  • config propery which overrides scroll-rig config. Props that might be useful to change are debug, scaleMultiplier, scrollLerp.
  • scaleMultiplier config added which affects PerspectiveCameraScene and ScrollScene scaling. Used to scale pixels vs viewport units (1 by default, i.e. 1px = 1 viewport unit). Suggest using 0.001 for perspective scenes to avoid depth buffer sorting issues etc. (1000px == 1 viewport unit)

ScrollScene

  • Scale scene using global config.scaleMultiplier

PerspectiveCameraScene

  • Scale scene using global config.scaleMultiplier

ResizeManager

  • Fix broken resize logic under some race conditions

v1.2.0

GlobalRenderer

  • Viewport scenes can now renderOnTop to render after global queue
  • depth is no longer disabled
  • config.fbo is removed, implement in your app instead
  • renderScissoris deprecated

PerspectiveScrollScene

  • Uses createPortal instead of nested scene and all of its problems (sweet!)
  • New prop renderOnTop to render after global render

v1.0:

GlobalCanvas

  • WebGL 2.0 by default
  • resizeOnHeight added to GlobalCanvas (default true)

ScrollScene

  • live flag is now called updateLayout
  • getOffset -> layoutOffset
  • scene prop passed to children is no longer a ref

PerspectiveScrollScene

  • Uses createPortal instead of nested scene and all of its problems (sweet!)

GlobalRenderer

  • colorManagement=true + gl.toneMapping = NoToneMapping to match hex with DOM

ResizeManager

  • resizeOnWebFontLoaded added to ResizeManager