skip to content
lav.ren / writing / 2026-04-six-attempts-at-folding-paper
Published
2026.04.25
essay

Six attempts at folding paper with CSS

A corner-fold effect that took two days and six approaches — from empty clip-path overlays through Three.js mesh deformation back to pure CSS with a single quadratic bezier.

This is what it looks like when it works:

Hello

Grab my corners to peel — pure CSS, no WebGL.

Projects

Solar garden simulator, AoC solver, ink playground — built with Astro + React.

About

Full-stack engineer. Paper by day, paper by night. Based in Belgium.

Blog

Thoughts on web performance, developer tooling, and creative coding experiments.

Controls

DirectSpringyToy
0.00

Grab any corner and drag. The corner straightens from a rounded arc into a diagonal, a small fold-over appears, then grows as you pull further. Release and it springs back. Pure CSS — no WebGL, no canvas, no texture capture. The DOM content is always the real DOM.

Getting here took six attempts over two days. Three CSS approaches, two WebGL approaches, then back to CSS. Here’s what failed, what I learned, and why the final solution is fourteen lines of clip-path math.


Attempts 1–3: CSS clipping has no exceptions

The cards on this site look like paper on a desk — warm cream, subtle shadow, slight tilt. The fold effect needed to extend that metaphor: grab a corner, peel it back, reveal the paper’s underside.

Attempt 1 started simple. Apply clip-path: polygon() to empty overlay <div> elements inside the card — a front face and a back face. The clip-path would carve the fold shape. The overlays were transparent and empty, so the clip-path was invisible. The card looked exactly the same. Clip-path on nothing is nothing.

Attempt 2 applied the clip-path to the card itself (hiding the folded corner) and placed a paper-colored flap <div> inside it to show the fold. This is where I learned a fundamental CSS rule: clip-path on a parent clips all descendants. The flap, being a child of the card, was clipped along with the content. A child element cannot escape its parent’s clip boundary. There is no clip-path: escape or overflow: visible workaround. This is how the CSS rendering model works.

Attempt 3 fixed the DOM structure. The flap became a sibling of the card — placed in the card’s parent container, positioned absolutely to overlap the corner. The card’s clip-path hid the corner, and the sibling flap (unaffected by the card’s clip) showed the paper-back color in the gap. This worked for the flat fold: a right triangle clipped from the corner, a reflected triangle on top showing the paper’s underside.

But the content didn’t bend. The fold was a rigid flat triangle. The text on the folded portion stayed flat. For a real paper fold, the content should curve with the fold. CSS 3D transforms (rotateX, rotateY) can position an element in 3D space, but they can’t deform sub-parts of it. The element is always a flat rectangle, just tilted.

The lesson: CSS can cut shapes and position rigid planes in 3D. It cannot deform content within an element. For text that bends, you need something that operates on the pixel level.


The Three.js detour

If CSS can’t deform content, WebGL can. Three.js lets you create a subdivided plane mesh, texture it with a snapshot of the card, and displace individual vertices to simulate a fold. The math is beautiful: vertices on the peeled side of a fold line rotate around it, tracing a cylindrical arc. A damped spring drives the animation.

Attempt 4 built a standalone demo with vanilla Three.js. The vertex deformation worked perfectly — the card curled like real paper. But the texture was wrong. The card content had to be rasterized into a Canvas texture before mapping onto the mesh. Three approaches to rasterization, three failures:

  • foreignObject SVG serialization couldn’t resolve CSS custom properties (var(--paper)) and couldn’t load self-hosted fonts from a blob URL context. The texture was black.
  • Canvas 2D manual drawing (background fill, word-wrapped text) produced visible font and layout differences. Different font renderer, different kerning.
  • html2canvas (a 40KB library specifically for DOM-to-canvas) got closest, but the colors were still slightly grey and the fonts slightly off.

The transition from DOM card to Three.js mesh was always visible. A 150ms cross-fade masked it somewhat, but the card “popping” to a slightly different appearance broke the illusion. You could see the moment the DOM disappeared and the texture took over.

