Create a realistic page flip effect with JavaScript & WebGL. Adjust speed, direction & trigger, then copy the full source code. No jQuery required.
Adjust the speed, flip direction and trigger mode. The preview updates live. Click or hover the canvas to see the effect. When you're happy, copy the full standalone HTML.
Click to flip
Your code is ready
Copy the generated HTML & JS to your project
Both images are loaded and uploaded to the GPU as WebGL textures. The fragment shader receives them as uTex0 (outgoing page) and uTex1 (incoming page). The shader classifies each pixel into one of four regions: behind the curl, flat outgoing page, on the cylinder surface, or the back face, and renders each differently.
The page curl axis is tilted at 100° using a rotation matrix (pcRot). Every pixel's UV coordinates are transformed into this rotated space, and its distance from the cylinder axis (pcYC) determines which region it falls in. A second inverse matrix (pcRRot) maps cylinder surface points back to texture space for sampling.
When pcYC < -pcCylR, the pixel is behind the curl and shows the incoming page with a soft drop shadow cast by the cylinder. When pcYC > pcCylR, it's on the flat outgoing page. Otherwise it's on the cylinder itself. The shader computes the arc angle, decides whether the pixel is on the front face, the back face, or an edge-shadow region, and samples accordingly.
Pixels on the back face of the curling page are rendered as a bright gray surface (pcGray) that varies with the cylinder's curvature, lighter at the crest, slightly darker at the edges. Anti-aliased edge blending (pcAA) smooths the transition between the cylinder surface and the background behind it.
On trigger, the target progress toggles between 0 and 1. Each frame, the current value lerps toward the target using cur += (tgt - cur) * speed, creating a smooth ease-out. The animation loop only runs while the values differ, with no wasted frames. The speed value is what you control with the slider above.
Here's the key fragment shader that creates the page curl. It takes two textures, a progress uniform, and produces the entire effect in a single pass.
// InvertedPageCurl — HP / Sergey Kosarevsky (BSD-3 Clause)
precision mediump float;
uniform sampler2D uTex0; // outgoing page
uniform sampler2D uTex1; // incoming page
uniform float uProgress; // 0 → 1
uniform float uDir; // 1.0 = right-to-left, -1.0 = left-to-right
varying vec2 vUV;
const float pcPI = 3.141592653589793;
const float pcCylR = 1.0 / pcPI / 2.0; // cylinder radius
const float pcScale = 512.0;
const float pcSharp = 3.0;
// Map a point on the cylinder back to texture space
vec3 pcHitPt(float ha, float yc, vec3 pt, mat3 rr) {
pt.y = ha / (2.0 * pcPI);
return rr * pt;
}
// Anti-aliased edge blend
vec4 pcAA(vec4 c1, vec4 c2, float d) {
d *= pcScale;
if (d < 0.0) return c2;
if (d > 2.0) return c1;
float dd = pow(1.0 - d / 2.0, pcSharp);
return (c2 - c1) * dd + c1;
}
// Distance from pt to nearest edge of [0,1]²
float pcEdgeDist(vec3 pt) {
float dx = abs(pt.x > 0.5 ? 1.0 - pt.x : pt.x);
float dy = abs(pt.y > 0.5 ? 1.0 - pt.y : pt.y);
if (pt.x < 0.0) dx = -pt.x;
if (pt.x > 1.0) dx = pt.x - 1.0;
if (pt.y < 0.0) dy = -pt.y;
if (pt.y > 1.0) dy = pt.y - 1.0;
if ((pt.x < 0.0 || pt.x > 1.0) && (pt.y < 0.0 || pt.y > 1.0))
return sqrt(dx * dx + dy * dy);
return min(dx, dy);
}
void main() {
float t = clamp(uProgress, 0.0, 1.0);
float dir = uDir;
vec2 pcP = vUV; // outgoing texture coords
vec2 pcQ = vUV; // incoming texture coords
// Map progress to cylinder sweep range [-0.16 … 1.5]
float pcAmt = t * 1.66 - 0.16;
float pcCylAng = 2.0 * pcPI * pcAmt;
// 100° rotation matrix — gives the diagonal page-curl angle
float pcDeg = 100.0 * pcPI / 180.0;
float pcCosA = cos(pcDeg);
float pcSinA = sin(pcDeg);
mat3 pcRot = mat3( pcCosA, -pcSinA, 0.0,
pcSinA, pcCosA, 0.0,
-0.801, 0.890, 1.0);
mat3 pcRRot = mat3( pcCosA, pcSinA, 0.0,
-pcSinA, pcCosA, 0.0,
0.985, 0.985, 1.0);
vec2 pcGeomP = dir > 0.0 ? pcP : vec2(1.0 - pcP.x, pcP.y);
vec3 pcPt = pcRot * vec3(pcGeomP, 1.0);
float pcYC = pcPt.y - pcAmt;
float pcShadowCarry = 0.0;
float tr = 0.0;
vec2 uv = pcP;
if (pcYC < -pcCylR) {
// Behind the curl — show incoming page with cylinder shadow
float pcSh = 0.0;
float pcYN = -pcCylR - pcCylR - pcYC;
float pcHA2 = (acos(clamp(pcYN / pcCylR, -1.0, 1.0)) + pcCylAng) - pcPI;
vec3 pcBP = pcHitPt(pcHA2, pcYN, pcPt, pcRRot);
if (pcYN < 0.0 && pcBP.x >= 0.0 && pcBP.y >= 0.0 &&
pcBP.x <= 1.0 && pcBP.y <= 1.0 && (pcHA2 < pcPI || pcAmt > 0.5)) {
pcSh = (1.0 - sqrt(pow(pcBP.x - 0.5, 2.0) + pow(pcBP.y - 0.5, 2.0)) / 0.71)
* pow(-pcYN / pcCylR, 3.0) * 0.5;
}
gl_FragColor = vec4(texture2D(uTex1, pcQ).rgb - pcSh, 1.0);
return;
} else if (pcYC > pcCylR) {
// Flat outgoing page — no distortion
tr = 0.0; uv = pcP;
} else {
float pcHA = (acos(clamp(pcYC / pcCylR, -1.0, 1.0)) + pcCylAng) - pcPI;
float pcHAMod = mod(pcHA, 2.0 * pcPI);
if ((pcHAMod > pcPI && pcAmt < 0.5) || (pcHAMod > pcPI / 2.0 && pcAmt < 0.0)) {
// See-through the cylinder
float pcStHA = pcPI - (acos(clamp(pcYC / pcCylR, -1.0, 1.0)) - pcCylAng);
vec3 pcStPt = pcHitPt(pcStHA, pcYC, pcPt, pcRRot);
if (pcYC <= 0.0 && (pcStPt.x < 0.0 || pcStPt.y < 0.0 ||
pcStPt.x > 1.0 || pcStPt.y > 1.0)) {
gl_FragColor = texture2D(uTex1, pcQ); return;
} else if (pcYC > 0.0) {
tr = 0.0; uv = pcP;
} else {
vec2 pcStUV = dir > 0.0 ? pcStPt.xy : vec2(1.0 - pcStPt.x, pcStPt.y);
tr = 0.0; uv = pcStUV;
}
} else {
vec3 pcCylPt = pcHitPt(pcHA, pcYC, pcPt, pcRRot);
if (pcCylPt.x < 0.0 || pcCylPt.y < 0.0 ||
pcCylPt.x > 1.0 || pcCylPt.y > 1.0) {
// Cylinder edge casts shadow
float pcStHA2 = pcPI - (acos(clamp(pcYC / pcCylR, -1.0, 1.0)) - pcCylAng);
vec3 pcStPt2 = pcHitPt(pcStHA2, pcYC, pcPt, pcRRot);
float pcShadow = max(0.0, (1.0 - pcEdgeDist(pcCylPt) * 30.0) / 3.0) * pcAmt;
if (pcYC <= 0.0 && (pcStPt2.x < 0.0 || pcStPt2.y < 0.0 ||
pcStPt2.x > 1.0 || pcStPt2.y > 1.0)) {
vec4 pcThr = texture2D(uTex1, pcQ);
pcThr.rgb -= pcShadow;
gl_FragColor = pcThr; return;
} else if (pcYC > 0.0) {
tr = 0.0; uv = pcP; pcShadowCarry = pcShadow;
} else {
vec2 pcSt2UV = dir > 0.0 ? pcStPt2.xy : vec2(1.0 - pcStPt2.x, pcStPt2.y);
tr = 0.0; uv = clamp(pcSt2UV, 0.0, 1.0); pcShadowCarry = pcShadow;
}
} else {
// Backside of the curled page (bright gray surface)
float pcGray = 0.8 * (pow(1.0 - abs(pcYC / pcCylR), 0.2) / 2.0 + 0.5);
vec4 pcBack = vec4(vec3(pcGray), 1.0);
vec4 pcOther;
if (pcYC < 0.0) {
float pcSh2 = (1.0 - sqrt(pow(pcCylPt.x - 0.5, 2.0) +
pow(pcCylPt.y - 0.5, 2.0)) / 0.71)
* pow(-pcYC / pcCylR, 3.0) * 0.5;
pcOther = vec4(0.0, 0.0, 0.0, pcSh2);
} else {
pcOther = texture2D(uTex1, pcQ);
}
vec4 pcBackFinal = pcAA(pcBack, pcOther, pcCylR - abs(pcYC));
float pcDist = pcEdgeDist(pcCylPt);
float pcShadow3 = max(0.0, (1.0 - pcDist * 30.0) / 3.0) * pcAmt;
vec4 pcThruClr = texture2D(uTex1, pcQ);
pcThruClr.rgb -= pcShadow3;
gl_FragColor = pcAA(pcBackFinal, pcThruClr, pcDist);
return;
}
}
}
// Flat outgoing page with optional shadow carry
vec4 city = texture2D(uTex0, uv);
city.rgb -= pcShadowCarry;
gl_FragColor = mix(city, texture2D(uTex1, pcQ), tr);
}
The JavaScript side is simple: load two images as textures, compile the shader, and animate uProgress on click or hover. The speed slider above controls the lerp factor used each frame.
// Lerp-based animation loop
var cur = 0, tgt = 0, raf = null;
var SPEED = 0.055; // increase = faster, decrease = slower
function draw() {
cur += (tgt - cur) * SPEED;
if (Math.abs(tgt - cur) < 0.001) {
cur = tgt; raf = null;
} else {
raf = requestAnimationFrame(draw);
}
gl.uniform1f(uProg, cur);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
// Click trigger
wrap.addEventListener('click', function() {
tgt = tgt < 0.5 ? 1 : 0;
if (!raf) raf = requestAnimationFrame(draw);
});
// Hover trigger (alternative)
// wrap.addEventListener('mouseenter', function() { tgt = 1; if (!raf) raf = requestAnimationFrame(draw); });
// wrap.addEventListener('mouseleave', function() { tgt = 0; if (!raf) raf = requestAnimationFrame(draw); });
The Book Flip stacks multiple leaves on top of each other. Each leaf has a front face and a back face. Adding a class triggers a CSS rotateY(-180deg) rotation, peeling the top leaf away to reveal the one underneath.
Each leaf is position: absolute; inset: 0 so all leaves occupy the same space. The front and back faces sit inside with backface-visibility: hidden so only the correct side is visible at any time:
<div class="book-scene">
<div style="width:480px; display:flex; justify-content:flex-end;">
<div class="book">
<div class="leaf" id="leaf-0">
<div class="front">Page 1</div>
<div class="back">Page 2</div>
</div>
<div class="leaf" id="leaf-1">
<div class="front">Page 3</div>
<div class="back">Page 4</div>
</div>
</div>
</div>
</div>
Five properties drive the entire effect. perspective on the scene adds the 3D depth. transform-origin: left center pins the rotation to the left edge, the spine. rotateY(-180deg) flips the leaf over. backface-visibility: hidden hides the rear of each face while it's pointing away:
.book-scene {
perspective: 2000px; /* 3D depth — lower = more dramatic */
}
.leaf {
position: absolute;
inset: 0;
transform-origin: left center; /* rotation axis = the spine */
transform-style: preserve-3d;
transition: transform 0.8s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.leaf.flipped {
transform: rotateY(-180deg); /* flip! */
}
.front, .back {
position: absolute;
inset: 0;
backface-visibility: hidden; /* hide the face pointing away */
}
.back {
transform: rotateY(180deg); /* pre-rotate the back face */
}
Leaves start stacked with the first leaf on top (z-index: 4, 3, 2, 1). During a flip, the animating leaf is raised to z-index: 99 so it sweeps above everything. After the flip completes, it's lowered below the remaining unflipped leaves.
The CSS Page Flip renders an open book with a visible spine. Each leaf occupies only the right half of the book container. When flipped, the leaf rotates around the spine and its back face becomes the left page of the spread, creating an authentic two-page reading experience.
All leaves are anchored to the right with right: 0; width: 50%. The rotation axis is transform-origin: left center, positioned exactly at the spine. The parent gets perspective for depth:
.book-scene {
perspective: 2500px; /* adjustable — controls 3D depth */
}
.book {
position: relative;
width: 500px;
height: 310px;
}
.leaf {
position: absolute;
right: 0; /* anchored to right half */
width: 50%; /* each leaf = one page width */
height: 100%;
transform-origin: left center; /* = the spine */
transform-style: preserve-3d;
transition: transform 1s ease;
}
.leaf.flipped {
transform: rotateY(-180deg); /* sweeps from right to left */
}
The front face is the right-hand page before the flip. The back face, pre-rotated 180°, becomes the left-hand page after the flip. Both use backface-visibility: hidden:
.front, .back {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.back {
transform: rotateY(180deg);
/* After the leaf flips -180deg, this face is now
pointing toward the viewer on the LEFT side */
}
The perspective value controls how extreme the 3D distortion looks during the flip. A low value (e.g. 600px) creates a very foreshortened, dramatic curl. A high value (e.g. 5000px) produces a nearly flat, subtle rotation, closer to a simple slide.
The generator above flips between two images on click or hover. With fullPage.js, you get the same page flip effect between fullscreen sections on scroll, with touch support, snap scrolling, and keyboard navigation built in.
Choose a preset, adjust the speed, and scroll inside the preview to see the transition live:
fullPage.js adds
Scroll inside the preview to see the page flip transition between sections
Page flip effects simulate physical page turns, making online publications feel tactile and familiar to readers.
Fullscreen sections that flip like book pages create a narrative flow, and each scroll reveals the next chapter.
The page curl adds drama to project showcases. Each section transition feels like turning to the next piece.
Photo stories and lookbooks where the page flip connects images with a sense of physical continuity.
uProgress uniform from 0 to 1. The shader transforms pixel UVs into a rotated cylinder space, classifies each pixel as behind the curl, on the flat page, on the cylinder front, or on the cylinder back, and renders each region with proper shading and drop shadows. Use the interactive generator above to configure speed and direction, then copy the full code.
cinematic: true and cinematicOptions: { effect: 'page' } to get a realistic WebGL page flip between fullscreen sections on scroll. For a simpler CSS-based 3D rotation, use the Effects extension with effects: 'cube'.
perspective, rotateY, backface-visibility). Card flips are simpler but less realistic.
Add scroll transitions, parallax and animations to your site with fullPage.js. One component, 80+ effects.
Powering fullscreen design for Google, Sony, BBC, eBay & more