motion-solid

Scroll animations

Trigger animations when elements enter the viewport, or link them to scroll progress.

There are two kinds of scroll animations:

  • Scroll-triggered: an animation starts when an element enters or leaves the viewport. It runs on its own once started, like any other animation.
  • Scroll-linked: a value follows the scroll position directly. Scrolling back reverses it.

Scroll-triggered animations

whileInView

A motion component animates to the values of whileInView while it's in the viewport, and back to animate (or its initial values) when it leaves:

<motion.div initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} />

The demo below scrolls inside its own box, so it passes the box as the viewport's root:

Scroll down

1Plan
2Design
3Build
4Test
5Ship
6Repeat

Viewport options

viewport configures how the element is observed:

<motion.section
    initial={{ opacity: 0, y: 40 }}
    whileInView={{ opacity: 1, y: 0 }}
    viewport={{ once: true, amount: 0.5, margin: "0px 0px -100px 0px" }}
/>
OptionTypeDefaultDescription
oncebooleanfalseRun whileInView the first time the element enters, and keep its values after it leaves.
amount"some" | "all" | number"some"How much of the element must be visible: any part, all of it, or a fraction from 0 to 1.
marginstringGrows or shrinks the viewport's box, in CSS margin syntax ("0px 0px -100px 0px"). Like IntersectionObserver's rootMargin.
root{ current: Element | null }viewportThe scrollable ancestor to observe the element in, instead of the browser viewport.

root is a { current } object, read when the component mounts. Set it from the container's ref:

const root = { current: null as HTMLElement | null }

return (
    <div ref={(element) => (root.current = element)} class="overflow-y-auto">
        <motion.div whileInView={{ opacity: 1 }} viewport={{ root }} />
    </div>
)

Viewport events

onViewportEnter and onViewportLeave are called with the IntersectionObserverEntry when the element enters or leaves the viewport. With once, onViewportLeave isn't called once the element has entered.

<motion.div
    onViewportEnter={(entry) => console.log("entered", entry?.target)}
    onViewportLeave={() => console.log("left")}
/>

useInView

To react to visibility in your own code (load data, start a video, change text), use useInView. It returns an accessor of whether an element is in view:

let element!: HTMLDivElement
const isInView = useInView(() => element, { once: true })

return <div ref={element}>{isInView() ? "Visible" : "Hidden"}</div>

Scroll-linked animations

useScroll

useScroll returns four motion values: the scroll offset in pixels (scrollX, scrollY) and the scroll progress from 0 to 1 (scrollXProgress, scrollYProgress). Pass one to a style prop and the element follows the scroll every frame:

const { scrollYProgress } = useScroll()

return <motion.div class="origin-left" style={{ scaleX: scrollYProgress }} />

Without options, useScroll tracks the page. To track a scrollable element, pass it as container. Here it's a { current } object set by the container's ref. useSpring smooths the progress:

Reading progress

The bar above this text fills up as you scroll. Its width is a scale, driven by the scroll progress of this box.

useScroll returns motion values. They update outside of Solid's reactivity, so the bar changes every frame without running any component code.

useSpring follows the progress with a spring. The bar catches up with a short delay, which smooths out fast scrolling.

The box you are scrolling is not the page. It is passed to useScroll as its container, through a signal set by its ref.

Scroll back up and the bar shrinks again. Progress goes from 0 at the top to 1 at the bottom.

Transforming progress

Progress goes from 0 to 1. Map it to other values with useTransform:

const { scrollYProgress } = useScroll()
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0])
const backgroundColor = useTransform(
    scrollYProgress,
    [0, 1],
    ["#4f6bff", "#ff4f8b"]
)

Tracking an element

With a target, progress measures how far the target has moved through the container (the viewport by default). offset sets where it starts and ends. Each entry is a pair of positions: one on the target, then one on the container. "start end" means "the target's start meets the container's end".

const { scrollYProgress } = useScroll({
    target,
    // 0 when the target's top reaches the bottom of the viewport,
    // 1 when its bottom leaves through the top.
    offset: ["start end", "end start"],
})

Without offset, progress runs from "start start" to "end end". The useScroll reference lists every position you can use. When the container isn't the page, give it a position other than static (the demo below uses relative): the target's position is measured from it.

Parallax

Each section below tracks its own position in the scroll box, and moves its shape against the scroll direction, so the shape scrolls slower than its section:

01
02
03
04

Horizontal scrolling

scrollX and scrollXProgress track horizontal scrolling. Both axes are always tracked, so use the pair that matches the direction your container scrolls in. See the horizontal example.

The scroll function

useScroll is built on scroll, which you can also call yourself. It takes a callback, which receives the progress (0 to 1) on every scroll, or an animation, which it plays along with the scroll. It returns a function that stops tracking:

import { animate, scroll } from "@motion-s/solid"
import { onSettled } from "solid-js"

function ProgressBar() {
    let container!: HTMLDivElement
    let bar!: HTMLDivElement

    onSettled(() => {
        const animation = animate(bar, { scaleX: [0, 1] }, { ease: "linear" })
        return scroll(animation, { container })
    })

    return (
        <div ref={container} class="h-64 overflow-y-auto">
            <div ref={bar} class="sticky top-0 h-1 origin-left bg-accent" />
            {/* content */}
        </div>
    )
}

It accepts container, target, offset and axis ("y" by default; the axis whose progress is passed to the callback or drives the animation). It's also exported from @motion-s/solid/dom, for use outside of components.