Motion Scroll
A component that animates its children based on scroll position using the Motion library's scroll and animate APIs.
import type { PreviewMeta } from "@/lib/preview";
import { MotionScroll } from "../motion-scroll";
const cards = [
{
title: "Foundations",
description: "A curated set of primitives for building consistent, accessible interfaces.",
},
{
title: "Motion",
description: "Scroll-driven animations that respond naturally to user interaction.",
},
{
title: "Composable",
description: "Every component is designed to be combined and extended without friction.",
},
];
const MotionScrollPreview = () => {
return (
<div className="flex flex-col items-center gap-32 px-8 pt-[50vh] pb-[50vh]">
{cards.map((card, index) => (
<MotionScroll
key={index}
keyframes={{ opacity: [0, 0, 1], y: ["33vh", 0] }}
offset={["start end", "center center"]}
ease="easeOut"
className="w-full max-w-md rounded-xl border border-border bg-background-secondary p-8"
>
<p className="font-mono text-foreground-secondary text-xs uppercase tracking-widest">
0{index + 1}
</p>
<h2 className="mt-2 font-semibold text-xl">{card.title}</h2>
<p className="mt-1 text-foreground-secondary text-sm">{card.description}</p>
</MotionScroll>
))}
</div>
);
};
export const meta = {
layout: "fullscreen",
mode: "iframe",
} satisfies PreviewMeta;
export default MotionScrollPreview;Dependencies
Source Code
import {
type DOMKeyframesDefinition,
type Easing,
scroll,
type UseScrollOptions,
useAnimate,
useReducedMotion,
} from "motion/react";
import { type ComponentPropsWithRef, useEffect, useRef } from "react";
import { Slot } from "@/components/slot";
import { useMatchMedia } from "@/foundations/hooks/use-match-media";
import { composeRefs } from "@/lib/compose-refs";
type MotionScrollProps = ComponentPropsWithRef<"div"> & {
keyframes: DOMKeyframesDefinition;
hooks?: {
onStart?: () => void;
onComplete?: () => void;
onUpdate?: (progress: number) => void;
};
offset?: UseScrollOptions["offset"];
ease?: Easing;
scroller?: (element: HTMLElement) => Element;
trigger?: (element: HTMLElement) => Element;
asChild?: boolean;
axis?: "x" | "y";
touchscreen?: boolean;
};
/**
* A component that animates its children based on scroll position.
* Extends the `motion` library's `scroll` and `animate` API.
*/
const MotionScroll = ({
keyframes,
hooks = {},
offset = ["start end", "end start"],
ease = "linear",
axis = "y",
scroller,
trigger,
asChild,
ref: propRef,
children,
touchscreen = false,
...rest
}: MotionScrollProps) => {
const [scope, animate] = useAnimate();
const isReducedMotion = useReducedMotion();
const isTouchscreen = useMatchMedia("(pointer: coarse)", true);
const hooksRef = useRef(hooks);
hooksRef.current = hooks;
useEffect(() => {
const element = scope.current;
if (!element) return;
if (!touchscreen && isTouchscreen) return;
if (isReducedMotion) return;
if (Object.keys(keyframes).length === 0) return;
const scrollOptions = {
target: trigger ? trigger(element) : element,
container: scroller ? scroller(element) : undefined,
offset,
axis,
};
const animation = animate(element, keyframes, { autoplay: false, ease });
const destroyScrollAnimation = scroll(animation, scrollOptions);
let previousProgress: number | null = null;
const destroyScrollProgress = scroll((progress: number) => {
if (progress === previousProgress) return;
if ((previousProgress === null || previousProgress === 0) && progress > 0) {
hooksRef.current.onStart?.();
}
if ((previousProgress ?? 0) < 1 && progress === 1) {
hooksRef.current.onComplete?.();
}
hooksRef.current.onUpdate?.(progress);
previousProgress = progress;
}, scrollOptions);
return () => {
animation.cancel();
destroyScrollAnimation();
destroyScrollProgress();
};
}, [
scope,
animate,
ease,
keyframes,
isTouchscreen,
axis,
offset,
scroller,
trigger,
touchscreen,
isReducedMotion,
]);
const Component = asChild ? Slot : "div";
return (
<Component ref={composeRefs(scope, propRef)} {...rest}>
{children}
</Component>
);
};
export { MotionScroll };Features
- Scroll-driven animations: Ties any CSS property animation directly to scroll progress — no timers, no manual listeners
- Reduced motion support: Automatically skips animations when the user has enabled reduced motion
- Touchscreen opt-out: By default, animations are disabled on touch devices; opt in with
touchscreen - Custom trigger and container: Track a different element, or use a custom scroll container
- Lifecycle hooks: React to scroll milestones with
onStart,onComplete, andonUpdate - Axis control: Respond to horizontal or vertical scroll
Anatomy
<MotionScroll keyframes={{ opacity: [0, 1] }}>{/* content */}</MotionScroll>
API Reference
| Prop | Default | Type | Description |
|---|---|---|---|
keyframes * | - | DOMKeyframesDefinition | The animation keyframes to apply as scroll progresses. |
offset | ["start end", "end start"] | UseScrollOptions["offset"] | Scroll offset range that maps to the animation's start and end. |
ease | "linear" | Easing | Easing function applied to the animation. |
axis | "y" | "x" | "y" | Which scroll axis to track. |
trigger | - | (element: HTMLElement) => Element | Returns the element whose scroll position drives the animation. Defaults to the component's own element. |
scroller | - | (element: HTMLElement) => Element | Returns the scroll container to observe. Defaults to the nearest scrollable ancestor. |
hooks | - | { onStart?: () => void; onComplete?: () => void; onUpdate?: (progress: number) => void } | Callbacks fired at scroll milestones. |
touchscreen | false | boolean | Enable animations on touch devices. |
asChild | - | boolean | Merge props onto the child element instead of rendering a wrapper div. |
By default, animations are skipped on touch devices to avoid conflicts with native scroll momentum. Set touchscreen to opt in when the animation is explicitly designed for touch contexts.
Examples
Fade In
Elements animate in as they enter the viewport.
import type { PreviewMeta } from "@/lib/preview";
import { MotionScroll } from "../motion-scroll";
const cards = [
{
title: "Foundations",
description: "A curated set of primitives for building consistent, accessible interfaces.",
},
{
title: "Motion",
description: "Scroll-driven animations that respond naturally to user interaction.",
},
{
title: "Composable",
description: "Every component is designed to be combined and extended without friction.",
},
];
const MotionScrollPreview = () => {
return (
<div className="flex flex-col items-center gap-32 px-8 pt-[50vh] pb-[50vh]">
{cards.map((card, index) => (
<MotionScroll
key={index}
keyframes={{ opacity: [0, 0, 1], y: ["33vh", 0] }}
offset={["start end", "center center"]}
ease="easeOut"
className="w-full max-w-md rounded-xl border border-border bg-background-secondary p-8"
>
<p className="font-mono text-foreground-secondary text-xs uppercase tracking-widest">
0{index + 1}
</p>
<h2 className="mt-2 font-semibold text-xl">{card.title}</h2>
<p className="mt-1 text-foreground-secondary text-sm">{card.description}</p>
</MotionScroll>
))}
</div>
);
};
export const meta = {
layout: "fullscreen",
mode: "iframe",
} satisfies PreviewMeta;
export default MotionScrollPreview;Parallax
Animate an inner element at a different rate than the scroll container to create depth.
import type { PreviewMeta } from "@/lib/preview";
import { MotionScroll } from "../motion-scroll";
const sections = [
{ label: "Design", bg: "bg-accent/10" },
{ label: "Build", bg: "bg-background-secondary" },
{ label: "Ship", bg: "bg-accent/5" },
];
const MotionScrollParallax = () => {
return (
<div className="flex justify-around px-12 pt-[30vh] pb-[30vh]">
{sections.map((_, index) => (
<MotionScroll
key={index}
keyframes={{ y: [60 * index, -60 * index] }}
offset={["start end", "end start"]}
className="my-[50vh] flex size-40 items-center justify-center rounded-lg border border-border bg-background-secondary text-foreground-secondary text-sm"
>
{index + 1}x
</MotionScroll>
))}
</div>
);
};
export const meta = {
layout: "fullscreen",
mode: "iframe",
} satisfies PreviewMeta;
export default MotionScrollParallax;Horizontal Scroll
Use axis="x" with a custom scroller to drive animations from a horizontal scroll container.
import { useMousePan } from "@/foundations/hooks/use-mouse-pan";
import type { PreviewMeta } from "@/lib/preview";
import { MotionScroll } from "../motion-scroll";
const items = [
{ index: "01", label: "Design" },
{ index: "02", label: "Prototype" },
{ index: "03", label: "Build" },
{ index: "04", label: "Test" },
{ index: "05", label: "Ship" },
{ index: "06", label: "Iterate" },
];
const MotionScrollHorizontal = () => {
const { ref } = useMousePan<HTMLUListElement>();
return (
<div className="flex h-screen items-center">
<ul
ref={ref}
className="flex h-full w-full cursor-grab select-none snap-x snap-mandatory items-center gap-12 overflow-x-auto px-[40vw] py-12 active:cursor-grabbing"
>
{items.map((item) => (
<MotionScroll
key={item.index}
asChild
axis="x"
keyframes={{ opacity: [0, 1, 0], scale: [0.85, 1, 0.85], rotate: [20, 0, -20] }}
offset={["0 1", "1 0"]}
scroller={(el) => el.parentElement as Element}
>
<li className="flex size-40 shrink-0 snap-center flex-col justify-between rounded-xl border border-border bg-background-secondary p-5">
<span className="font-mono text-foreground-secondary text-xs">{item.index}</span>
<span className="font-semibold text-xl">{item.label}</span>
</li>
</MotionScroll>
))}
</ul>
</div>
);
};
export const meta = {
layout: "fullscreen",
mode: "iframe",
} satisfies PreviewMeta;
export default MotionScrollHorizontal;Best Practices
-
Animate
transformandopacity, not layout properties: properties likewidth,height,top, andlefttrigger layout recalculation on every scroll event. Stick tox,y,scale,rotate, andopacity— they run on the GPU and never cause reflow. -
Match
easeto the animation intent:linear(the default) is correct for effects that should map 1:1 to scroll position, like parallax. For reveal animations — where the element animates in once and stays — use an easing curve likeeaseOutso the motion feels intentional rather than mechanical. -
Leave
touchscreenoff unless the animation is built for it: the default exists to avoid fighting native scroll momentum on touch devices. Only enable it when the animation is lightweight and won’t interfere with the user’s scroll intent.
Previous
Marquee
Next
Sequence