---
title: useScrollTurn
description: Scroll position as a rotation, delivered once per frame, written straight to wherever it goes.
source: https://adamjurek.com/components/use-scroll-turn
---

## Home


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

<!-- ::start:showcase demo="use-scroll-turn" height="420" -->
<!-- ::end:showcase -->

## Never through state

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

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.

> [!IMPORTANT] 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

| Where | Writes to |
| --- | --- |
| `ScrollSpin` | a CSS transform on a wrapper element |
| `apps/web/src/modules/chrome/logo-model.tsx` | `rotation.y` on the hero's GLB |


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/use-scroll-turn.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/use-scroll-turn.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | motion · Scroll effects |
| Files | `use-scroll-turn.ts` |
| Dependencies | None |
| Tags | scroll, hook, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { useCallback, useRef } from "react";
import { useScrollTurn, type ScrollTurn } from "@sushindustries/ui";

export function SpinningMark({ children }: { children: React.ReactNode }) {
	const ref = useRef<HTMLDivElement>(null);

	const apply = useCallback(({ turn, wobble }: ScrollTurn) => {
		const node = ref.current;
		if (!node) return;
		node.style.transform = `rotateX(${wobble}deg) rotateY(${turn * 360}deg)`;
	}, []);

	useScrollTurn(apply);

	return <div ref={ref}>{children}</div>;
}
```

This is `ScrollSpin`'s own implementation - the hook alone, without the
wrapping component.

## What you should see

Nothing while the page is at the top. Scroll down two viewport heights (the
default `revolutions`) and the element completes one full rotation, with a
slight wobble on the X axis from the default `tilt`. Scroll back up and it
turns back the same way - the rotation is a direct function of `scrollY`,
never a triggered animation.

## If nothing happens

The callback must be memoised with `useCallback`. An inline arrow function
is a new value on every render, which is a dependency of the internal
effect, so the scroll listener is torn down and rebuilt constantly - on a
page that re-renders for unrelated reasons, the rotation can stall or jump.


## Guides


## Why it is a hook and not just part of `ScrollSpin`

`ScrollSpin` writes a CSS transform. The hero on the home page writes a
three.js object's rotation, because a CSS `rotateY` on a canvas spins the
rendered image like a photograph rather than turning the model inside it.

```ts
// ScrollSpin: a transform, straight onto the node.
useScrollTurn(({ turn, wobble }) => {
	node.style.transform = `rotateX(${wobble}deg) rotateY(${turn * 360}deg)`;
});

// The 3D mark: a rotation, inside the scene.
useScrollTurn(({ turn }) => {
	group.rotation.y = turn * Math.PI * 2;
});
```

Two completely different write targets, one question: how far has this page
turned. Sharing the measurement means a screenful of scrolling turns the CSS
mark and the GLB by the same amount, which is the sort of agreement that
silently stops being true the moment it is written twice.

## Revolutions are viewport heights

`revolutions` is how many screens of scrolling make one full turn, not how many
pixels. In pixels a phone would spin four times over the same content a desktop
turns once, because the content is the same and the screen is not.

## Reduced motion

Under `prefers-reduced-motion: reduce` the callback fires once, at the current
position, and never again.

Once, rather than never: that leaves whatever it drives in a sensible still
pose rather than at zero, which matters because zero is a value nobody chose -
a model parked at rotation zero may be showing you its back.


## API


<!-- generated:api -->

## Signature

```ts
useScrollTurn(onTurn: (value: ScrollTurn) => void, { revolutions = 2, tilt = 8 }: ScrollTurnOptions = {}): void
```

Scroll position as a rotation, delivered once per frame. The callback runs inside `requestAnimationFrame` and is expected to write somewhere directly - a DOM node's transform, a three.js object's rotation. 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. A plain passive scroll listener rather than a Lenis subscription, so this works with or without smooth scrolling: when Lenis is mounted it is already driving the native scroll position, so `scrollY` is the smoothed value either way, and when it is not this still works. Under `prefers-reduced-motion: reduce` the callback fires once, at the current position, and never again. That leaves whatever it drives in a sensible still state rather than at zero, which matters when zero is not a pose anyone chose.

<!-- /generated:api -->

## Notes

`onTurn` is an effect dependency along with `revolutions` and `tilt` - wrap
it in `useCallback`, or the scroll listener is torn down and rebuilt on
every render of whatever calls this hook.

`tilt: 0` is a legitimate way to ask for a flat turntable with no wobble at
all; `wobble` is still computed and passed every frame, it is simply always
zero. There is no way to get `turn` without `wobble` in the callback shape -
read only the field that matters and ignore the other.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="use-scroll-turn" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

The home page's hero mark turns a three.js group's rotation directly,
rather than a CSS transform - the same measurement `ScrollSpin` uses, aimed
at a different write target:

```tsx
import { useCallback, useRef } from "react";
import { useScrollTurn } from "@sushindustries/ui";
import type { Group } from "three";

export function LogoModel() {
	const modelRef = useRef<Group>(null);

	const turn = useCallback(({ turn, wobble }: { turn: number; wobble: number }) => {
		const group = modelRef.current;
		if (!group) return;
		group.rotation.y = turn * Math.PI * 2;
		group.rotation.x = (wobble * Math.PI) / 180;
	}, []);

	useScrollTurn(turn, { revolutions: 3, tilt: 6 });

	return <ProductViewer model={LOGO_MODEL} modelRef={modelRef} transparent />;
}
```

A CSS `rotateY` on the canvas would spin the rendered image like a
photograph rather than turning the model inside it - which is why this
writes into the scene instead of onto the DOM node, unlike `ScrollSpin`.

## What this example is not

`ProductViewer` and `modelRef` are doing the three.js setup here; this hook
supplies only the `turn` and `wobble` numbers each frame. It has no opinion
about scenes, cameras or renderers.
