滚动剥离
滚动驱动的页面剥离效果,当前页面像贴纸一样从右上角卷起剥离,露出下一层内容。
交互预览
动效定义与代码
{
"slug": "scroll-peel-away",
"name": "滚动剥离",
"nameEn": "Scroll Peel Away",
"description": "滚动驱动的页面剥离效果,当前页面像贴纸一样从右上角卷起剥离,露出下一层内容。配合阴影和 3D 旋转营造真实的纸张卷曲质感。",
"descriptionEn": "Scroll-driven peel-away effect where the current section curls up from the corner like a sticker being peeled off, revealing the next layer beneath. Combined with shadow and 3D rotation for realistic paper-curl texture.",
"category": "scroll",
"tags": [
"scroll",
"3d",
"peel",
"curl",
"paper",
"sticky",
"apple"
],
"trigger": "on-scroll",
"difficulty": "advanced",
"duration": "continuous",
"easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"cssProperties": [
"transform",
"clip-path",
"box-shadow",
"opacity"
],
"isGPUAccelerated": true,
"previewBg": "light",
"keywords": [
"peel",
"curl",
"paper",
"sticker",
"scroll animation",
"3D",
"apple",
"page transition"
],
"useCases": [
"Creative portfolio page transitions",
"Magazine-style article navigation",
"Product reveal sequences",
"Interactive storytelling experiences"
],
"relatedAnimations": [
"scroll-page-turn",
"scroll-reveal",
"flip-card"
],
"recommendedStyles": [
"apple-style",
"editorial",
"full-page-scroll",
"magazine-grid"
],
"codeSnippets": [
{
"label": "CSS Keyframes",
"language": "css",
"code": "/* === Scroll Peel Away ===\n * The current section peels from the top-right corner,\n * curling diagonally to reveal the section beneath.\n * Driven by scroll progress via JS or scroll-timeline.\n */\n\n.peel-container {\n position: relative;\n overflow: hidden;\n}\n\n.peel-section {\n position: sticky;\n top: 0;\n width: 100%;\n height: 100vh;\n will-change: transform, clip-path;\n transform-origin: bottom left;\n}\n\n/* The curling corner overlay — a pseudo-element that\n simulates the underside of the peeled paper */\n.peel-section::before {\n content: \"\";\n position: absolute;\n top: 0;\n right: 0;\n width: 50%;\n height: 50%;\n background: linear-gradient(\n 135deg,\n rgba(255, 255, 255, 0.1) 0%,\n rgba(0, 0, 0, 0.15) 100%\n );\n transform-origin: top right;\n pointer-events: none;\n opacity: 0;\n transition: opacity 0.15s linear;\n border-radius: 0 0 0 40%;\n}\n\n/* Fold shadow along the diagonal crease */\n.peel-section::after {\n content: \"\";\n position: absolute;\n inset: 0;\n pointer-events: none;\n background: linear-gradient(\n 135deg,\n transparent 40%,\n rgba(0, 0, 0, 0.25) 50%,\n transparent 60%\n );\n opacity: 0;\n transition: opacity 0.15s linear;\n}\n\n.peel-section[data-peeling=\"true\"]::before,\n.peel-section[data-peeling=\"true\"]::after {\n opacity: 1;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .peel-section {\n transform: none !important;\n clip-path: none !important;\n transition: none;\n }\n .peel-section::before,\n .peel-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\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 PeelAwayProps {\n pages: ReactNode[];\n className?: string;\n}\n\nexport function PeelAway({ pages, className }: PeelAwayProps) {\n const { containerRef, progress } = useScrollProgress();\n const total = pages.length;\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 className=\"sticky top-0 h-screen w-full overflow-hidden\">\n {pages.map((page, i) => {\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\n // Peel from top-right: rotate around bottom-left origin\n const rotateX = local * -15;\n const rotateZ = local * 8;\n const translateY = local * -20;\n const scale = 1 - local * 0.05;\n const clipInset = local * 100; // percentage from top-right\n\n // Diagonal clip-path: shrinks from top-right corner\n const clipPath =\n local > 0\n ? `polygon(0% 0%, ${100 - clipInset}% 0%, 0% ${100 - clipInset}%)`\n : \"none\";\n\n return (\n <div\n key={i}\n className=\"absolute inset-0\"\n style={{\n transformOrigin: \"bottom left\",\n transform: `perspective(1200px) rotateX(${rotateX}deg) rotateZ(${rotateZ}deg) translateY(${translateY}%) scale(${scale})`,\n clipPath: local > 0.01 ? clipPath : \"none\",\n zIndex: total - i,\n boxShadow:\n local > 0.01\n ? `-8px 8px ${30 * local}px rgba(0,0,0,${0.3 * local})`\n : \"none\",\n }}\n >\n {page}\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n\n// Usage:\n// <PeelAway pages={[\n// <div className=\"h-screen bg-white flex items-center justify-center\">\n// <h1 className=\"text-6xl font-bold text-slate-900\">First</h1>\n// </div>,\n// <div className=\"h-screen bg-amber-50 flex items-center justify-center\">\n// <h1 className=\"text-6xl font-bold text-amber-900\">Second</h1>\n// </div>,\n// <div className=\"h-screen bg-sky-50 flex items-center justify-center\">\n// <h1 className=\"text-6xl font-bold text-sky-900\">Third</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 PeelPageProps {\n children: ReactNode;\n index: number;\n total: number;\n}\n\nfunction PeelPage({ children, index, total }: PeelPageProps) {\n const ref = useRef(null);\n const { scrollYProgress } = useScroll({\n target: ref,\n offset: [\"start start\", \"end start\"],\n });\n\n const rotateX = useTransform(scrollYProgress, [0, 1], [0, -15]);\n const rotateZ = useTransform(scrollYProgress, [0, 1], [0, 8]);\n const y = useTransform(scrollYProgress, [0, 1], [\"0%\", \"-20%\"]);\n const scale = useTransform(scrollYProgress, [0, 1], [1, 0.95]);\n const opacity = useTransform(scrollYProgress, [0, 0.6, 1], [1, 0.8, 0]);\n const shadow = useTransform(\n scrollYProgress,\n [0, 1],\n [\n \"-0px 0px 0px rgba(0,0,0,0)\",\n \"-12px 12px 40px rgba(0,0,0,0.35)\",\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: \"bottom left\",\n rotateX,\n rotateZ,\n y,\n scale,\n opacity,\n boxShadow: shadow,\n zIndex: total - index,\n }}\n >\n {children}\n </motion.div>\n </div>\n );\n}\n\n// Usage:\n// <div style={{ perspective: \"1200px\" }}>\n// <PeelPage index={0} total={3}>\n// <div className=\"h-screen bg-white grid place-items-center\">\n// <h1 className=\"text-6xl font-bold text-slate-900\">First</h1>\n// </div>\n// </PeelPage>\n// <PeelPage index={1} total={3}>\n// <div className=\"h-screen bg-amber-50 grid place-items-center\">\n// <h1 className=\"text-6xl font-bold text-amber-900\">Second</h1>\n// </div>\n// </PeelPage>\n// <PeelPage index={2} total={3}>\n// <div className=\"h-screen bg-sky-50 grid place-items-center\">\n// <h1 className=\"text-6xl font-bold text-sky-900\">Third</h1>\n// </div>\n// </PeelPage>\n// </div>"
}
]
}