Hover the corner, then click
Page Curl Reveal
A dark page covers a promo code: on hover its top-right corner curls from 34 to 150 px (polygon clip-path + gradient fold, 550 ms), on click it pulls away and the content appears — a native button with aria-pressed, six lines of JS. Measured: the shipped click value (380 px) leaves a 26 px triangle in the bottom-left corner.
This effect is free — the Cards category contains 41 effects total, including 6 free. Effect.Labs has 811 vanilla effects. Explore the category →
Usage examples
Hover the corner, then click
Hover the corner, then click
How it works
.pcr-card is a 250 × 160 px button (position: relative; overflow: hidden; border-radius: 12px, #101018 background, white border at 8%) stacking three layers in position: absolute; inset: 0: .pcr-under, the reward (centered grid, green radial-gradient halo rgba(34,255,170,.18) at 70% 20% on #0d1512, code at 1.25 rem #22ffaa, .68 rem uppercase sub-line #7fbfa8), .pcr-page on top (135° gradient #262640 → #16162a, .8 rem text #cfcfe8), and .pcr-curl, the fold, anchored top-right and ignoring the pointer. One length drives everything: --pcr-c, the size of the removed corner.
The cut: .pcr-page carries clip-path: polygon(0 0, calc(100% − c) 0, 100% c, 100% 100%, 0 100%) — a pentagon whose top-right vertex is replaced by a 45° diagonal that removes a right triangle with legs c. At rest c = 34px; on :hover and :focus-visible, c = 150px. The variable isn't registered with @property, so it jumps from one value to the other — but the transition is on clip-path itself (.55s cubic-bezier(.25,.8,.3,1)): the browser interpolates between the two computed polygons, which have the same number of vertices, and the fold glides. No @property needed here, unlike effects that animate the variable.
The fold: .pcr-curl is a c × c square (width and height transitioned over .55 s) painted with linear-gradient(225deg, transparent 48%, #3d3d66 50%, #52528a 70%, #2c2c4d 100%) — the half facing the removed corner is transparent, the other half draws the back of the page with a light #52528a center between two darker edges; border-bottom-left-radius: 55% rounds the loop of the fold and box-shadow: -5px 5px 12px rgba(0,0,0,.45) sets it on the page. Because the cut and the fold share c, the fold's hypotenuse and the cut edge coincide at every instant.
The click: the script (six lines) sets aria-pressed="false" on every .pcr-card and flips it on click. [aria-pressed="true"] pushes c to 380 px on the page (also under :hover, so hovering can't close it) and the fold to 380 × 380 px with opacity: 0 (.6 s transition, opacity .35 s delayed by .3 s). Measured in Chromium: at 380 px on a 250 × 160 card the diagonal (x − y = −130) still leaves a right triangle with 26 px legs in the bottom-left — 331 page pixels remain visible after the click; the page only disappears from c = width + height, i.e. 410 px. Second click: back to 34 px. prefers-reduced-motion: reduce removes both transitions.
Accessibility
- prefers-reduced-motion handled:
.pcr-page, .pcr-curl { transition: none }— states apply instantly, the reveal stays complete. One of the few effects in the catalog shipped with this rule. - Semantics: a native
buttonwhosearia-pressedtoggles — announced as a toggle button, activatable with Enter and Space. But thearia-labelreplaces the content: a screen reader never hears “WELCOME20”, the reward is invisible to it. Put the code in the label once revealed (aria-label="Promo code WELCOME20, 20% off everything"whenaria-pressedturns true), and translate it: the shipped HTML is in French. The threedivs inside thebuttonare invalid (a button only accepts phrasing content): replace them withspans set todisplay: blockorgrid. - Contrast measured (Chromium, pixels under the text): page text
#cfcfe8at 12.8 px on the#262640 → #16162agradient = 9.6 to 11.6:1 (AAA); code#22ffaaat 20 px on#0d1512= 14:1, 9:1 at the heart of the halo; sub-line#7fbfa8at 10.9 px = 8.75:1, 5.6:1 on the halo — everything exceeds 4.5:1, no color to change. Only the 10.9 px size is small for useful information. - Keyboard: Tab reaches the button,
:focus-visibledrawsoutline: 2px solid #a78bfaat 3 px and curls the corner to 150 px — the same preview as hover; Enter or Space reveals. Touch: no hover, a tap reveals directly (the 150 px preview doesn't exist under a finger); target 250 × 160 px. - The
buttondoesn't inherit the page font: withoutfont: inherit, the text renders in the browser's default button font (Arial 13.33 px in Chromium) — rem sizes follow the root, the family doesn't. And the cursor staysdefaultover a clickable card: addfont: inherit; cursor: pointer.
Browser compatibility
Requires clip-path: polygon() with calc() and var(), transitions on clip-path, the inset shorthand and place-items — the limiting property is inset. ES5 JavaScript, zero dependencies.
Without JavaScript, hover and focus still curl the corner to 150 px, but nothing reveals the code: add a server-side fallback or show the code elsewhere. Without clip-path transitions (very old browsers), states jump at once; without inset, the three layers have neither size nor position and only the card background remains.
The code
Copy the three blocks into your page. No dependencies.
<button class="pcr-card" type="button" aria-label="Survoler ou activer pour corner la page et révéler le code promo">
<div class="pcr-under"><div><strong>WELCOME20</strong><span>-20 % sur tout</span></div></div>
<div class="pcr-page">Survolez le coin ↗</div>
<span class="pcr-curl" aria-hidden="true"></span>
</button>
.pcr-card {
position: relative;
width: 250px;
height: 160px;
border-radius: 12px;
overflow: hidden;
background: #101018;
border: 1px solid rgba(255,255,255,.08);
}
.pcr-under {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-align: center;
background: radial-gradient(circle at 70% 20%, rgba(34,255,170,.18), transparent 55%), #0d1512;
}
.pcr-under strong {
font-size: 1.25rem; color: #22ffaa; letter-spacing: .06em
}
.pcr-under span {
display: block; font-size: .68rem; color: #7fbfa8; margin-top: 4px; letter-spacing: .12em; text-transform: uppercase
}
.pcr-page {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: linear-gradient(135deg, #262640, #16162a);
color: #cfcfe8;
font-size: .8rem;
letter-spacing: .04em;
clip-path: polygon(0 0, calc(100% - var(--pcr-c)) 0, 100% var(--pcr-c), 100% 100%, 0 100%);
transition: clip-path .55s cubic-bezier(.25,.8,.3,1);
--pcr-c: 34px;
}
.pcr-curl {
position: absolute;
top: 0; right: 0;
width: var(--pcr-c);
height: var(--pcr-c);
--pcr-c: 34px;
background: linear-gradient(225deg, transparent 48%, #3d3d66 50%, #52528a 70%, #2c2c4d 100%);
border-bottom-left-radius: 55%;
box-shadow: -5px 5px 12px rgba(0,0,0,.45);
transition: width .55s cubic-bezier(.25,.8,.3,1), height .55s cubic-bezier(.25,.8,.3,1);
pointer-events: none;
}
.pcr-card:hover .pcr-page, .pcr-card:focus-visible .pcr-page {
--pcr-c: 150px
}
.pcr-card:hover .pcr-curl, .pcr-card:focus-visible .pcr-curl {
width: 150px; height: 150px
}
.pcr-card:focus-visible {
outline: 2px solid #a78bfa; outline-offset: 3px
}
.pcr-card[aria-pressed="true"] .pcr-page {
--pcr-c: 380px
}
.pcr-card[aria-pressed="true"] .pcr-curl {
width: 380px; height: 380px; opacity: 0; transition: width .6s cubic-bezier(.25,.8,.3,1), height .6s cubic-bezier(.25,.8,.3,1), opacity .35s ease .3s
}
.pcr-card[aria-pressed="true"]:hover .pcr-page {
--pcr-c: 380px
}
@media (prefers-reduced-motion: reduce) {
.pcr-page, .pcr-curl {
transition: none
}
}
(function() {
/* fx-0775 — Page Curl Reveal : clic = révélation complète (toggle), survol = pli */
document.querySelectorAll('.pcr-card').forEach(function(card) {
card.setAttribute('aria-pressed', 'false');
card.addEventListener('click', function() {
card.setAttribute('aria-pressed', card.getAttribute('aria-pressed') !== 'true');
});
});
})();
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
--pcr-c at rest (CSS — .pcr-page and .pcr-curl) |
34px | Size of the curled corner at rest. 0 = flat page with no invitation; 48px = clearly visible corner. Set the same value on both elements: the variable is declared on each, not inherited between siblings. |
--pcr-c on hover (CSS — :hover / :focus-visible, + fold width/height) |
150px | Size of the preview. The fold is opaque and lies on the page: at 150 px on a 250 px card it already covers the right half of the centered label (visible in the demo). Drop to 110 px to keep the label whole. Three values to change together: the page variable, the fold's width and height. |
--pcr-c on click (CSS — [aria-pressed="true"], two rules) |
380px | Must be at least width + height of the card (410 px for 250 × 160) for the page to disappear; at 380 px a 26 px triangle remains bottom-left. Allow 10 px of margin. |
| Duration and curve (CSS — transition .55s cubic-bezier(.25,.8,.3,1)) | .55s | Decelerating curve: the fold starts fast and settles. .35s = crisp; .8s = heavy paper. The three transitions (clip-path, width, height) must stay in sync or the fold drifts off the cut. |
| Back of the page (CSS — .pcr-curl, linear-gradient 225deg) | transparent 48% → #3d3d66 → #52528a 70% → #2c2c4d | The fold's material. Lighten the 70% stop for glossy paper; keep the transparent stop under 50% so the empty triangle stays aligned with the cut. |
| Dimensions (CSS — .pcr-card width / height) | 250 × 160 px | Card format. Recompute the click value (width + height) and, on a wide card, the hover value. The layers follow through inset: 0. |
| Hidden content (HTML — .pcr-under) | code + sub-line | Free HTML in a centered grid: text, emoji, image. Everything in it is hidden on screen but present in the DOM — read by screen readers unless the aria-label overrides it, copyable with Ctrl+A. |
FAQ
After the click, a small corner of the page stays visible bottom-left — why?
Geometry. The cut removes everything above the diagonal x − y = width − c; at c = 380px on 250 × 160, the diagonal passes through (0, 130) and (30, 160): the triangle below it, 26 px per leg, stays covered by the page. Measured in Chromium: 331 page pixels after the click. You need c ≥ width + height (410 px); raise both [aria-pressed="true"] rules to 420px.
The --pcr-c variable isn't registered with @property: how can the corner glide?
Because the variable isn't what's animated — clip-path, width and height are, and their computed values change when the variable changes. The browser interpolates two polygons with the same number of vertices — which is why the pentagon keeps five points even at rest. @property would only be needed to animate --pcr-c itself.
Can I reveal on hover alone, without a click — or remove the hover preview?
For a revealing hover: raise the :hover rules to --pcr-c: 420px and the fold to 420 px, drop the JS; the page comes back as soon as the mouse leaves. To remove the preview (touch behavior everywhere): delete the :hover rules and keep the :focus-visible ones. To close automatically: a setTimeout that sets aria-pressed back to false after a few seconds.