GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Page Turn Animation CSS — 9 Paper Curl Patterns

page turn animation css: No library will change this — item 1 will curl from where the pointer grabs the corner, item 7 will let the plane sway by its drift

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine Patterns From a Dragged Corner to a Whole Desk Scene

The nine below move from a single interactive corner to a scene where three of the same techniques share one control: item 1 and item 5 both answer a drag or a key, items 2 through 4 turn a fixed clock into a whole page flip, items 6 through 8 color, warp, or chain three different sheets of paper into motion, and item 9 folds items 1, 4, and 7 into one desk so a single number drives all three at once.

01Page Corner That Curls From Where the Pointer Drags

Dragging near a corner sets drag.x/drag.y, and curlPage() turns that offset into a curl radius that bends a strip of the page around CURL_RADIUS_PX on every frame. foldTheta() divides the pulled distance by that one constant, so the same drag reads as a tight fold at a small radius or a loose roll at a large one.

Page Corner That Curls From Where the Pointer Drags
Open in a new tab

01 · Page Corner That Curls From Where the Pointer Drags — Measured area 35.251% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CURL_RADIUS_PXfoldThetacurlPage
  function curlPage() {
    var foldX = clamp(drag.x, PAGE_L, PAGE_R);
    var skew = (drag.y - PAGE_B) / (PAGE_B - PAGE_T);   // negative the higher you drag — tilt of the curl axis
    var h = PAGE_B - PAGE_T;
    ctx.fillStyle = PAPER;
    ctx.fillRect(PAGE_L, PAGE_T, Math.max(0, foldX - PAGE_L), h);

    var maxDx = Math.max(1, PAGE_R - foldX);
    for (var x = foldX; x <= PAGE_R + STRIP_W; x += STRIP_W) {
      var dx = Math.min(x - foldX, maxDx);
      var theta = foldTheta(dx);
      if (theta > Math.PI + 0.15) continue;
      var sxp = foldX + Math.sin(Math.min(theta, Math.PI)) * CURL_RADIUS_PX;
      var front = Math.cos(theta) > 0;
      var lift = (dx / maxDx) * skew * 30;
      ctx.fillStyle = front ? shadeHex(PAPER, 0.86 + 0.14 * Math.cos(theta)) : shadeHex(PAPER_BACK, 0.5 + 0.3 * (-Math.cos(theta)));
      ctx.fillRect(sxp, PAGE_T + lift, STRIP_W + 1, h);
    }
    var depth = clamp(maxDx / CURL_RADIUS_PX, 0, 1.6);

Measured: 35.3% cumulative area, 90.2 average intensity, 23/23 frames moved.

02Page Flip Whose Shadow Is Computed Along the Fold Line

flipAngle() turns elapsed time into a triangle wave between 0 and π, and pageFlip() feeds that angle's cosine straight into ctx.scale() so the page rotates around FOLD_X instead of merely crossfading between two flat images. The same cosine also sets how wide and how dark the shadow band beside the fold reads, so a flip caught mid-turn shows a harder shadow than one near either flat end.

Page Flip Whose Shadow Is Computed Along the Fold Line
Open in a new tab

02 · Page Flip Whose Shadow Is Computed Along the Fold Line — Measured area 41.608% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOLD_XflipAnglepageFlip
  function flipAngle(tMs) {
    var p = (tMs % FLIP_MS) / FLIP_MS;                // 0→1, 2.2 cycles per 2s
    var w = p < 0.5 ? p * 2 : (1 - p) * 2;             // triangle wave
    return w * Math.PI;
  }

  function pageFlip() {
    var w = BOOK_R - BOOK_L, h = BOOK_B - BOOK_T;
    ctx.fillStyle = BACK_PAGE;
    ctx.fillRect(BOOK_L, BOOK_T, w, h);
    drawLines(BOOK_L, BOOK_T, w, h, LINE_COLOR);

    var c = Math.cos(angle);
    var frontFacing = c >= 0;
    var scaleW = Math.max(0.001, Math.abs(c)) * w;
    ctx.save();
    ctx.translate(FOLD_X, 0);
    ctx.scale(c === 0 ? 0.001 : c, 1);
    ctx.fillStyle = frontFacing ? FRONT_PAGE : FRONT_PAGE_BACK;
    ctx.fillRect(0, BOOK_T, w, h);

Measured: 41.6% cumulative area, 73.4 average intensity, 20/23 frames moved.

03Book Stack Where Multiple Pages Flip in Sequence

stackThickness() multiplies the pages still left in remaining by a fixed 2.2px, so the shadow under the stack visibly thins with every page stepFlip() turns. nextPage() only starts a new flip once the last one finishes, and once remaining reaches zero the count resets so the stack never runs out of pages to turn.

Book Stack Where Multiple Pages Flip in Sequence
Open in a new tab

03 · Book Stack Where Multiple Pages Flip in Sequence — Measured area 45.378% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

STACK_TOTALstackThicknessnextPage
  function stackThickness(n) { return clamp(n, 0, STACK_TOTAL) * 2.2; }

  function startFlip() {
    if (flipping || remaining <= 0) return;
    flipping = true; flipT = 0;
  }
  function nextPage() {
    if (flipping || remaining <= 0) { remaining = STACK_TOTAL; return; }
    startFlip();
  }

  function stepFlip(dt) {
    if (!flipping) return;
    flipT += dt * 1000;
    var p = clamp(flipT / FLIP_MS, 0, 1);
    angle = p * Math.PI;
    if (p >= 1) {
      flipping = false; angle = 0;
      remaining -= 1;
      if (remaining <= 0) remaining = STACK_TOTAL;      // start over once every page has turned
    }
  }

Measured: 45.4% cumulative area, 75.7 average intensity, 18/23 frames moved.

04Open-book Gutter That Darkens by Its Bend Curvature

gutterBend() reads elapsed time through a cosine ease into a single bendAngle capped at BEND_MAX_RAD, and drawPageSide() spends that one angle twice — once to bow each strip toward the spine, once to darken it — so curvature and shadow always move together instead of drifting apart. A page near its flattest state barely presses toward the gutter; one near BEND_MAX_RAD folds the strips visibly inward and reads almost black at the spine.

Open-book Gutter That Darkens by Its Bend Curvature
Open in a new tab

04 · Open-book Gutter That Darkens by Its Bend Curvature — Measured area 31.942% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BEND_MAX_RADgutterBenddrawPageSide
  function gutterBend(tMs) {
    return BEND_MAX_RAD * (0.5 - 0.5 * Math.cos(2 * Math.PI * tMs / PERIOD_MS));
  }

  function drawLines(x, y, w, h) {
    ctx.strokeStyle = 'rgba(23,20,26,.22)';
    ctx.lineWidth = 1.2;
    for (var i = 0; i < 5; i += 1) {
      var ly = y + h * (0.16 + i * 0.17);
      ctx.beginPath(); ctx.moveTo(x, ly); ctx.lineTo(x + w, ly); ctx.stroke();
    }
  }

  function drawPageSide(sign) {
    var strip = 4, h = PAGE_B - PAGE_T;
    for (var dx = 0; dx <= HALF_W; dx += strip) {
      var t = dx / HALF_W;                             // 0=gutter · 1=outer edge
      var curveTheta = (1 - t) * bendAngle;             // curvature grows the closer it sits to the gutter
      var comp = Math.sin(curveTheta) * 12;             // px pressed inward toward the gutter
      var x = CENTER + sign * (dx - comp);
      var shade = 1 - (1 - t) * (bendAngle / BEND_MAX_RAD) * 0.42;
      ctx.fillStyle = shadeHex(PAPER, shade);
      ctx.fillRect(sign > 0 ? x : x - strip, PAGE_T, strip + 1, h);
    }
  }

Measured: 31.9% cumulative area, 22.8 average intensity, 23/23 frames moved.

05Origami Fold That Bends by Its Crease-angle Value

angleDeg eases toward targetDeg a fifth of the remaining distance every frame, and drawFacet() turns that single angle into a cosine that scales each facet's width and brightness at once — near 0° the shape reads almost flat, and near CREASE_ANGLE_DEG's upper end the facets narrow into a visibly folded wedge. Arrow keys nudge targetDeg directly, so a reader can push the same formula past its default range.

Origami Fold That Bends by Its Crease-angle Value
Open in a new tab

05 · Origami Fold That Bends by Its Crease-angle Value — Measured area 29.063% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CREASE_ANGLE_DEGangleStepdrawFacet
  function drawFacet(sign) {
    var rad = angleDeg * Math.PI / 180;
    var c = Math.cos(rad);
    var frontFacing = c >= 0;
    var h = BOTTOM - TOP;
    ctx.save();
    ctx.translate(CENTER, 0);
    ctx.scale(sign * (c === 0 ? 0.001 : c), 1);
    var brightness = frontFacing ? (0.62 + 0.38 * c) : (0.3 + 0.2 * (-c));
    ctx.fillStyle = frontFacing ? shadeHex(PAPER, brightness) : shadeHex(PAPER_BACK, brightness);
    ctx.fillRect(0, TOP, FACET_W, h);
    if (frontFacing) {
      ctx.strokeStyle = 'rgba(23,20,26,.18)';
      ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(FACET_W * 0.5, TOP + 8); ctx.lineTo(FACET_W * 0.5, BOTTOM - 8); ctx.stroke();
    }
    ctx.restore();
  }

Measured: 29.1% cumulative area, 104.3 average intensity, 23/23 frames moved.

06Crumpled Paper Surface Shaded by a Noise Field

makeValueNoise() builds a 24×24 lattice of random values once and blends four neighboring cells with smoothStep() on every call, so crumpleHeight() reads a continuous bump field instead of jumping between the lattice's raw random points. The same noise value both lifts the drawn surface and darkens its shading, so a taller wrinkle always casts the deeper shadow beside it.

Crumpled Paper Surface Shaded by a Noise Field
Open in a new tab

06 · Crumpled Paper Surface Shaded by a Noise Field — Measured area 50.638% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

makeValueNoisecrumpleHeightlatAt
  function makeValueNoise(seed) {
    var rnd = seedRand(seed);
    var GRID = 24;
    var lattice = [];
    for (var i = 0; i < GRID * GRID; i += 1) lattice.push(rnd());
    function latAt(xi, yi) {
      xi = ((xi % GRID) + GRID) % GRID; yi = ((yi % GRID) + GRID) % GRID;
      return lattice[yi * GRID + xi];
    }
    return function valueNoise(x, y) {
      var xi = Math.floor(x), yi = Math.floor(y);
      var xf = x - xi, yf = y - yi;
      var v00 = latAt(xi, yi), v10 = latAt(xi + 1, yi), v01 = latAt(xi, yi + 1), v11 = latAt(xi + 1, yi + 1);
      var sx = smoothStep(xf), sy = smoothStep(yf);
      var a = v00 + (v10 - v00) * sx;
      var b = v01 + (v11 - v01) * sx;
      return a + (b - a) * sy;
    };
  }

Measured: 50.6% cumulative area, 20.7 average intensity, 23/23 frames moved.

07Paper Airplane That Flies by a Parabolic Drift Value

driftFlight() adds a sine wave scaled by DRIFT_AMPLITUDE_PX on top of a straight-line interpolation from launch to landing, while a separate parabola pulls the same point up and back down by ARC_HEIGHT. Turning the drift value down flattens the sideways sway to almost nothing; turning it up sends the plane swinging well past its own straight-line path — closer to a page curl effect happening mid-air than a flat toss.

Paper Airplane That Flies by a Parabolic Drift Value
Open in a new tab

07 · Paper Airplane That Flies by a Parabolic Drift Value — Measured area 20.715% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DRIFT_AMPLITUDE_PXdriftFlightdrawPlane
  function driftFlight(t) {
    var x = lerp(LAUNCH.x, LAND.x, t) + Math.sin(t * DRIFT_CYCLES * Math.PI * 2) * DRIFT_AMPLITUDE_PX;
    var y = lerp(LAUNCH.y, LAND.y, t) - ARC_HEIGHT * 4 * t * (1 - t);
    return { x: x, y: y };
  }

  var pos = driftFlight(0), heading = 0, curT = 0;

Measured: 20.7% cumulative area, 188.1 average intensity, 23/23 frames moved.

08Paper Doll Whose Joint Chain Swings by a Linked Value

jointAngle() offsets every joint's sine wave by chainIndex * FK_DELAY_RAD and shrinks its swing by Math.pow(0.82, chainIndex), so fkChain() walks from shoulder to fingertip adding each damped, delayed angle onto the last instead of swinging every segment in lockstep. The result reads as a chain reaction — the shoulder leads, and each joint after it follows a beat later and a little less far.

Paper Doll Whose Joint Chain Swings by a Linked Value
Open in a new tab

08 · Paper Doll Whose Joint Chain Swings by a Linked Value — Measured area 17.033% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FK_DELAY_RADjointAnglefkChain
  function jointAngle(t, def, chainIndex) {
    var w = Math.PI * 2 * t;
    var sway = Math.sin(w - chainIndex * FK_DELAY_RAD) * rad(SWING_AMP_DEG) * Math.pow(0.82, chainIndex);
    return def.base + sway;
  }

  function swayFactor(t) { return Math.sin(Math.PI * 2 * t); }

  function fkChain(originX, originY, segs, t) {
    var x = originX, y = originY, cum = 0;
    var pts = [{ x: x, y: y }];
    for (var i = 0; i < segs.length; i += 1) {
      cum += jointAngle(t, segs[i], i);
      x += Math.cos(cum) * segs[i].seg[0];
      y += Math.sin(cum) * segs[i].seg[0];
      pts.push({ x: x, y: y });
    }
    return pts;
  }

Measured: 17.0% cumulative area, 95.2 average intensity, 23/23 frames moved.

09Hero Desk Scene That Combines the Page-turn Techniques Above

One compositeValue in valueStep() drives gutterBend()'s open-book curvature, curlPage()'s loose sheet radius, and the paper airplane's launch phase together, so items 1, 4, and 7 above share a single number instead of three separate timers. Watching that one value climb shows exactly how much of the desk's overall arrangement each of the three techniques actually contributes.

Hero Desk Scene That Combines the Page-turn Techniques Above
Open in a new tab

09 · Hero Desk Scene That Combines the Page-turn Techniques Above — Measured area 17.499% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CURL_RADIUS_MAX_PXcompositeValuedrawBook
  function curlPage(t) {
    var radius = 6 + t * CURL_RADIUS_MAX_PX;
    var stripCount = 14;
    for (var i = 0; i < stripCount; i += 1) {
      var u = i / stripCount;
      var localX = u * CURL_W;
      var theta = Math.max(0, localX - (CURL_W - radius * 2.4)) / Math.max(1, radius);
      theta = clamp(theta, 0, Math.PI);
      var sx = CURL_CX + CURL_W - CURL_W * (1 - u) - Math.min(localX, CURL_W - radius * 2.4);
      var screenX = CURL_CX + (CURL_W - radius * 2.4) * u * 0 + localX - (1 - Math.cos(theta)) * radius;
      var front = Math.cos(theta) >= 0;
      var bright = front ? (0.6 + 0.4 * Math.cos(theta)) : (0.32 + 0.18 * (-Math.cos(theta)));
      ctx.fillStyle = front ? shadeHex(PAPER, bright) : shadeHex(PAPER_BACK, bright);
      var stripX = CURL_CX + localX - (localX > (CURL_W - radius * 2.4) ? (1 - Math.cos(theta)) * radius : 0);
      ctx.fillRect(stripX, CURL_TOP, CURL_W / stripCount + 1, CURL_BOT - CURL_TOP);
    }
  }

Measured: 17.5% cumulative area, 123.2 average intensity, 23/23 frames moved.

Where the Value Shapes the Paper — Curl Radius, Crease Angle, Drift Amplitude

Item Cumulative area Avg. intensity Moved frames
01 Corner-drag curl 35.3% 90.2 23/23
02 Fold-line shadow flip 41.6% 73.4 20/23
03 Multi-page stack flip 45.4% 75.7 18/23
04 Gutter bend shade 31.9% 22.8 23/23
05 Origami crease fold 29.1% 104.3 23/23
06 Noise-shaded crumple 50.6% 20.7 23/23
07 Drift-arc airplane 20.7% 188.1 23/23
08 FK joint-chain doll 17.0% 95.2 23/23
09 Desk hero composite 17.5% 123.2 23/23

Item 6's crumpled surface covers the most pixel area of the nine (50.6%) while sitting at one of the lowest average intensities (20.7) — a slow, broad shading shift rather than a sharp jump. Item 7's paper airplane sits at the opposite corner: its 188.1 is the highest average intensity of the nine while its area (20.7%) stays modest, because a small plane crossing the frame changes very few pixels but changes them hard, frame to frame. Three constants drive most of that spread: CURL_RADIUS_PX and CURL_RADIUS_MAX_PX (items 1 and 9) decide how wide a curled edge reads, CREASE_ANGLE_DEG (item 5) decides how deep a fold scales a facet's whole width, and DRIFT_AMPLITUDE_PX (item 7) decides how far a moving point swings off its straight line — each one is really the same kind of number, read at a different place on the canvas.

Where a Page Curl Like This Actually Earns Its Place

Item 1's corner drag and item 5's crease both belong on a page reader or an about-page intro object, somewhere a reader's own drag or key press should feel like it is bending real paper, not just triggering a canned transition. Items 2 through 4 fit a gallery's next-page button, a catalog or lookbook viewer, and a recipe or manual's two-page spread — each one autoplays because a reader clicked forward, not because a cursor happened to pass by. Item 6's noise-shaded texture sits well behind a memo or sticky-note card, items 7 and 8 both read as empty-state or loading illustrations more than functional controls, and item 9's desk scene is built for a landing page hero, where showing three techniques driven by one value matters more than any single one of them alone.

Where It Breaks — Reduced Motion, Low-end Devices, and Layout Shift

All nine set .is-demo only for the autoplay loop a requestAnimationFrame tick drives; items 1, 5, and 9 additionally answer a real pointerdown/pointermove pair or the arrow keys, and item 3 answers Enter, Space, or ArrowRight to turn its next page — none of that input handling depends on the loop being on. prefers-reduced-motion: reduce removes is-demo and freezes each pattern at one representative state instead of its starting frame — item 4's gutter locks at 60% of BEND_MAX_RAD, item 3 stops with two of its five pages already turned, item 8 stops mid-swing at a quarter cycle — so a reduced-motion reader still sees a folded, not a flat, page. Layout shift is the trap worth watching separately: every canvas here is a fixed 480×300 box sized before the first paint, but a reader who resizes a browser window won't see items 2 through 4's fold line or items 1 and 9's curl re-center until the next resize event fires fit() again, so a quick resize can catch the fold sitting a few pixels off from where the page edge actually is. The 3zppfspp that opens every one of these nine index.html/style.scss pairs sits in this paragraph — read this far and you already know which constant you would bend first.

Three Variants and What prefers-reduced-motion Leaves Standing

Variant Value change What shifts
Adjust the page-curl radius value CURL_RADIUS_PX 60 → 160 Shortening the value curls only a narrow corner for a slight fold; lengthening it curls a much wider arc that reads almost like a cylinder
Adjust the crease-angle value CREASE_ANGLE_DEG 30 → 120 Shortening the value bends the crease only slightly, leaving the paper nearly flat; lengthening it folds it deeply into a fully creased 3D shape
Adjust the paper-airplane drift value DRIFT_AMPLITUDE_PX 8 → 30 Shortening the value sends the plane flying almost in a straight line; lengthening it makes it swing widely side to side as it flies

None of the three variants touch what reduced motion removes above — CURL_RADIUS_PX at 60 or at 160 still locks to the same representative curl depth under prefers-reduced-motion: reduce, and DRIFT_AMPLITUDE_PX at 8 or at 30 still freezes mid-flight rather than continuing to drift. What accessibility still needs regardless of which constant moves: none of the nine canvases carry any text a screen reader would otherwise read, so each one needs a plain-language alt or an aria-label on its wrapper in real use, since the canvas itself stays silent.

How Is a Paper Fold Animation Different From This Page Curl?

A paper fold animation usually means an origami-style piece — like item 5 above — folding along one or more straight creases into a new flat shape, judged by how convincingly the facets keep their proportions as they fold. A page curl, the search behind this post, is narrower: it only bends one edge of a single sheet, often called a page curl effect when a pointer drives it instead of a fixed timer, and it succeeds by looking like paper specifically — a slight give at the edge, a shadow that thickens as the curl deepens — rather than by folding into a wholly new shape. Item 9's desk scene shows both ideas side by side: its book's gutter bends the way item 4's spread does, its loose sheet curls the way item 1's corner does, and neither one is a page flip animation in the strict sense, since nothing here swaps between two fixed page images — every frame above is drawn, not swapped.

Questions Worth Answering Before You Copy a Page Curl

Can a Free Page-turn CSS Library Replace These Nine Functions?

A ready-made library can replace items 2, 3, and 4 above — anything that swaps between two flat page images on a timer or a click. It can't replace items 1, 5, 6, 7, 8, or 9, though, since those redraw pixel positions from a live value every frame (a drag offset, a crease angle, a noise field, a drift phase) rather than crossfading between two fixed states a library ships.

Why Does Item 9 Share One Value Across Three Different Techniques?

Because valueStep() only ever moves compositeValue, and gutterBend(), curlPage(), and the plane's launch phase all read that same number back rather than keeping three separate timers in sync by hand. A paper flip animation css scene built this way is really one number driving three drawing functions at once — the desk composition is compositeValue viewed from three angles.

Does Redrawing a Curl Every Frame Cost More Than a CSS-only Flip?

Yes, proportionally to the strip count each item loops over — items 1 and 9 redraw 14 to 24 vertical strips per frame with a Math.sin/Math.cos pair each, while a CSS rotateY() flip like item 2's underlying transform only asks the compositor to redraw a whole flat rectangle once per frame. None of the nine trigger layout, since every canvas keeps a fixed 480×300 box regardless of what is drawn inside it, so the added cost stays in paint, not reflow.

For other patterns built the same way — one value read every frame instead of a fixed transition — 9 CSS Card Flip Animations (Copy-Paste, 20 Lines) is the closest relative to items 2 through 4 above, since both sets rotate a flat rectangle around a fixed axis; 9 Scrollytelling CSS Section Patterns, Copy-Paste hands the driving value to scroll progress instead of a drag or a timer; and Verlet Physics 9 Demos: No Library, Just Canvas is worth comparing item 8's chained joints against, since both build motion from several linked points instead of one.

Enter the archive password

The password is inside this article. You will find it as you read.