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