Attempt 5 moved to React Three Fiber within the Astro build pipeline (the site is Astro + React islands). This fixed the CDN reliability issue and gave us tree-shaking, but the texture fidelity problem was identical — it’s a rasterization problem, not a framework problem.

We even tried a hybrid approach: keep the DOM visible for the flat portion, use Three.js only for the deformed region, and clip-path the DOM to hide the folded area. The clip-path on the DOM and the mesh fold line had to align pixel-perfectly. They didn’t.

Two adversarial AI agents independently audited the geometry math and found a real bug: the bottom-right corner’s arc endpoints were swapped in the buildFlapArc function, causing the arc to bulge outward instead of rounding inward. Both agents confirmed it. Even with the fix, the fundamental problem remained: DOM-to-texture rasterization is lossy by definition. No capture method will ever produce pixels identical to the browser’s own rendering.

The lesson: if the DOM card and the Three.js mesh will ever be visible simultaneously (or in rapid succession), the fidelity mismatch is unavoidable. The only seamless solution is to never replace the DOM.


The insight that unlocked it

The pivot came from a simple question: does the content actually need to bend?

For a portfolio site, the fold is a decorative detail. Visitors aren’t inspecting the text curvature at the fold line. They’re noticing that the card feels like paper — that grabbing a corner does something physical. The visual cue that sells “paper” is the fold-over triangle showing the paper’s underside, the shadow at the fold line, and the spring-back animation. None of these require the text to deform.

A rigid flat fold — a triangle clipped from the corner with a reflected triangle on top — is physically correct for stiff paper or cardboard. And it keeps the DOM visible at all times. No rasterization, no texture, no transition pop.

The challenge was making it feel smooth. The earlier CSS attempts (attempt 3) had a jarring jump between “normal rounded corner” and “folded corner.” The solution was doing the entire card shape through clip-path: path() — including the rounded corners that would normally be border-radius.


Three phases and a bezier

The card’s shape is always a clip-path: path() that traces a rounded rectangle. Three corners use standard SVG arcs (A 10 10 0 0 1 ...). The fourth — the folding corner — uses a quadratic bezier (Q) whose control point interpolates between the corner vertex and the diagonal midpoint:

  • Control at corner vertex → tight arc, matches the normal border-radius
  • Control at diagonal midpoint → straight line, the fold diagonal

Sliding the control point between these two positions morphs the corner smoothly from round to straight. No phase switching, no transition, one continuous path.

The fold progresses through three phases as the drag amount increases from 0 to 1:

LIFT (0 → 0.3): The corner morphs from its rounded arc toward a straight diagonal. The clip size stays fixed at the crease size (configurable, ~22px default). A radial shadow appears at the corner, selling the illusion of the paper lifting off the desk. No flap visible yet.

FOLD (0.3 → 0.6): The clip holds at crease size. The fold-over flap grows from an invisible sliver to fill the crease. The flap’s hypotenuse is geometrically locked to the clip’s diagonal — the reflected corner vertex lerps from the hypotenuse midpoint (degenerate line) to the full reflected corner (complete triangle).

PULL (0.6 → 1.0): The clip and flap are locked together at the same size. Both grow from the crease size to the maximum fold size. This is the “keep pulling” phase — the fold gets bigger.

The flap is always a sibling element (lesson from attempt 2), positioned absolutely in the card’s parent, with z-index: 2 above the card’s z-index: 1. Its inner corner has a rounded arc (via SVG path A command) that scales with the fold size.

The spring physics are a simple damped spring (F = -kx - dv) with a configurable feel parameter that maps to stiffness/damping/mass. The default (feel = 0) is fully direct — no bounce, immediate response. Drag starts only after 3px of movement to avoid accidental activation.


What I’d tell myself on day one

Don’t rasterize the DOM. The browser’s renderer is the only thing that can render the DOM identically to itself. Any capture pipeline — foreignObject, html2canvas, Canvas 2D — introduces a fidelity gap. If the original DOM will be visible next to the captured version, the gap is visible.

Start with the question “what visual cue actually sells the effect?” before choosing the technology. For paper folds, it’s the triangle + shadow + spring, not content deformation. CSS can do all three.

And check your arc sweep flags. Both of them.