Skip to content

fix: pause animation when shader element leaves the viewport#268

Merged
uuuulala merged 5 commits into
paper-design:mainfrom
kimiaShahbaghi:fix/pause-off-screen
Jul 15, 2026
Merged

fix: pause animation when shader element leaves the viewport#268
uuuulala merged 5 commits into
paper-design:mainfrom
kimiaShahbaghi:fix/pause-off-screen

Conversation

@kimiaShahbaghi

@kimiaShahbaghi kimiaShahbaghi commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

What and why

Animated shaders ran their requestAnimationFrame loop continuously, even
when the element was completely scrolled out of view. On a page with a
MeshGradient hero section, for example, scrolling down to content meant
the shader kept burning GPU cycles and battery the whole time — the user
could never escape it short of switching tabs.

The library already paused shaders when the browser tab was hidden
(visibilitychange). This PR extends that same pattern one step further:
also pause when the element itself is not visible in the viewport.

Closes #188.

How it works

A single file changed: packages/shaders/src/shader-mount.ts.

An IntersectionObserver is set up in the constructor alongside the existing
ResizeObserver. When the element leaves the viewport it calls
setCurrentSpeed(0) — the same codepath already used by the tab-visibility
pause. When the element re-enters the viewport it calls
setCurrentSpeed(this.speed) and the animation resumes from exactly where
it left off (currentFrame is preserved, so there's no visual jump).

setSpeed() and handleDocumentVisibilityChange() are updated to check both
conditions together:

```ts
this.setCurrentSpeed(this.ownerDocument.hidden || !this.isInViewport ? 0 : this.speed);
```

dispose() disconnects the observer to avoid memory leaks.

Behaviour summary

Scenario Before After
Animated shader scrolled off-screen RAF runs RAF paused
Scrolled back into view N/A Resumes from same frame
Tab hidden RAF paused RAF paused (unchanged)
Static shader (speed=0) No RAF No RAF (unchanged)
setFrame() scrubbing off-screen Works Works (calls render() directly)
SSR Works Works (typeof IntersectionObserver guard)

Why this approach

  • Zero API surface change — no new props, no opt-in. All consumers benefit
    automatically, same as the existing visibilitychange pause.
  • Zero bundle cost — native browser API, no polyfill needed. Supported in
    every browser that supports WebGL2.
  • Zero perf cost while in view — IntersectionObserver fires only on
    crossing events, not on every frame.
  • Follows the existing pattern exactly — the visibilitychange pause is
    already unconditional; this is a direct extension of that design decision.

Note

Low Risk
Single-file behavior change in the render loop; mirrors existing tab-visibility pause and uses a standard browser API with a no-op when unavailable.

Overview
Animated shaders no longer keep their requestAnimationFrame loop running when the mount element is scrolled off-screen. ShaderMount now watches intersection with the viewport (using the element’s own window for iframe/PiP) and pauses the same way as when the tab is hidden.

setSpeed, visibilitychange, and the new observer all funnel through updateCurrentSpeed, which sets effective speed to 0 when the document is hidden or the element is not intersecting, otherwise restores this.speed. currentFrame is unchanged, so animation resumes without a time jump. The observer is disconnected in dispose. No new public API.

Reviewed by Cursor Bugbot for commit 6f67953. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Jun 21, 2026

Copy link
Copy Markdown

@kimiaShahbaghi is attempting to deploy a commit to the Paper Design Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
shaders-docs Ready Ready Preview, Comment Jul 15, 2026 12:12pm

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 23f6ab7. Configure here.

private parentDevicePixelHeight = 0;
private devicePixelsSupported = false;

private intersectionObserver: IntersectionObserver | null = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Viewport default allows pre-check animation

Medium Severity

Shaders mounted off-screen animate unnecessarily until the IntersectionObserver's first asynchronous callback. This is because isInViewport defaults to true when setSpeed is called during initialization.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 23f6ab7. Configure here.

@uuuulala
uuuulala merged commit 4d78ae9 into paper-design:main Jul 15, 2026
3 checks passed
@uuuulala

Copy link
Copy Markdown
Member

Hey @kimiaShahbaghi
Thank you very much for this contribution!!
I'm merging it to main, it will be available in the next version of npm package

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mesh Gradient performance

2 participants