滚动翻页
滚动驱动的 3D 翻页效果,页面像书页一样沿 Y 轴翻转剥离,露出下一页内容。
交互预览
动效定义与代码
{
"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>"
}
]
}