Skip to main content

Full-bleed reveals & curtains

Four scroll-driven reveal effects that don't need a horizontal track: an image that grows from a centred rectangle to fill the screen, a giant word whose letters become a full-screen photo, a closing section that lifts like a curtain to uncover the footer, and a grid of photos that flies apart around a rising headline.

Reveal from the centre​

A background image grows from a small, centred rectangle to fill the whole pinned stage while the page scrolls, a dark layer fades in over it, and the heading and text rise in on top, once the image is nearly full.

Structure (a 170vh pinned scene, pinned 37–63%, stage content aligned to the bottom):

div .pin__stage justify-content: flex-end
├─ div .reveal__media "Get parent" + Custom, Start 30, End 60, From 50
│ ├─ img .reveal__image
│ └─ div .reveal__shade
└─ div .reveal__content "Get parent" + Fade 50–60 + Bottom-to-top 50–60

Fade writes opacity and Bottom-to-top writes transform, so both presets can safely share the content element.

CSS:

.reveal__media {
position: absolute; inset: 0; z-index: -1;
clip-path: polygon(
calc(50% - 50% * var(--fa-scroll--custom)) calc(50% - 50% * var(--fa-scroll--custom)),
calc(50% + 50% * var(--fa-scroll--custom)) calc(50% - 50% * var(--fa-scroll--custom)),
calc(50% + 50% * var(--fa-scroll--custom)) calc(50% + 50% * var(--fa-scroll--custom)),
calc(50% - 50% * var(--fa-scroll--custom)) calc(50% + 50% * var(--fa-scroll--custom)));
}
.reveal__image { width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
.reveal__shade {
position: absolute; inset: 0;
background: rgb(0 0 0 / .6);
opacity: var(--fa-scroll--custom, 1);
}
.reveal__content { position: relative; z-index: 1; color: #fff; }

Setting From to 50 makes --fa-scroll--custom start at 0.5, so the image is already a half-size rectangle the moment the reveal begins, rather than starting from a single point. Without the animation running, the clip-path becomes invalid (so the image shows full-size) and the shade stays fully on — giving a full image with readable text as the fallback.

Values you can change: From (50 = starts half-size; 0 = starts from a point; 70 = starts as a large frame already), the window (30–60 here: starts while the scene is still arriving, ends just before the stage would release — keep the end inside the pinned window), the shade colour/ strength, the content's own window (so the text arrives once the image is nearly full), and a circular reveal instead of rectangular — swap the Custom row and clip-path for the built-in Circle preset on .reveal__media instead.

  • Media: "Get parent" + one Custom row, Start 30, End 60, From 50.
  • Content: "Get parent" + a Fade row and a Bottom-to-top row, both Start 50, End 60.
  • The image inside the media element: native Object fit "cover", border radius 0.

Common problems:

  • Rounded corners show up once the image is meant to be full-screen. Many themes round every img by default — set border-radius: 0 on the image's own class.
  • The clip does nothing, or the image jumps. A second preset writing clip-path on the same element (Sweep, Circle, Blinds) is overriding it — keep one clip-path source per element.
  • The content's own transform is lost. Bottom-to-top already writes transform — put any extra transform you need on a wrapper instead.

Giant text mask​

A short word, filled with a photo, sits centred in a pinned stage. As the page scrolls, the letters grow far beyond the edges of the screen, so the photo inside them ends up covering everything — then a dark layer and the real page content fade in on top.

Structure (a 200vh pinned scene, pinned 33–67%, stage height: 100vh):

div .pin__stage position: sticky; isolation: isolate
├─ p .mask__word "Get parent" + Custom, Start 35, End 55
├─ div .mask__shade "Get parent" + Custom, Start 45, End 50
└─ div .mask__content "Get parent" + Fade, Start 45, End 60

CSS:

.mask__word {
--mk-size: 129px;
position: absolute; inset: 0; z-index: 0; margin: 0;
height: 100vh; overflow: hidden;
display: flex; justify-content: center; align-items: center;
white-space: nowrap; text-align: center; font-weight: 900;
font-size: calc(var(--mk-size) + 8900px * var(--fa-scroll--custom, 0));
letter-spacing: calc(-.5em * var(--fa-scroll--custom, 0));
transform: scale(calc(.99 + var(--fa-scroll--custom, 0) * .1));
color: transparent;
background: url("your-image.jpg") center / cover no-repeat;
-webkit-background-clip: text; background-clip: text;
}
.mask__shade { position: absolute; inset: 0; z-index: 1; background: rgb(0 0 0 / .6); opacity: var(--fa-scroll--custom, 1); pointer-events: none; }
.mask__content { position: relative; z-index: 2; color: #fff; }

On phones, shrink --mk-size further (59px below 768px, 45px below 479px). Without the animation the shade stays on, so the white page content stays readable as the fallback state.

Values you can change: --mk-size (the word's starting size — pick it so the word roughly fills the width of the screen), 8900px (how large the letters eventually get — the photo inside them must cover the whole screen through a single letter by the end), and the three windows (word 35–55, shade 45–50, content 45–60).

  • Word (as a Basic Text element, tag p): "Get parent" + one Custom row, Start 35, End 55; use the image as the element's class background and write the rest of the CSS in custom CSS.
  • Shade: Custom row, Start 45, End 50. Content: Fade row, Start 45, End 60. Both "Get parent".
  • Phone sizes go in that breakpoint's custom CSS field.

Common problems:

  • Choppy motion on slower laptops. font-size changes the whole text layout every single frame — keep the word short, and check smoothness on a mid-range laptop, not just a fast one.
  • The word wraps onto two lines as it grows. Missing white-space: nowrap.
  • Black or empty-looking letters in Safari. The -webkit-background-clip: text prefix is missing alongside the unprefixed property.
  • The stage stops sticking at one particular screen size. An overlay gradient set directly on the sticky stage at that breakpoint — keep any darkening in its own separate shade element instead.

At the very end of the page, the closing block (a call to action) scrolls up, shrinks slightly and rounds its bottom corners, uncovering the footer underneath, which doesn't move at all.

Structure (the closing block and the footer must be siblings inside the same section):

section .curtain background = the footer's own colour; padding 0; overflow-x reset
├─ div .curtain__closing Custom, Start 50, End 65 (measures itself), Disable from tablet
│ └─ div .curtain__panel the visible block: background, padding
│ └─ div .curtain__inner heading, text, button
└─ footer .curtain__footer position: sticky; bottom: 0

The reveal itself is plain CSS: an opaque block on top scrolls away and uncovers a footer stuck to the bottom beneath it. Nothing here is actually moved by the scroll value, so nothing visibly lags behind by a frame.

CSS:

.curtain {
padding: 0; overflow-x: initial; background: #18181b;
--ct-shrink: .05; --ct-corner: 2rem; --ct-fade: .6; --ct-rise: 2rem;
}
.curtain__closing {
position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: stretch;
--ct-h: calc(var(--fa-scroll--custom) * 0vh + 100vh);
--ct-r: var(--fa-scroll--custom, 0);
min-height: var(--ct-h, auto);
}
.curtain__panel {
flex-grow: 1; display: flex; flex-direction: column; justify-content: center; align-items: center;
background: #fafafa; padding: 6rem 1.5rem;
transform-origin: 50% 0;
transform: scale(calc(1 - var(--ct-shrink) * var(--ct-r)));
border-bottom-left-radius: calc(var(--ct-r) * var(--ct-corner));
border-bottom-right-radius: calc(var(--ct-r) * var(--ct-corner));
will-change: transform;
}
.curtain__inner {
opacity: calc(1 - var(--ct-fade) * var(--ct-r));
transform: translateY(calc(var(--ct-r) * -1 * var(--ct-rise)));
}
.curtain__footer { position: sticky; bottom: 0; z-index: 0; color: #d4d4d8; padding: 3rem 1.5rem 1.5rem; }
@media (max-width: 991px) { .curtain__footer { position: relative; } }

Exact timing without needing to know the footer's actual height: while animated, the closing block is exactly one screen tall, so on its own progress, 50% means its bottom edge is level with the bottom of the screen, and 65% means it's already lifted by 30% of the screen. Put the visual effects on the panel, never on the measured block itself — the plugin measures the box after transforms.

Values you can change: --ct-shrink/--ct-corner/--ct-fade/--ct-rise on .curtain, and the End value (65 here — later means a slower lift). The section's background matches the footer's colour on purpose: whatever gap the shrinking panel leaves always matches the footer behind it.

  • Section: padding 0, background = footer colour, plus the overflow-x reset in custom CSS.
  • Closing block: "Disable from tablet" + one Custom row, Start 50, End 65 — no "Get parent".
  • Footer: native Position "sticky", Bottom 0, with Position "relative" set for the tablet breakpoint instead of a hand-written @media rule.

Common problems:

  • The top of the footer never actually shows. A sticky footer taller than the window simply can't show its own top edge — keep it under roughly 600px tall on desktop; on tablet and phone it reverts to being a normal, non-sticky footer anyway.
  • The timing feels off. The scale was applied to the measured block itself instead of the panel — move it to the panel.
  • The footer jumps, or doesn't stick at all. Missing overflow-x reset on the section, or the footer isn't inside the same section as the closing block.
  • A visible gap or colour strip between the panel and the footer. The section's background doesn't actually match the footer's.

Fan out​

Two columns of images in the middle of a normal (not pinned) section fly outward to the left and right as it scrolls past, each with its own distance and angle, while a heading in the middle rises up through them. No pinned stage required here.

Structure:

section .fan overflow: hidden (the images leave the frame)
└─ div .fan__container "I am parent", position: relative
├─ div .fan__media 2-column grid
│ ├─ div .fan__left > img .fan__image ×3 "Get parent" + Custom, Start 20, End 85 (first), End 100 (rest)
│ └─ div .fan__right > img .fan__image ×3 same rows, mirrored
└─ div .fan__content own Custom row, End 80 (measures itself, no "Get parent")
└─ div .fan__content-inner the heading — moves using the inherited value

CSS:

.fan { overflow: hidden; }
.fan__container { position: relative; z-index: 1; display: flex; justify-content: center; align-items: center; }
.fan__media { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.fan__left, .fan__right { display: flex; flex-direction: column; gap: 1rem; }
.fan__image { position: relative; z-index: 2; aspect-ratio: 5 / 3; object-fit: cover; border-radius: .5rem; }
.fan__left > .fan__image { transform-origin: top left; }
.fan__right > .fan__image { transform-origin: top right; }
.fan__left > :nth-child(1) { transform: translateX(calc(var(--fa-scroll--custom, 0) * -200%)) translateY(calc(var(--fa-scroll--custom, 0) * 80%)) rotate(calc(var(--fa-scroll--custom, 0) * -30deg)); }
.fan__left > :nth-child(2) { transform: translateX(calc(var(--fa-scroll--custom, 0) * -280%)) rotate(calc(var(--fa-scroll--custom, 0) * -30deg)); }
.fan__left > :nth-child(3) { transform: translateX(calc(var(--fa-scroll--custom, 0) * -220%)) translateY(calc(var(--fa-scroll--custom, 0) * -20%)) rotate(calc(var(--fa-scroll--custom, 0) * -60deg)); }
.fan__right > :nth-child(1) { transform: translateX(calc(var(--fa-scroll--custom, 0) * 200%)) translateY(calc(var(--fa-scroll--custom, 0) * 80%)) rotate(calc(var(--fa-scroll--custom, 0) * 30deg)); }
.fan__right > :nth-child(2) { transform: translateX(calc(var(--fa-scroll--custom, 0) * 280%)) rotate(calc(var(--fa-scroll--custom, 0) * 30deg)); }
.fan__right > :nth-child(3) { transform: translateX(calc(var(--fa-scroll--custom, 0) * 220%)) translateY(calc(var(--fa-scroll--custom, 0) * -20%)) rotate(calc(var(--fa-scroll--custom, 0) * 60deg)); }
.fan__content { position: absolute; top: 50%; left: 50%; width: 100%; max-width: 550px; transform: translateX(-50%); text-align: center; }
.fan__content-inner { transform: translateY(calc(var(--fa-scroll--custom, 0) * -130%)); }

On phones (below 768px), use a single column: hide .fan__right and use bigger moves on the left column alone, so the effect still reads clearly with fewer images on screen.

Values you can change: each image's own distance (±200–280%), lift and rotation angle — mirror left and right so the fan stays visually symmetric; transform-origin on the outer corner (rather than the centre) makes images swing outward instead of spinning in place; the windows (20–85/ 20–100 for images, End 80 for the content).

  • Container: "I am parent". Each image: "Get parent" + one Custom row, Start 20, End 85 (or just Start 20, no End, for the rest).
  • Content: one Custom row, End 80, no "Get parent".
  • Hide .fan__right at the mobile breakpoint with that breakpoint's native Display control.

Common problems:

  • The heading's timing seems to drift. An element that measures itself must never move itself — the row stays on .fan__content, the actual movement on .fan__content-inner.
  • A horizontal scrollbar appears. The flying images leave the section's bounds — add overflow: hidden to the section.
  • Hover effects on the images stopped working. Scroll trigger elements get their transitions reset automatically — put hover effects on a child or wrapper instead (or, 3.2.0+, turn on Allow own transitions).
  • The images don't move at all. No "I am parent" on the container, or no "Get parent" on the images.