Skip to content
Back to skills

R3f Scroll Driven 3d

ASecurity

Connect GSAP ScrollTrigger to React Three Fiber — Zustand bridge, useFrame animation, scroll progress to 3D transforms. The pattern for scroll-driven 3D product showcases.

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 16, 2026
ai-agentsgoreactperformance

Security analysis

A100/100

Scanned October 16, 2026

npx -y skills add RaNDoM6913/claude-code-superkit --skill r3f-scroll-driven-3d --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of R3f Scroll Driven 3d?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for R3f Scroll Driven 3d
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/random6913-r3f-scroll-driven-3d-claude-code-superkit/badge)](https://www.skillsdirectory.com/skills/random6913-r3f-scroll-driven-3d-claude-code-superkit)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: r3f-scroll-driven-3d
description: Connect GSAP ScrollTrigger to React Three Fiber — Zustand bridge, useFrame animation, scroll progress to 3D transforms. The pattern for scroll-driven 3D product showcases.
tokens: 1677
---

# Scroll-Driven 3D with GSAP + R3F

## Purpose

GSAP ScrollTrigger runs in the DOM; R3F runs in WebGL — they cannot talk directly. Bridge them with a Zustand store: GSAP writes scroll progress, R3F reads it in `useFrame`.

```
[GSAP ScrollTrigger] → writes → [Zustand Store] → reads → [R3F useFrame]
       (DOM)                      (shared state)              (WebGL)
```

## Use when / Do not use

- **Use when:** scroll position drives a 3D scene — product showcases, scrollytelling, camera/model/material state tied to scroll progress.
- **Do not use:** DOM-only scroll animation (plain ScrollTrigger, no store needed) or one-shot 3D intros with no scroll link (animate directly in `useFrame`).

## Hard Rules

1. `scrub` MUST be a number (e.g. `scrub: 1`), never `true` — the number adds catch-up smoothing; `true` locks 1:1 to the wheel and looks janky in 3D.
2. Every ScrollTrigger in this pattern MUST set `invalidateOnRefresh: true` — start/end values recalculate on resize/refresh so the pinned section stays aligned.
3. When normalizing timeline progress to 0–1, MUST extend the timeline with `tl.set({}, {}, 1)` — without it the timeline ends at the last tween and `tl.call()` positions after that point never fire.
4. Inside `useFrame` (or any per-frame callback), read the store with `useScrollStore.getState()` — never the hook subscription, which re-renders React on every scroll tick.

## Why Zustand (Not React State/Context)?

- React state/context triggers re-renders on every scroll tick (60fps = 60 re-renders/sec)
- Zustand `getState()` reads directly without subscribing — zero re-renders
- `useFrame` already runs at 60fps — just read the latest value

## Workflow

### Step 1: Create the store

```tsx
// stores/useScrollStore.ts
import { create } from 'zustand';

interface ScrollStore {
  progress: number;          // 0-1 scroll progress
  currentTexture: string;    // active screen texture path
  setProgress: (p: number) => void;
  setTexture: (t: string) => void;
}

export const useScrollStore = create<ScrollStore>((set) => ({
  progress: 0,
  currentTexture: '/textures/screen-1.png',
  setProgress: (p) => set({ progress: p }),
  setTexture: (t) => set({ currentTexture: t }),
}));
```

### Step 2: GSAP writes to store

```tsx
// components/ScrollSection.tsx
import { useRef, useEffect } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useScrollStore } from '@/stores/useScrollStore';

gsap.registerPlugin(ScrollTrigger);

export function ScrollSection() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // getState(), not the hook — stable setters, no 60fps re-renders of this component
    const { setProgress, setTexture } = useScrollStore.getState();

    const ctx = gsap.context(() => {
      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: ref.current,
          start: 'top top',
          end: 'bottom bottom',
          scrub: 1,                    // Hard Rule 1: number, NOT true
          invalidateOnRefresh: true,   // Hard Rule 2: MUST have
          pin: true,
        },
      });

      // Animate progress 0 → 1
      tl.to({}, {
        duration: 1,
        onUpdate: function() {
          setProgress(this.progress());
        },
      });

      // Swap texture at 50% scroll
      tl.call(() => setTexture('/textures/screen-2.png'), [], 0.5);

      // Hard Rule 3: extend timeline to full duration — otherwise it ends at
      // the last tween and tl.call() positions after that never fire
      tl.set({}, {}, 1.0);

    }, ref);

    return () => ctx.revert();
  }, []);

  return <div ref={ref} style={{ height: '300vh' }} />;
}
```

### Step 3: R3F reads in useFrame

```tsx
// components/3d/PhoneModel.tsx
import { useFrame } from '@react-three/fiber';
import { useScrollStore } from '@/stores/useScrollStore';
import { useRef } from 'react';
import * as THREE from 'three';

export function PhoneModel() {
  const groupRef = useRef<THREE.Group>(null);

  useFrame(() => {
    if (!groupRef.current) return;

    // Hard Rule 4: getState() not hook — no re-renders
    const { progress } = useScrollStore.getState();

    // Rotate based on scroll
    groupRef.current.rotation.y = progress * Math.PI * 2;

    // Phase-based animation
    if (progress < 0.3) {
      // Entry: scale up
      const t = progress / 0.3;
      groupRef.current.scale.setScalar(0.5 + t * 0.5);
    } else if (progress < 0.7) {
      // Showcase: full size, rotate
      groupRef.current.scale.setScalar(1);
    } else {
      // Exit: scale down
      const t = (progress - 0.7) / 0.3;
      groupRef.current.scale.setScalar(1 - t * 0.3);
    }
  });

  return (
    <group ref={groupRef}>
      {/* Your 3D model here */}
    </group>
  );
}
```

## Performance Tips

- **Pre-allocate:** No `new THREE.Vector3()` inside useFrame — use refs
- **getState():** Always in useFrame, never hook subscription
- **DPR:** `dpr={[1, 2]}` — higher wastes mobile GPU
- **Dispose:** `dispose={null}` on reusable groups

## Dynamic Texture Swapping

Preload every texture up front (never load inside `useFrame`), then swap `material.map` when the store value changes. Step 2's `tl.call(...)` is what drives `currentTexture`.

```tsx
// components/3d/ScreenMaterial.tsx
import { useTexture } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import * as THREE from 'three';
import { useScrollStore } from '@/stores/useScrollStore';

export function ScreenMaterial() {
  const matRef = useRef<THREE.MeshBasicMaterial>(null);
  const [screen1, screen2] = useTexture([
    '/textures/screen-1.png',
    '/textures/screen-2.png',
  ]);
  const byPath: Record<string, THREE.Texture> = {
    '/textures/screen-1.png': screen1,
    '/textures/screen-2.png': screen2,
  };

  useFrame(() => {
    // Hard Rule 4: getState() not hook
    const { currentTexture } = useScrollStore.getState();
    const next = byPath[currentTexture];
    if (matRef.current && next && matRef.current.map !== next) {
      matRef.current.map = next;
      matRef.current.needsUpdate = true;
    }
  });

  // toneMapped=false: screen UI textures should not be tone-mapped
  return <meshBasicMaterial ref={matRef} map={screen1} toneMapped={false} />;
}
```

For correct color space on screen textures, see the `threejs-color-management` skill.

## Recap — non-negotiables

- `scrub: <number>`, never `scrub: true`.
- `invalidateOnRefresh: true` on every ScrollTrigger.
- `tl.set({}, {}, 1)` to extend the timeline to full duration when normalizing 0–1.
- `useScrollStore.getState()` inside `useFrame` and callbacks — never the hook subscription.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.