ScrollScroll Animationsno dependencies

SnapSections

Full-height sections inside their own viewport that advance exactly one section per wheel or trackpad gesture with an eased snap animation, plus clickable dot navigation and PageUp/PageDown/Arrow/Space/Home/End keys; each section's content rises in as it becomes active.

Preview

01 / 04

Capture

Drop in ideas from anywhere: links, notes, screenshots and voice.

02 / 04

Organize

Everything finds its place with smart tags and nested spaces.

03 / 04

Collaborate

Comment, assign and decide together in real time.

04 / 04

Publish

Turn any space into a polished page with one click.

Customize

8 props
900
12

Installation

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

npx motionfoundry add snap-sections

Writes components/ui/snap-sections.tsx and components/styles/snap-sections.module.css.

Usage

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

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

Props

PropTypeDefault
sections

Section titles, one full-height section per item.

string[]['Capture', 'Organize', 'Collaborate', 'Publish']
accentColor

Accent color for the glow orb (hue-shifted per section), kicker and active dot.

string'#34d399'
duration

Snap animation duration in ms.

number900
ease

Easing curve of the snap animation.

'expo.out' | 'power3.inOut' | 'power2.out' | 'sine.inOut' | 'back.out''expo.out'
showDots

Show the clickable dot navigation.

booleantrue
dotsPosition

Side the dot navigation sits on.

'right' | 'left''right'
threshold

Accumulated wheel delta in px needed to trigger a snap; raise it to ignore tiny trackpad nudges.

number12
smooth

Animate snaps with easing; false (or prefers-reduced-motion) uses native CSS scroll-snap.

booleantrue
height

Height of the viewport (and of each section) in px; width is always 100%.

number380
borderRadius

Corner radius of the viewport in px.

number20
onSectionChange

Called with the new index whenever the active section changes.

(index: number) => voidundefined
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" whose label explains the Page Up/Page Down controls; PageDown/ArrowDown/Space go forward, PageUp/ArrowUp/Shift+Space go back, Home/End jump to the ends, all with a 2px :focus-visible outline. Each section is a labelled <section> ("2 of 4: Organize") with a real heading. The dot navigation is a <nav aria-label="Sections"> of buttons with descriptive aria-labels and aria-current on the active one. A fresh wheel gesture past the first or last section is not intercepted, so the page keeps scrolling; touch uses native CSS scroll-snap. Under prefers-reduced-motion (or smooth=false) the wheel is not intercepted, snapping is native CSS scroll-snap and content transitions are removed.

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

Source

