useScrollTurn

@sushindustries/ui

Scroll position as a rotation, delivered once per frame, written straight to wherever it goes.

6 min read
View as MarkdownEdit on GitHub

The measurement behind ScrollSpin, on its own, because the same numbers have to drive two things that share no code.

Viewport width
Phone22remthe floor, the narrowest this promises to work at
tanstacktanstack add https://adamjurek.com/r/tanstack/use-scroll-turn.jsonshadcnpnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/use-scroll-turn.json

Never through state

useScrollTurn(({ turn, wobble }) => {
	node.style.transform = `rotateY(${turn * 360}deg)`;
});
tsx

The callback runs inside requestAnimationFrame and is expected to write somewhere directly. Nothing here holds state, because at 60fps a state-driven version re-renders its subtree on every frame of every scroll, which is the one reliable way to make a light page feel heavy.

The same reasoning is why ProductViewer takes a modelRef rather than a rotation prop: a prop would re-render the whole viewer sixty times a second to change one float React has no reason to know about.

Wrap the callback in `useCallback`

It is a dependency of the effect. An inline arrow function is a new function on every render, so the listener is torn down and rebuilt every time anything above it changes.

Where this is used

WhereWrites to
ScrollSpina CSS transform on a wrapper element
apps/web/src/modules/chrome/logo-model.tsxrotation.y on the hero's GLB

Install

tanstack add https://adamjurek.com/r/tanstack/use-scroll-turn.json
shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/use-scroll-turn.json
shell
pnpm add @sushindustries/ui @sushindustries/atoms
shell

What you get

Version0.1.0
Categorymotion · Scroll effects
Filesuse-scroll-turn.ts
DependenciesNone
Tagsscroll, hook, no-deps

No runtime dependencies

It brings nothing with it beyond the stylesheet.