返回动效库
滚动高级

滚动翻页

滚动驱动的 3D 翻页效果,页面像书页一样沿 Y 轴翻转剥离,露出下一页内容。

滚动时continuous

交互预览

动效定义与代码

{
  "slug": "scroll-page-turn",
  "name": "滚动翻页",
  "nameEn": "Scroll Page Turn",
  "description": "滚动驱动的 3D 翻页效果,页面像书页一样沿 Y 轴翻转剥离,露出下一页内容。使用 sticky 定位锁定视口,配合 perspective 和 rotateY 实现电影级翻页体验。",
  "descriptionEn": "Scroll-driven 3D page turn effect where sections flip along the Y-axis like book pages, revealing the next section beneath. Uses sticky positioning to lock the viewport, combined with perspective and rotateY for a cinematic page-turning experience.",
  "category": "scroll",
  "tags": [
    "scroll",
    "3d",
    "page-turn",
    "perspective",
    "sticky",
    "apple"
  ],
  "trigger": "on-scroll",
  "difficulty": "advanced",
  "duration": "continuous",
  "easing": "cubic-bezier(0.32, 0.72, 0, 1)",
  "cssProperties": [
    "transform",
    "opacity",
    "box-shadow"
  ],
  "isGPUAccelerated": true,
  "previewBg": "dark",
  "keywords": [
    "page turn",
    "book flip",
    "scroll animation",
    "3D",
    "perspective",
    "apple",
    "sticky scroll",
    "cinematic"
  ],
  "useCases": [
    "Product launch storytelling pages",
    "Portfolio case study transitions",
    "Immersive brand narrative sites",
    "Full-page scroll experiences"
  ],
  "relatedAnimations": [
    "scroll-reveal",
    "parallax-float",
    "flip-card"
  ],
  "recommendedStyles": [
    "apple-style",
    "full-page-scroll",
    "parallax-sections",
    "hero-fullscreen"
  ],
  "codeSnippets": [
    {
      "label": "CSS Keyframes",
      "language": "css",
      "code": "/* === Scroll Page Turn ===\n * Each .page-turn-section is a sticky full-screen panel.\n * Wrap them inside a tall scroll container so the user's\n * scroll progress drives the 3D rotation via JS or\n * scroll-timeline (where supported).\n */\n\n.page-turn-container {\n  perspective: 1200px;\n  perspective-origin: center center;\n}\n\n.page-turn-section {\n  position: sticky;\n  top: 0;\n  width: 100%;\n  height: 100vh;\n  overflow: hidden;\n  transform-origin: left center;\n  will-change: transform, opacity;\n  backface-visibility: hidden;\n  transition: transform 0.1s linear, opacity 0.1s linear;\n}\n\n/* Shadow on the folding edge — driven by the same scroll progress */\n.page-turn-section::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n  background: linear-gradient(\n    to left,\n    rgba(0, 0, 0, 0.4) 0%,\n    transparent 60%\n  );\n  opacity: 0;\n  transition: opacity 0.1s linear;\n}\n\n/* When the page is mid-turn, show the fold shadow */\n.page-turn-section[data-turning=\"true\"]::after {\n  opacity: 1;\n}\n\n/* Reduced motion: instant cut, no 3D */\n@media (prefers-reduced-motion: reduce) {\n  .page-turn-section {\n    transform: none !important;\n    transition: none;\n  }\n  .page-turn-section::after {\n    display: none;\n  }\n}"
    },
    {
      "label": "Tailwind CSS",
      "language": "tsx",
      "code": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\n/**\n * Hook: maps scroll progress of a tall wrapper to 0-1.\n * Each \"page\" occupies 100vh of scroll distance.\n */\nfunction useScrollProgress() {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [progress, setProgress] = useState(0);\n\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n\n    function onScroll() {\n      const rect = el!.getBoundingClientRect();\n      const scrollable = el!.scrollHeight - window.innerHeight;\n      const scrolled = -rect.top;\n      setProgress(Math.max(0, Math.min(1, scrolled / scrollable)));\n    }\n\n    window.addEventListener(\"scroll\", onScroll, { passive: true });\n    onScroll();\n    return () => window.removeEventListener(\"scroll\", onScroll);\n  }, []);\n\n  return { containerRef, progress };\n}\n\ninterface PageTurnProps {\n  pages: ReactNode[];\n  className?: string;\n}\n\nexport function PageTurn({ pages, className }: PageTurnProps) {\n  const { containerRef, progress } = useScrollProgress();\n  const total = pages.length;\n\n  // Height: each page gets 100vh of scroll room\n  const containerHeight = `${total * 100}vh`;\n\n  return (\n    <div\n      ref={containerRef}\n      className={className}\n      style={{ height: containerHeight, position: \"relative\" }}\n    >\n      <div\n        className=\"sticky top-0 h-screen w-full\"\n        style={{ perspective: \"1200px\" }}\n      >\n        {pages.map((page, i) => {\n          // Each page turns between progress i/(total-1) and (i+1)/(total-1)\n          const start = i / total;\n          const end = (i + 1) / total;\n          const local = Math.max(\n            0,\n            Math.min(1, (progress - start) / (end - start))\n          );\n          const angle = local * -90;\n          const opacity = 1 - local;\n          const shadow = local * 0.5;\n\n          return (\n            <div\n              key={i}\n              className=\"absolute inset-0\"\n              style={{\n                transformOrigin: \"left center\",\n                transform: `rotateY(${angle}deg)`,\n                opacity: Math.max(0, opacity),\n                zIndex: total - i,\n                backfaceVisibility: \"hidden\",\n                boxShadow:\n                  shadow > 0\n                    ? `inset -30px 0 60px rgba(0,0,0,${shadow})`\n                    : \"none\",\n              }}\n            >\n              {page}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n// Usage:\n// <PageTurn pages={[\n//   <div className=\"h-screen bg-slate-900 flex items-center justify-center\">\n//     <h1 className=\"text-6xl text-white\">Page 1</h1>\n//   </div>,\n//   <div className=\"h-screen bg-indigo-900 flex items-center justify-center\">\n//     <h1 className=\"text-6xl text-white\">Page 2</h1>\n//   </div>,\n//   <div className=\"h-screen bg-emerald-900 flex items-center justify-center\">\n//     <h1 className=\"text-6xl text-white\">Page 3</h1>\n//   </div>,\n// ]} />"
    },
    {
      "label": "Framer Motion",
      "language": "tsx",
      "code": "import { motion, useScroll, useTransform } from \"framer-motion\";\nimport { useRef, type ReactNode } from \"react\";\n\n// Tip: disable 3D transforms for reduced motion users\n// import { useReducedMotion } from \"framer-motion\";\n\ninterface PageProps {\n  children: ReactNode;\n  index: number;\n  total: number;\n}\n\nfunction TurnPage({ children, index, total }: PageProps) {\n  const ref = useRef(null);\n  const { scrollYProgress } = useScroll({\n    target: ref,\n    offset: [\"start start\", \"end start\"],\n  });\n\n  const rotateY = useTransform(scrollYProgress, [0, 1], [0, -90]);\n  const opacity = useTransform(scrollYProgress, [0, 0.8, 1], [1, 0.6, 0]);\n  const shadow = useTransform(\n    scrollYProgress,\n    [0, 1],\n    [\n      \"inset 0px 0 0px rgba(0,0,0,0)\",\n      \"inset -40px 0 80px rgba(0,0,0,0.5)\",\n    ]\n  );\n\n  return (\n    <div ref={ref} style={{ height: \"100vh\" }}>\n      <motion.div\n        style={{\n          position: \"sticky\",\n          top: 0,\n          height: \"100vh\",\n          width: \"100%\",\n          transformOrigin: \"left center\",\n          rotateY,\n          opacity,\n          boxShadow: shadow,\n          backfaceVisibility: \"hidden\",\n          zIndex: total - index,\n        }}\n      >\n        {children}\n      </motion.div>\n    </div>\n  );\n}\n\n// Usage:\n// <div style={{ perspective: \"1200px\" }}>\n//   <TurnPage index={0} total={3}>\n//     <div className=\"h-screen bg-slate-900 grid place-items-center\">\n//       <h1 className=\"text-6xl text-white\">Page One</h1>\n//     </div>\n//   </TurnPage>\n//   <TurnPage index={1} total={3}>\n//     <div className=\"h-screen bg-indigo-900 grid place-items-center\">\n//       <h1 className=\"text-6xl text-white\">Page Two</h1>\n//     </div>\n//   </TurnPage>\n//   <TurnPage index={2} total={3}>\n//     <div className=\"h-screen bg-emerald-900 grid place-items-center\">\n//       <h1 className=\"text-6xl text-white\">Page Three</h1>\n//     </div>\n//   </TurnPage>\n// </div>"
    }
  ]
}