ScrollScroll Animationsno dependencies

ScrollColorShift

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

Chapter 01

Midnight

Start somewhere quiet, deep and focused.

Chapter 02

Ember

Then turn up the heat as momentum builds.

Chapter 03

Daylight

Bright, open and impossible to miss.

Chapter 04

Ultraviolet

And finish with something a little electric.

Customize

9 props
0.6
0.1

Installation

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

npx motionfoundry add scroll-color-shift

Writes 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.

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

Props

PropTypeDefault
sections

Section titles; the background shifts color between consecutive sections.

string[]['Midnight', 'Ember', 'Daylight', 'Ultraviolet']
color1

Background of section 1 (colors repeat every 4 sections).

string'#0f172a'
color2

Background of section 2.

string'#ea580c'
color3

Background of section 3.

string'#fde68a'
color4

Background of section 4.

string'#6d28d9'
textMode

Text color; auto picks light or dark per background for contrast.

'auto' | 'light' | 'dark''auto'
blend

Portion of the scroll between two section centers spent blending (0.1 quick swap, 1 continuous).

number0.6
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" ("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

components/ui/scroll-color-shift.tsx
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}