Agents (llms.txt)
Octocat

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, and onUpdate
  • Axis control: Respond to horizontal or vertical scroll

Anatomy

          <MotionScroll keyframes={{ opacity: [0, 1] }}>{/* content */}</MotionScroll>
        

API Reference

PropDefaultTypeDescription
keyframes *-DOMKeyframesDefinitionThe 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"EasingEasing function applied to the animation.
axis"y""x" | "y"Which scroll axis to track.
trigger-(element: HTMLElement) => ElementReturns the element whose scroll position drives the animation. Defaults to the component's own element.
scroller-(element: HTMLElement) => ElementReturns 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.
touchscreenfalsebooleanEnable animations on touch devices.
asChild-booleanMerge 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 transform and opacity, not layout properties: properties like width, height, top, and left trigger layout recalculation on every scroll event. Stick to x, y, scale, rotate, and opacity — they run on the GPU and never cause reflow.

  • Match ease to 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 like easeOut so the motion feels intentional rather than mechanical.

  • Leave touchscreen off 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