75 / Scenes
Scroll Scene
Turns the scrolling of a tall element into a number between nought and one, published to a custom property and to a callback, without rendering the page to do it.
0 percent
"use client"
import * as React from "react"
import { ScrollScene } from "mischief-ui/scroll-scene"
export function ScrollSceneDemo() {
const box = React.useRef<HTMLDivElement>(null)
const readout = React.useRef<HTMLParagraphElement>(null)
return (
<div className="border-border h-72 w-full max-w-md overflow-y-auto rounded-[var(--radius)] border">
<div className="text-muted-foreground grid h-40 place-items-center text-xs">
Scroll down inside this panel
</div>
<ScrollScene
className="h-[36rem]"
onProgress={(progress) => {
// Written straight to the nodes. A scene driven by scrolling must
// not render the page sixty times a second.
if (box.current) {
box.current.style.transform = `rotate(${progress * 180}deg) scale(${0.6 + progress * 0.6})`
}
if (readout.current) {
readout.current.textContent = `${Math.round(progress * 100)} percent`
}
}}
>
<div className="sticky top-0 grid h-72 place-content-center justify-items-center gap-4">
<div
ref={box}
className="bg-primary size-24 rounded-[var(--radius)]"
/>
<p
ref={readout}
className="text-muted-foreground text-xs tabular-nums"
>
0 percent
</p>
</div>
</ScrollScene>
<div className="text-muted-foreground grid h-40 place-items-center text-xs">
And back up again
</div>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-sceneimport { ScrollScene } from "mischief-ui/scroll-scene"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { cn } from "@/lib/utils" export type ScrollRange = "cover" | "contain" | "enter" | "exit" export type ScrollSceneProps = Omit< React.HTMLAttributes<HTMLDivElement>, "onProgress"> & { /** * Which span of scrolling maps to nought through one. Cover runs from the * moment the element appears to the moment it has gone.Usage
export function Scrubbed() {
const box = React.useRef(null)
return (
<ScrollScene className="h-[200vh]" sticky onProgress={(p) => {
if (box.current) box.current.style.opacity = String(p)
}}>
<div ref={box}>Held while the scene goes past</div>
</ScrollScene>
)
}Why this is not a piece of state
The obvious shape for this component would be a hook returning a number. That number changes on every frame, so every frame would render the tree under it, and a scroll-linked effect built that way stutters on any machine that is also doing something else.
So progress is published twice, and neither way renders anything. It is written to the element as the --scroll-progress custom property, which CSS can use directly, and it is handed to onProgress, which a canvas or a ref can use directly.
.parallax {
translate: 0 calc(var(--scroll-progress) * -80px);
opacity: var(--scroll-progress);
}Choosing a range
- cover: nought when the element first appears at the bottom, one when it has completely gone past the top. The longest span, and the usual choice for a backdrop.
- contain: the span during which the element is fully inside the viewport. Right for something that should finish while it is still being looked at.
- enter: nought to one across the arrival alone.
- exit: nought to one across the departure alone.
The loop runs only while the element is on screen, and takes one final reading on the way out so a scene left behind holds an end value rather than whatever it happened to have.
Motion that is not the page's idea
This keeps working when someone has asked for reduced motion, and that is deliberate. The movement here is the reader's own: it happens because they are scrolling, it stops when they stop, and it reverses when they go back. That is direct manipulation rather than something the page decided to do at them.
What you drive with it is a different matter. If the progress is running an animation that would be uncomfortable, check the preference where you use it rather than expecting this component to guess.
API
range"cover" | "contain" | "enter" | "exit"Which span of scrolling maps to nought through one. Defaults to "cover".onProgress(progress: number) => voidCalled on every frame the element is on screen.stickybooleanPins the children to the viewport while the scene scrolls past them.Accessibility
The scene is an ordinary element and adds nothing to the accessibility tree. Content inside it is normal markup and is reached in normal order, including when sticky is on. Nothing here is required to read the page, so a reader who never scrolls it past has lost nothing.