Chapter sections inside a smooth-scroll viewport where the whole background and text color interpolate between up to four colors as each section reaches the center, with automatic light/dark text for contrast and a tunable blend window.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add scroll-color-shiftWrites components/ui/scroll-color-shift.tsx and components/styles/scroll-color-shift.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ScrollColorShift } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ScrollColorShift />6 );7}Props
Accessibility
The viewport is a focusable role="region" ("Color sections") so keyboard users can Tab to it and scroll with arrows/Page keys/Space (native) with a 2px :focus-visible outline in the current text color. Each chapter is a <section> with a real heading. textMode="auto" picks near-black or near-white text from the background's luminance so contrast holds at every section; the chapter counter and ring decorations are aria-hidden. Wheel input is smoothed only while the viewport can still move and is released at the edges; touch and scrollbar dragging stay native. Under prefers-reduced-motion the wheel is not intercepted and colors switch at the midpoint between sections instead of fading continuously.
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/scroll-color-shift.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 const overflowY = window.getComputedStyle(el).overflowY;42 if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {43 if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true;44 }45 }46 el = el.parentElement;47 }48 return false;49}50 51/**52 * Self-contained smooth scroller for an overflow container: wheel input sets a target and a rAF53 * loop eases scrollTop toward it. Touch, keyboard and scrollbar stay native; wheel is released at54 * the edges so the page keeps scrolling.55 */56function useSmoothScroll(57 containerRef: React.RefObject<HTMLElement | null>,58 { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,59) {60 const callbackRef = React.useRef(onScroll);61 React.useEffect(() => {62 callbackRef.current = onScroll;63 }, [onScroll]);64 65 React.useEffect(() => {66 const el = containerRef.current;67 if (!el) return;68 const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);69 const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;70 let target = el.scrollTop;71 let current = target;72 let raf = 0;73 let lastTime = 0;74 const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);75 const emit = () => {76 const limit = getLimit();77 const scroll = clamp(el.scrollTop, 0, limit);78 callbackRef.current?.({ scroll, limit, progress: limit > 0 ? scroll / limit : 0 });79 };80 const tick = (time: number) => {81 const dt = lastTime ? Math.min(64, Math.max(1, time - lastTime)) : 16.667;82 lastTime = time;83 target = clamp(target, 0, getLimit());84 if (Math.abs(el.scrollTop - current) > 2) {85 current = target = el.scrollTop;86 raf = 0;87 lastTime = 0;88 emit();89 return;90 }91 current += (target - current) * (1 - Math.pow(1 - ease, dt / 16.667));92 if (Math.abs(target - current) < 0.35) current = target;93 el.scrollTop = current;94 emit();95 if (current === target) {96 raf = 0;97 lastTime = 0;98 return;99 }100 raf = requestAnimationFrame(tick);101 };102 const onWheel = (event: WheelEvent) => {103 if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;104 let dy = event.deltaY;105 if (event.deltaMode === 1) dy *= 16;106 else if (event.deltaMode === 2) dy *= el.clientHeight;107 dy *= multiplier;108 if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;109 const limit = getLimit();110 if (!raf) current = target = el.scrollTop;111 if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;112 event.preventDefault();113 target = clamp(target + dy, 0, limit);114 if (!raf) {115 lastTime = 0;116 raf = requestAnimationFrame(tick);117 }118 };119 const onNativeScroll = () => {120 if (raf) return;121 current = target = el.scrollTop;122 emit();123 };124 if (smooth) el.addEventListener("wheel", onWheel, { passive: false });125 el.addEventListener("scroll", onNativeScroll, { passive: true });126 const ro = new ResizeObserver(emit);127 ro.observe(el);128 if (el.firstElementChild) ro.observe(el.firstElementChild);129 emit();130 return () => {131 if (smooth) el.removeEventListener("wheel", onWheel);132 el.removeEventListener("scroll", onNativeScroll);133 ro.disconnect();134 if (raf) cancelAnimationFrame(raf);135 };136 }, [containerRef, lerp, wheelMultiplier, smooth]);137}138 139type RGB = [number, number, number];140 141function parseHex(hex: string, fallback: RGB): RGB {142 const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());143 if (!m) return fallback;144 const h = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];145 return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];146}147 148const DARK_TEXT: RGB = [12, 12, 18];149const LIGHT_TEXT: RGB = [248, 248, 250];150 151function textFor(bg: RGB, mode: "auto" | "light" | "dark"): RGB {152 if (mode === "light") return LIGHT_TEXT;153 if (mode === "dark") return DARK_TEXT;154 const lum = (0.2126 * bg[0] + 0.7152 * bg[1] + 0.0722 * bg[2]) / 255;155 return lum > 0.55 ? DARK_TEXT : LIGHT_TEXT;156}157 158const mix = (a: RGB, b: RGB, t: number) =>159 `rgb(${a.map((v, i) => Math.round(v + (b[i] - v) * t)).join(" ")})`;160 161export interface ScrollColorShiftProps {162 /** Section titles; the background shifts color between consecutive sections */163 sections?: string[];164 /** Background of section 1 (repeats every 4 sections) */165 color1?: string;166 /** Background of section 2 */167 color2?: string;168 /** Background of section 3 */169 color3?: string;170 /** Background of section 4 */171 color4?: string;172 /** Text color: "auto" picks light or dark per background for contrast */173 textMode?: "auto" | "light" | "dark";174 /** Portion of the scroll between sections spent blending (0.1 = quick swap, 1 = continuous) */175 blend?: number;176 /** Viewport height in px */177 height?: number;178 /** Viewport corner radius in px */179 borderRadius?: number;180 /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */181 lerp?: number;182 /** Multiplier applied to wheel / trackpad deltas */183 wheelMultiplier?: number;184 /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */185 smooth?: boolean;186 /** Optional className merged onto the root */187 className?: string;188 /** Inline styles merged onto the root */189 style?: React.CSSProperties;190}191 192const DEFAULT_SECTIONS = ["Midnight", "Ember", "Daylight", "Ultraviolet"];193const BLURBS = [194 "Start somewhere quiet, deep and focused.",195 "Then turn up the heat as momentum builds.",196 "Bright, open and impossible to miss.",197 "And finish with something a little electric.",198];199 200export function ScrollColorShift({201 sections = DEFAULT_SECTIONS,202 color1 = "#0f172a",203 color2 = "#ea580c",204 color3 = "#fde68a",205 color4 = "#6d28d9",206 textMode = "auto",207 blend = 0.6,208 height = 380,209 borderRadius = 20,210 lerp = 0.1,211 wheelMultiplier = 1,212 smooth = true,213 className = "",214 style,215}: ScrollColorShiftProps) {216 const reduced = usePrefersReducedMotion();217 const rootRef = React.useRef<HTMLDivElement>(null);218 const viewportRef = React.useRef<HTMLDivElement>(null);219 const counterRef = React.useRef<HTMLSpanElement>(null);220 const sectionRefs = React.useRef<(HTMLElement | null)[]>([]);221 const scrollRef = React.useRef(0);222 const count = sections.length;223 224 const palette = React.useMemo(() => {225 const bgs = [color1, color2, color3, color4].map((c, i) => parseHex(c, i % 2 ? LIGHT_TEXT : DARK_TEXT));226 return bgs.map((bg) => ({ bg, fg: textFor(bg, textMode) }));227 }, [color1, color2, color3, color4, textMode]);228 229 const apply = React.useCallback(() => {230 const vp = viewportRef.current;231 const root = rootRef.current;232 if (!vp || !root || count === 0) return;233 const center = scrollRef.current + vp.clientHeight / 2;234 const mids = sectionRefs.current.slice(0, count).map((s) => (s ? s.offsetTop + s.offsetHeight / 2 : 0));235 let i = 0;236 while (i < count - 2 && center > mids[i + 1]) i++;237 const span = Math.max(1, (mids[i + 1] ?? mids[i]) - mids[i]);238 const raw = count > 1 ? clamp((center - mids[i]) / span, 0, 1) : 0;239 const b = clamp(blend, 0.05, 1);240 let t = clamp((raw - (1 - b) / 2) / b, 0, 1);241 t = reduced ? Math.round(t) : t * t * (3 - 2 * t);242 const a = palette[i % 4];243 const z = palette[Math.min(i + 1, count - 1) % 4];244 root.style.setProperty("--mf-scs-bg", mix(a.bg, z.bg, t));245 root.style.setProperty("--mf-scs-fg", mix(a.fg, z.fg, t));246 const current = raw > 0.5 ? Math.min(i + 1, count - 1) : i;247 if (counterRef.current) {248 counterRef.current.textContent = `${String(current + 1).padStart(2, "0")} / ${String(count).padStart(2, "0")}`;249 }250 }, [blend, count, palette, reduced]);251 252 const handleScroll = React.useCallback(253 (state: SmoothScrollState) => {254 scrollRef.current = state.scroll;255 apply();256 },257 [apply],258 );259 260 useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });261 262 React.useEffect(() => {263 apply();264 }, [apply]);265 266 const vars = {267 "--mf-scs-h": `${height}px`,268 "--mf-scs-r": `${borderRadius}px`,269 "--mf-scs-bg": mix(palette[0].bg, palette[0].bg, 0),270 "--mf-scs-fg": mix(palette[0].fg, palette[0].fg, 0),271 ...style,272 } as React.CSSProperties;273 274 return (275 <div ref={rootRef} style={vars} className={`${styles.root} ${className}`.trim()}>276 <span ref={counterRef} className={styles.counter} aria-hidden="true">277 {`01 / ${String(count).padStart(2, "0")}`}278 </span>279 <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label="Color sections">280 <div className={styles.content}>281 {sections.map((title, i) => (282 <section283 key={`${title}-${i}`}284 ref={(node) => {285 sectionRefs.current[i] = node;286 }}287 className={styles.section}288 style={{ "--mf-scs-i": i } as React.CSSProperties}289 >290 <svg aria-hidden="true" className={styles.rings} viewBox="0 0 200 200">291 {[90, 68, 46, 24].map((r, k) => (292 <circle key={r} cx="100" cy="100" r={r} strokeDasharray={k % 2 ? "3 7" : undefined} />293 ))}294 </svg>295 <span className={styles.kicker}>{`Chapter ${String(i + 1).padStart(2, "0")}`}</span>296 <h3 className={styles.title}>{title}</h3>297 <p className={styles.text}>{BLURBS[i % BLURBS.length]}</p>298 </section>299 ))}300 </div>301 </div>302 </div>303 );304}