components/ui/snap-sections.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/snap-sections.module.css";5 6type SnapEase = "expo.out" | "power3.inOut" | "power2.out" | "sine.inOut" | "back.out";7 8const EASES: Record<SnapEase, (t: number) => number> = {9  "expo.out": (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),10  "power3.inOut": (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),11  "power2.out": (t) => 1 - (1 - t) * (1 - t),12  "sine.inOut": (t) => -(Math.cos(Math.PI * t) - 1) / 2,13  "back.out": (t) => 1 + 2.70158 * Math.pow(t - 1, 3) + 1.70158 * Math.pow(t - 1, 2),14};15 16const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));17 18function usePrefersReducedMotion(): boolean {19  const [reduced, setReduced] = React.useState(false);20  React.useEffect(() => {21    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");22    const update = () => setReduced(mq.matches);23    update();24    mq.addEventListener("change", update);25    return () => mq.removeEventListener("change", update);26  }, []);27  return reduced;28}29 30export interface SnapSectionsProps {31  /** Section titles, one full-height section per item */32  sections?: string[];33  /** Accent color for the glow orb, kicker and active dot */34  accentColor?: string;35  /** Snap animation duration in ms */36  duration?: number;37  /** Easing curve of the snap animation */38  ease?: SnapEase;39  /** Show the clickable dot navigation */40  showDots?: boolean;41  /** Side the dot navigation sits on */42  dotsPosition?: "right" | "left";43  /** Accumulated wheel delta in px needed to trigger a snap */44  threshold?: number;45  /** Animate snaps with easing (native CSS scroll-snap when false or under reduced motion) */46  smooth?: boolean;47  /** Viewport height in px */48  height?: number;49  /** Viewport corner radius in px */50  borderRadius?: number;51  /** Called with the new index whenever the active section changes */52  onSectionChange?: (index: number) => void;53  /** Optional className merged onto the root */54  className?: string;55  /** Inline styles merged onto the root */56  style?: React.CSSProperties;57}58 59const DEFAULT_SECTIONS = ["Capture", "Organize", "Collaborate", "Publish"];60const BLURBS = [61  "Drop in ideas from anywhere: links, notes, screenshots and voice.",62  "Everything finds its place with smart tags and nested spaces.",63  "Comment, assign and decide together in real time.",64  "Turn any space into a polished page with one click.",65  "Every gesture moves exactly one section, never more.",66];67 68export function SnapSections({69  sections = DEFAULT_SECTIONS,70  accentColor = "#34d399",71  duration = 900,72  ease = "expo.out",73  showDots = true,74  dotsPosition = "right",75  threshold = 12,76  smooth = true,77  height = 380,78  borderRadius = 20,79  onSectionChange,80  className = "",81  style,82}: SnapSectionsProps) {83  const reduced = usePrefersReducedMotion();84  const animated = smooth && !reduced;85  const viewportRef = React.useRef<HTMLDivElement>(null);86  const sectionRefs = React.useRef<(HTMLElement | null)[]>([]);87  const [active, setActive] = React.useState(0);88  const activeRef = React.useRef(0);89  const animRef = React.useRef(0);90  const changeRef = React.useRef(onSectionChange);91  const count = sections.length;92 93  React.useEffect(() => {94    changeRef.current = onSectionChange;95  }, [onSectionChange]);96 97  const markActive = React.useCallback((i: number) => {98    if (activeRef.current === i) return;99    activeRef.current = i;100    setActive(i);101    changeRef.current?.(i);102  }, []);103 104  const goTo = React.useCallback(105    (index: number) => {106      const el = viewportRef.current;107      const i = clamp(index, 0, Math.max(0, count - 1));108      const section = sectionRefs.current[i];109      if (!el || !section) return;110      markActive(i);111      if (animRef.current) cancelAnimationFrame(animRef.current);112      animRef.current = 0;113      const to = section.offsetTop;114      if (!animated) {115        el.scrollTop = to;116        return;117      }118      const from = el.scrollTop;119      const fn = EASES[ease] ?? EASES["expo.out"];120      const ms = Math.max(1, duration);121      const start = performance.now();122      el.dataset.animating = "true";123      const tick = (now: number) => {124        const t = clamp((now - start) / ms, 0, 1);125        el.scrollTop = from + (to - from) * fn(t);126        if (t < 1) {127          animRef.current = requestAnimationFrame(tick);128        } else {129          animRef.current = 0;130          el.scrollTop = to;131          el.dataset.animating = "false";132        }133      };134      animRef.current = requestAnimationFrame(tick);135    },136    [animated, count, duration, ease, markActive],137  );138 139  React.useEffect(() => {140    const el = viewportRef.current;141    if (!el) return;142    let lastWheel = 0;143    let acc = 0;144    let consumed = false;145    const onWheel = (event: WheelEvent) => {146      if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;147      const dy = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientHeight : 1);148      if (dy === 0) return;149      const now = performance.now();150      const fresh = now - lastWheel > 160;151      lastWheel = now;152      if (fresh) {153        consumed = false;154        acc = 0;155      }156      const idx = activeRef.current;157      const atEdge = (dy < 0 && idx === 0) || (dy > 0 && idx === count - 1);158      // A new gesture past the first/last section is released so the page keeps scrolling.159      if (atEdge && !animRef.current && (fresh || !consumed)) return;160      event.preventDefault();161      if (consumed || animRef.current) return;162      acc += dy;163      if (Math.abs(acc) >= Math.max(1, threshold)) {164        consumed = true;165        acc = 0;166        goTo(idx + (dy > 0 ? 1 : -1));167      }168    };169    const onScroll = () => {170      if (animRef.current || el.clientHeight === 0) return;171      markActive(clamp(Math.round(el.scrollTop / el.clientHeight), 0, Math.max(0, count - 1)));172    };173    const ro = new ResizeObserver(() => {174      const section = sectionRefs.current[activeRef.current];175      if (!animRef.current && section) el.scrollTop = section.offsetTop;176    });177    ro.observe(el);178    if (animated) el.addEventListener("wheel", onWheel, { passive: false });179    el.addEventListener("scroll", onScroll, { passive: true });180    return () => {181      if (animated) el.removeEventListener("wheel", onWheel);182      el.removeEventListener("scroll", onScroll);183      ro.disconnect();184      if (animRef.current) cancelAnimationFrame(animRef.current);185      animRef.current = 0;186      el.dataset.animating = "false";187    };188  }, [animated, count, threshold, goTo, markActive]);189 190  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {191    const idx = activeRef.current;192    let next = -1;193    if (event.key === "PageDown" || event.key === "ArrowDown" || (event.key === " " && !event.shiftKey)) next = idx + 1;194    else if (event.key === "PageUp" || event.key === "ArrowUp" || (event.key === " " && event.shiftKey)) next = idx - 1;195    else if (event.key === "Home") next = 0;196    else if (event.key === "End") next = count - 1;197    if (next < 0 || next > count - 1 || next === idx) return;198    event.preventDefault();199    goTo(next);200  };201 202  const vars = {203    "--mf-sns-h": `${height}px`,204    "--mf-sns-r": `${borderRadius}px`,205    "--mf-sns-accent": accentColor,206    ...style,207  } as React.CSSProperties;208 209  return (210    <div style={vars} className={`${styles.root} ${className}`.trim()} data-dots={dotsPosition}>211      <div212        ref={viewportRef}213        className={styles.viewport}214        tabIndex={0}215        role="region"216        aria-label={`${count} snapping sections. Use Page Up and Page Down to move between them.`}217        onKeyDown={onKeyDown}218        data-animating="false"219        data-snap={animated ? "js" : "css"}220      >221        {sections.map((title, i) => (222          <section223            key={`${title}-${i}`}224            ref={(node) => {225              sectionRefs.current[i] = node;226            }}227            className={styles.section}228            data-active={String(i === active)}229            aria-label={`${i + 1} of ${count}: ${title}`}230            style={{ "--mf-sns-i": i } as React.CSSProperties}231          >232            <span aria-hidden="true" className={styles.orb} />233            <span aria-hidden="true" className={styles.bigIndex}>234              {String(i + 1).padStart(2, "0")}235            </span>236            <div className={styles.body}>237              <span className={styles.kicker}>{`${String(i + 1).padStart(2, "0")} / ${String(count).padStart(2, "0")}`}</span>238              <h3 className={styles.title}>{title}</h3>239              <p className={styles.text}>{BLURBS[i % BLURBS.length]}</p>240            </div>241          </section>242        ))}243      </div>244      {showDots && count > 1 ? (245        <nav className={styles.dots} aria-label="Sections">246          {sections.map((title, i) => (247            <button248              key={`${title}-${i}`}249              type="button"250              className={styles.dot}251              aria-label={`Go to section ${i + 1}: ${title}`}252              aria-current={i === active ? "true" : undefined}253              onClick={() => goTo(i)}254            />255          ))}256        </nav>257      ) : null}258    </div>259  );260}