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
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add split-screen-scrollWrites 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.
1import { SplitScreenScroll } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <SplitScreenScroll />6 );7}Props
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
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}