ScrollScroll Animationsno dependencies

SplitScreenScroll

A pinned two-column story inside its own smooth-scroll viewport: as you scroll, the text column travels up while the gradient visual column travels down, so each heading meets its paired visual mid-screen, with a progress divider and counter between them.

Preview

  1. 01Discovery

    Capture the idea

    Sketch fast, keep everything. Rough notes become the first frame of the story.

  2. 02Design

    Shape the system

    Tokens, grids and type scales turn scattered screens into one coherent language.

  3. 03Motion

    Prototype in motion

    Real interactions, real timing. Feel the flow long before a line of production code.

  4. 04Engineering

    Ship with confidence

    Accessible, tested and measured. Releases become routine instead of events.

  5. 05Insights

    Learn and iterate

    Watch how people use it, then fold every insight back into the next cycle.

Customize

8 props
0.1
1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add split-screen-scroll

Writes components/ui/split-screen-scroll.tsx and components/styles/split-screen-scroll.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { SplitScreenScroll } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <SplitScreenScroll />6  );7}

Props

PropTypeDefault
items

Panel titles; each item pairs a text panel with a gradient visual panel.

string[]['Capture the idea', 'Shape the system', 'Prototype in motion', 'Ship with confidence', 'Learn and iterate']
palette

Colors cycled through the visual panels; each panel blends its color into the next.

string[]['#f472b6', '#a78bfa', '#38bdf8', '#34d399', '#fbbf24']
accentColor

Accent color for panel numbers and the divider progress fill.

string'#f472b6'
reverse

Swap directions so the text column moves down and the visual column moves up.

booleanfalse
showCounter

Show the center progress divider and the 01 / 05 counter.

booleantrue
height

Height of the component's own scroll viewport in px (width is always 100%).

number380
borderRadius

Corner radius of the viewport in px.

number20
lerp

Interpolation factor per 60fps frame for wheel smoothing (0.05 floaty, 0.3 snappy).

number0.1
wheelMultiplier

Multiplier applied to wheel and trackpad deltas.

number1
smooth

Enable smooth wheel scrolling; false (or prefers-reduced-motion) uses native scrolling.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

