Sequenced reveals
Three ways to show a list of steps, captions or images one at a time as the visitor scrolls: using "Sequence children" (the modern, 3.1.1+ way), a split-screen variant where the text scrolls normally but a sticky image tracks it, and the hand-tuned windows you'd use before 3.1.1 or when items need different lengths of time.
Sequence: items that take turns
Steps (captions, features) appear one after another in the same spot while the stage is pinned: the current one moves up and fades as the next one rises in from below. Adding or removing a step needs no retuning at all.
Structure (build the pinned scene first — a 400vh scene pins at 20–80%):
div .pin__stage
└─ div .seq__steps "Sequence children", Start 20, End 80 (= the pinned window)
├─ div .seq__step "Get parent" + one Custom row with no Start/End
├─ div .seq__step same
└─ …
CSS:
.seq__steps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
gap: 1.5rem;
}
.seq__step {
--seq-on: calc(var(--fa-scroll--custom) * 0 + 1);
--seq-row: calc(var(--fa-scroll--custom) * 0 + 1);
--seq-col: 1 / calc(-1 + var(--fa-scroll--custom) * 0);
--seq-in: min(var(--fa-scroll--custom, 0) * 4, 1);
--seq-out: min((1 - var(--fa-scroll--custom, 0)) * 4, 1);
grid-row: var(--seq-row, auto);
grid-column: var(--seq-col, auto);
opacity: calc(1 - var(--seq-on, 0) * (1 - min(var(--seq-in), var(--seq-out))));
transform: translateY(calc(var(--seq-on, 0) * ((1 - var(--seq-in)) - (1 - var(--seq-out))) * 1rem));
will-change: transform, opacity;
}
.seq__step:first-child { --seq-in: 1; }
.seq__step:last-child { --seq-out: 1; }
--seq-on, --seq-row and --seq-col are only valid while the animation is actually running.
Animated, every step sits stacked in row 1 across all columns; without the animation they fall
back to auto and simply flow as an ordinary, fully-visible list (in columns, if there's room).
Values you can change:
- Sequence Start/End: match your scene's pinned window.
- Sequence overlap (10/25/50/100%): widens each window into its neighbours. Don't use it for captions replacing each other in the same spot — the outgoing and incoming text are both half visible at once and read as a tangle. It's fine (even nice) for images, which crossfade well.
- The
* 4in--seq-in/--seq-out: a quarter of each step's window fades in, a quarter fades out. A higher number makes the fades shorter. - The
1remtravel distance for the in/out movement.
- Bricks
- Etch
- Steps parent: Sequence children on, Start 20, End 80 (optional overlap).
- Each step: "Get parent" + one Custom row, with no Start/End of its own.
- Paste the CSS above into the custom CSS of a global class shared by every step.
- Steps play in the order Bricks actually renders them (its internal element order) — keep that equal to the order you see in the Structure panel.
- Steps parent classes:
fa--seq fa--seq--start-20 fa--seq--end-80(optionallyfa--seq--overlap-25). - Each step:
fa--parent fa--custom. :first-child/:last-childnest inside.seq__step's class with&.
Common problems:
- Two captions sit on top of each other, both half visible. Overlap was used on steps sharing one spot — remove it; overlap is for images, not captions.
- All steps overlap in the builder, or without JavaScript. The shared grid cell was switched
on by something other than the animation's own variable (a body class, or the index variables,
which only exist once JavaScript runs) — switch the layout using a variable computed purely from
the animation itself, like
--seq-onabove. - Steps never change while pinned. No "Get parent" on the steps — they're measuring the Sequence parent, which is itself pinned and motionless.
- Two steps visible at once, or a gap with none visible. A Start/End was set by hand on some steps. Leave Start/End empty on every step and only set the range on the parent.
- Requires 3.1.1. On older versions, use the manual windows pattern below.
Split scroll: sticky image, text that scrolls
Two columns: on one side, an image frame stays in place; on the other, text blocks scroll normally. Each image is at full visibility exactly when its matching text block is centred on the screen, crossfading between images as the text advances — and it works for any number of blocks, with no per-block windows to tune.
Structure:
section .split overflow-x reset, padding 0
└─ div .split__row Custom row, no Start/End; scroll smoothing ~0.2s; measures itself
├─ div .split__media sticky, one screen tall
│ └─ div .split__frame "Index children"
│ └─ img .split__img ×n stacked in one grid cell
└─ div .split__texts "Index children"
└─ div .split__text ×n one screen tall each
├─ img .split__text-img a copy of the image, shown only on phones
└─ h3, p
Only the row has a scroll trigger row at all — images and text blocks inherit its
--fa-scroll--custom, and "Index children" gives
each one its position (--fa-i, from 0) and the total count (--fa-n). Keep the same order in
both columns.
CSS:
.split { padding: 0; overflow-x: initial; }
.split__row { display: flex; flex-direction: row; width: 100%; position: relative; }
.split__media { position: sticky; top: 0; height: 100vh; flex: 1; display: grid; place-items: center; padding: 2rem; }
.split__frame { display: grid; width: min(100%, 30rem); aspect-ratio: 4 / 5; overflow: hidden; border-radius: 1rem; }
.split__img { grid-area: 1 / 1; width: 100%; height: 100%; object-fit: cover; }
.split__texts { flex: 1; padding-inline: clamp(1rem, 5vw, 2rem); } /* the section has no padding of its own: the gutter lives here, phones included */
.split__text { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; max-width: 32rem; }
.split__text-img { display: none; }
.split__text :is(h2, h3) { color: inherit; } /* headings take the theme's heading colour otherwise — dark on a dark section */
/* block i (from 0) is centred when p = (i + 1) / (n + 1); s = the currently-centred block, continuous */
.split__img, .split__text {
--sp-s: clamp(0, var(--fa-scroll--custom) * (var(--fa-n) + 1) - 1, var(--fa-n) - 1);
--sp-d: calc(var(--fa-i) - var(--sp-s));
--sp-on: clamp(0, 1 - (max(var(--sp-d), -1 * var(--sp-d)) - .3) / .4, 1); /* 1 within ±.3, 0 past ±.7 */
}
.split__img { opacity: var(--sp-on, 1); scale: calc(1.06 - .06 * var(--sp-on, 1)); }
.split__img:not(:first-child) { opacity: var(--sp-on, 0); }
.split__text { opacity: calc(.3 + .7 * var(--sp-on, 1)); }
@media (max-width: 767px) {
.split .split__row { flex-direction: column; }
.split .split__media { display: none; }
.split .split__text { min-height: 0; padding-block: 3rem; opacity: 1; }
.split .split__text-img { display: block; width: 100%; aspect-ratio: 4 / 5; object-fit: cover; border-radius: 1rem; margin-bottom: 1.5rem; }
}
Because each text block is exactly one screen tall, the row is n screens tall in total, and
block i sits centred at p = (i + 1)/(n + 1) — which works cleanly for 5 or 7 blocks, where a
plain 5%-grid window couldn't land exactly. Without JavaScript, the first image shows and every
text block is fully opaque; on phones each block shows its own copy of the image stacked above it
instead of a sticky column.
Values you can change: the .3/.4 pair (how long an image stays fully shown, and how long
the crossfade lasts), the .3 + .7 opacity floor for inactive text blocks, and the 1.06 starting
zoom on images (use 1 for none). Keep every text block exactly the same height — the centring
formula depends on it.
- Bricks
- Etch
- Row: one Custom row, scroll smoothing ~0.2s. Frame and texts column: "Index children". Images and text blocks themselves need no Fancy settings at all.
- The whole CSS above goes in the section's custom CSS, with each element given its matching class.
- Row classes:
fa--custom fa--smooth-20; frame and texts column:fa--index. - Phone rules nest as
@media (max-width: 767px) { … }inside each class.
Common problems:
- An image changes before or after its matching text is actually centred. The text blocks don't all have the same height, or a block isn't a direct child of the texts column.
- All images show at once. "Index children" is missing from the frame, so there's no
--fa-ito work with. - The image column scrolls away instead of staying put. The row isn't stretched to the full
height of the texts column, or a parent has
overflow: hiddenclipping the sticky behaviour.
Manual windows
Before 3.1.1 (or when items genuinely need different durations), give each item its own Start/End by hand instead of using Sequence children. The rule: give siblings consecutive windows inside the scene's pinned window. Four items in a 300vh scene (pinned 25–75%): 35–45, 45–55, 55–65, 65–75. Short caption fades: 35–40, 45–50, 55–60, and so on.
A deck of cards that flies off, one at a time
div .pin__stage
└─ div .deck position: relative, flex-grow: 1
├─ div .deck__card "Get parent" + Custom 35–45
├─ div .deck__card "Get parent" + Custom 45–55
├─ div .deck__card … 55–65
└─ div .deck__card … 65–75
.deck { position: relative; flex-grow: 1; }
.deck__card {
position: absolute; top: 10%; height: 80%;
left: calc(3rem * var(--deck-k, 0)); width: calc(100% - 3rem * var(--deck-k, 0));
transform-origin: bottom right;
transform: translateX(calc(var(--fa-scroll--custom, 0) * 200px)) rotate(calc(var(--fa-scroll--custom, 0) * 90deg));
opacity: calc(1 - var(--fa-scroll--custom, 0));
}
.deck__card:nth-child(1) { --deck-k: 3; z-index: 4; }
.deck__card:nth-child(2) { --deck-k: 2; z-index: 3; }
.deck__card:nth-child(3) { --deck-k: 1; z-index: 2; }
.deck__card:nth-child(4) { --deck-k: 0; z-index: 1; }
The first card sits on top; each one below it shows a 3rem step. Adding a fifth card needs a fifth
window and a fifth :nth-child rule, and all the offsets shift by one.
Captions with a hold in the middle
"Reverse" (on the Scroll trigger row) gives a peak — in, then straight back out — not a hold. For in / hold / out, nest two wrappers, each with their own window:
div .captions display: grid
└─ div .caption outer: Fade + Custom, both the same "in" window
└─ div .caption__inner inner: Fade on the "out" window, Inverted
.caption {
--cap-on: calc(var(--fa-scroll--fade) * 0 + 1);
grid-column: 1;
grid-row: calc(var(--cap-row, 1) - (var(--cap-row, 1) - 1) * var(--cap-on, 0));
transform: translateY(calc((1 - var(--fa-scroll--custom, 1)) * 2rem));
}
.caption:nth-child(2) { --cap-row: 2; }
.caption:nth-child(3) { --cap-row: 3; }
Animated, every caption shares row 1; without the animation each one gets its own row and the list reads as an ordinary list. The first caption needs its own entrance-style row too (ending right as the stage pins), or it has nothing to switch its shared cell on. For a list of any real length, "Sequence children" is the modern replacement for this whole pattern.
Common problems:
- One effect on one element, only part of it works. Only one preset per CSS property per element — put the second effect (the "exit", say) on an inner wrapper.
- Captions overlap in the builder, or without JavaScript. The shared cell was switched on by
something other than the animation's own variable — gate it the way
--cap-ondoes above. - Adding an item breaks the whole timing. That's the nature of manual windows — with 3.1.1, switch to Sequence children instead.