The viewport is a focusable role="region" with an aria-label, so keyboard users can Tab to it and scroll with arrow/Page keys/Space (native, unsmoothed) with a 2px :focus-visible outline. Wheel input is smoothed only while the viewport can still move; at the edges wheel events are released to the page, and touch and scrollbar dragging stay native. Text panels are an ordered list of real headings and paragraphs, with aria-current="step" on the active panel; the visual column, divider and counter are decorative and aria-hidden. Under prefers-reduced-motion the wheel is not intercepted (columns follow native scrolling only).

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/split-screen-scroll.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/split-screen-scroll.module.css";5 6interface SmoothScrollState {7  /** Current scrollTop of the viewport in px */8  scroll: number;9  /** Maximum scrollTop in px */10  limit: number;11  /** scroll / limit, 0..1 */12  progress: number;13}14 15interface SmoothScrollOptions {16  lerp: number;17  wheelMultiplier: number;18  smooth: boolean;19  onScroll?: (state: SmoothScrollState) => void;20}21 22const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));23 24function usePrefersReducedMotion(): boolean {25  const [reduced, setReduced] = React.useState(false);26  React.useEffect(() => {27    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");28    const update = () => setReduced(mq.matches);29    update();30    mq.addEventListener("change", update);31    return () => mq.removeEventListener("change", update);32  }, []);33  return reduced;34}35 36/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */37function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {38  let el: Element | null = node instanceof Element ? node : null;39  while (el && el !== root) {40    if (el instanceof HTMLElement) {41      if (el.dataset.mfScrollPrevent !== undefined) return true;42      const oy = window.getComputedStyle(el).overflowY;43      if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) {44        if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true;45      }46    }47    el = el.parentElement;48  }49  return false;50}51 52/**53 * Self-contained smooth scroller for an overflow container: wheel input sets a target, a rAF54 * loop eases scrollTop toward it (frame-rate independent) and stops when settled. Touch,55 * keyboard and scrollbar stay native; at the edges wheel events are released to the page.56 */57function useSmoothScroll(58  containerRef: React.RefObject<HTMLElement | null>,59  { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,60) {61  const callbackRef = React.useRef(onScroll);62  React.useEffect(() => {63    callbackRef.current = onScroll;64  }, [onScroll]);65 66  React.useEffect(() => {67    const el = containerRef.current;68    if (!el) return;69    const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);70    const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;71    let target = el.scrollTop;72    let current = target;73    let raf = 0;74    let lastTime = 0;75    const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);76    const emit = () => {77      const limit = getLimit();78      const scroll = clamp(el.scrollTop, 0, limit);79      callbackRef.current?.({ scroll, limit, progress: limit > 0 ? scroll / limit : 0 });80    };81    const stop = () => {82      raf = 0;83      lastTime = 0;84    };85    const tick = (time: number) => {86      const frames = (lastTime ? clamp(time - lastTime, 1, 64) : 16.667) / 16.667;87      lastTime = time;88      target = clamp(target, 0, getLimit());89      if (Math.abs(el.scrollTop - current) > 2) {90        // Scrollbar drag, keyboard or touch moved the viewport: hand control back.91        current = target = el.scrollTop;92        stop();93        emit();94        return;95      }96      current += (target - current) * (1 - Math.pow(1 - ease, frames));97      if (Math.abs(target - current) < 0.35) current = target;98      el.scrollTop = current;99      emit();100      if (current === target) stop();101      else raf = requestAnimationFrame(tick);102    };103    const onWheel = (event: WheelEvent) => {104      if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;105      let dy = event.deltaY;106      if (event.deltaMode === 1) dy *= 16;107      else if (event.deltaMode === 2) dy *= el.clientHeight;108      dy *= multiplier;109      if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;110      const limit = getLimit();111      if (!raf) current = target = el.scrollTop;112      // Release to native scrolling at the edges so the page is never trapped.113      if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;114      event.preventDefault();115      target = clamp(target + dy, 0, limit);116      if (!raf) raf = requestAnimationFrame(tick);117    };118    const onNativeScroll = () => {119      if (raf) return;120      current = target = el.scrollTop;121      emit();122    };123    if (smooth) el.addEventListener("wheel", onWheel, { passive: false });124    el.addEventListener("scroll", onNativeScroll, { passive: true });125    const ro = new ResizeObserver(emit);126    ro.observe(el);127    if (el.firstElementChild) ro.observe(el.firstElementChild);128    emit();129    return () => {130      if (smooth) el.removeEventListener("wheel", onWheel);131      el.removeEventListener("scroll", onNativeScroll);132      ro.disconnect();133      if (raf) cancelAnimationFrame(raf);134    };135  }, [containerRef, lerp, wheelMultiplier, smooth]);136}137 138export interface SplitScreenScrollProps {139  /** Panel titles, one text panel paired with one visual panel per item */140  items?: string[];141  /** Gradient colors cycled through the visual panels */142  palette?: string[];143  /** Accent color for numbers, divider progress and counter */144  accentColor?: string;145  /** Swap directions: text column moves down and visual column moves up */146  reverse?: boolean;147  /** Show the progress divider and "01 / 05" counter in the middle */148  showCounter?: boolean;149  /** Viewport height in px */150  height?: number;151  /** Viewport corner radius in px */152  borderRadius?: number;153  /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */154  lerp?: number;155  /** Multiplier applied to wheel / trackpad deltas */156  wheelMultiplier?: number;157  /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */158  smooth?: boolean;159  /** Optional className merged onto the root */160  className?: string;161  /** Inline styles merged onto the root */162  style?: React.CSSProperties;163}164 165const DEFAULT_ITEMS = ["Capture the idea", "Shape the system", "Prototype in motion", "Ship with confidence", "Learn and iterate"];166const DEFAULT_PALETTE = ["#f472b6", "#a78bfa", "#38bdf8", "#34d399", "#fbbf24"];167const COPY = [168  "Sketch fast, keep everything. Rough notes become the first frame of the story.",169  "Tokens, grids and type scales turn scattered screens into one coherent language.",170  "Real interactions, real timing. Feel the flow long before a line of production code.",171  "Accessible, tested and measured. Releases become routine instead of events.",172  "Watch how people use it, then fold every insight back into the next cycle.",173];174const TAGS = ["Discovery", "Design", "Motion", "Engineering", "Insights"];175 176export function SplitScreenScroll({177  items = DEFAULT_ITEMS,178  palette = DEFAULT_PALETTE,179  accentColor = "#f472b6",180  reverse = false,181  showCounter = true,182  height = 380,183  borderRadius = 20,184  lerp = 0.1,185  wheelMultiplier = 1,186  smooth = true,187  className = "",188  style,189}: SplitScreenScrollProps) {190  const reduced = usePrefersReducedMotion();191  const viewportRef = React.useRef<HTMLDivElement>(null);192  const stageRef = React.useRef<HTMLDivElement>(null);193  const [active, setActive] = React.useState(0);194  const total = Math.max(1, items.length);195  const colors = palette.length > 0 ? palette : DEFAULT_PALETTE;196 197  const handleScroll = React.useCallback(198    ({ progress }: SmoothScrollState) => {199      stageRef.current?.style.setProperty("--mf-sss-p", progress.toFixed(5));200      setActive(clamp(Math.round(progress * (total - 1)), 0, total - 1));201    },202    [total],203  );204 205  useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });206 207  const order = items.map((_, i) => i);208  const textOrder = reverse ? [...order].reverse() : order;209  const visualOrder = reverse ? order : [...order].reverse();210 211  const vars = {212    "--mf-sss-h": `${height}px`,213    "--mf-sss-r": `${borderRadius}px`,214    "--mf-sss-accent": accentColor,215    "--mf-sss-n": total,216    ...style,217  } as React.CSSProperties;218 219  return (220    <div style={vars} className={`${styles.root} ${className}`.trim()}>221      <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label="Split screen story">222        <div className={styles.track} style={{ height: height * total }}>223          <div ref={stageRef} className={styles.stage}>224            <div className={styles.column}>225              <ol className={`${styles.strip} ${reverse ? styles.down : styles.up}`}>226                {textOrder.map((i) => (227                  <li key={`t-${i}`} className={styles.textPanel} aria-current={i === active ? "step" : undefined}>228                    <span className={styles.tag}>229                      <span className={styles.num}>{String(i + 1).padStart(2, "0")}</span>230                      {TAGS[i % TAGS.length]}231                    </span>232                    <h3 className={styles.title}>{items[i]}</h3>233                    <p className={styles.copy}>{COPY[i % COPY.length]}</p>234                  </li>235                ))}236              </ol>237            </div>238            <div className={styles.column} aria-hidden="true">239              <div className={`${styles.strip} ${reverse ? styles.up : styles.down}`}>240                {visualOrder.map((i) => {241                  const c1 = colors[i % colors.length];242                  const c2 = colors[(i + 1) % colors.length];243                  return (244                    <div245                      key={`v-${i}`}246                      className={styles.visualPanel}247                      style={{ "--mf-sss-c1": c1, "--mf-sss-c2": c2 } as React.CSSProperties}248                    >249                      <div className={styles.visual}>250                        <svg className={styles.art} viewBox="0 0 200 200" preserveAspectRatio="xMidYMid slice">251                          <circle cx={60 + (i % 3) * 40} cy={70 + (i % 2) * 30} r={46 + (i % 4) * 8} fill="rgba(255,255,255,0.2)" />252                          <rect x={100 - (i % 3) * 20} y={110} width="80" height="80" rx="18" fill="rgba(0,0,0,0.16)" transform={`rotate(${12 + i * 9} 140 150)`} />253                          <path d={`M0 ${150 - i * 6} C 60 ${120 + i * 4}, 120 ${180 - i * 5}, 200 ${140 + i * 3} L200 200 L0 200 Z`} fill="rgba(255,255,255,0.14)" />254                        </svg>255                        <span className={styles.bigNum}>{String(i + 1).padStart(2, "0")}</span>256                      </div>257                    </div>258                  );259                })}260              </div>261            </div>262            {showCounter && (263              <div className={styles.divider} aria-hidden="true">264                <span className={styles.rail}>265                  <span className={styles.fill} />266                </span>267                <span className={styles.counter}>{`${String(active + 1).padStart(2, "0")} / ${String(total).padStart(2, "0")}`}</span>268              </div>269            )}270          </div>271        </div>272      </div>273    </div>274  );275}