There are quite a lot of gallery layouts that maintain exhibiting up on web sites, from portfolios to company websites. On this tutorial, I’ll construct a scattered picture gallery that scrolls infinitely, with every picture shifting at its personal pace to create a delicate floating impact. Once you click on an merchandise, it scales out of the grid right into a full element view.
I’ll break this tutorial into three sections:
Slider (Scroller): Infinite scrolling pushed by the mouse wheel and contact enter, with per-item parallax.
Reveal: When an merchandise enters the viewport, it fades in to keep away from any snapping as photos seem.
Transition: A morphing impact that expands a clicked merchandise into the element view.
On this tutorial, I solely use GSAP and plain JavaScript. We use Observer for enter, GSAP Flip for the transition morph, and SplitText for the textual content reveals.
1. Templating
HTML
To start out we have to create the gallery:
Above the Cove
Then the element view lives in a separate overlay, hidden till one thing is clicked:
As proven above, there’s one content material group per slide, matched by its data-index. Once we click on a gallery merchandise, it turns into the lively merchandise. The transition by no means builds any markup; it merely swaps which content material group is seen and which picture supply the preview shows. The
is the one aspect that each thumbnail finally morphs into.
Styling
Now the CSS, since we’re hijacking the scroll we’ve to kill the native scroll and for certain we’ve to arrange the scattered of every merchandise
Every gallery__slide may have two customized properties: --stagger to which pushes the merchandise from middle, and --img-w only for the width. I maintain it in CSS and never in JavaScript as a result of later we rebuild the loop on resize, and meaning wiping each JavaScript remodel off these components.
2. The Slider
As talked about, that is the engine that owns the scroll place, and the opposite two modules react to it.
Infinite Scroll
The trick is to not use the true browser scroll to handle the scrolling. As an alternative, I take advantage of a paused timeline during which each slide travels one full lap, then deal with its playhead because the scroll place.
Within the code we’ve verticalLoop, a helper that builds that timeline. It’s primarily based on GSAP’s seamlessLoop helper, tailored right here for a vertical format.
For every merchandise it creates two tweens: one which carries the merchandise up till it has handed the highest edge, and one which instantly re-enters it from the underside of the gallery.
// Two tweens per merchandise: exit prime, then re-enter from the underside
for (let i = 0; i < size; i++) {
const merchandise = objects[i];
// Present y offset in px
const curY = (yPercents[i] / 100) * heights[i];
// Distance to the container's prime edge
const distanceToStart = merchandise.offsetTop + curY - startY;
// Distance till absolutely off-screen prime
const distanceToLoop =
distanceToStart + heights[i] * gsap.getProperty(merchandise, "scaleY");
// Journey up till absolutely off-screen
tl.to(
merchandise,
{
yPercent: snap(((curY - distanceToLoop) / heights[i]) * 100),
length: distanceToLoop / pixelsPerSecond,
},
0,
).fromTo(
// Then re-enter from the underside finish of the container
merchandise,
{
yPercent: snap(
((curY - distanceToLoop + totalHeight) / heights[i]) * 100,
),
},
{
yPercent: yPercents[i],
length: (totalHeight - distanceToLoop) / pixelsPerSecond,
immediateRender: false,
},
distanceToLoop / pixelsPerSecond,
);
}
A few of you’ll in all probability perceive that the positions are animated with yPercent and never y as a result of it’s relative to the aspect’s personal peak, that method the values nonetheless maintain up even when components aren’t all the identical measurement. However we use the y property for the parallax impact.
There’s additionally immediateRender: false on the fromTo is to stops GSAP from making use of that “begin on the backside” worth the second the tween is created. With out it, each merchandise would soar to the underside of the gallery on the primary body.
The ultimate line of the helper does the identical job for the timeline as a complete:
// Pre-render each tween to keep away from a first-frame soar
tl.progress(1, true).progress(0, true);
Scrubbing to the top and again forces each tween to render as soon as, so no tween is caught initialising on the primary actual body.
The length is derived from distance, so pace stays fixed throughout objects of various heights:
// Journey pace: pace 1 = 100px per second
const pixelsPerSecond = (config.pace || 1) * 100;
So length: distanceToLoop / pixelsPerSecond, confused about this calculation? Our timeline is paused: true and we by no means play it, we solely set the playhead by hand. This line offers us an trade charge: one second of playhead time equals 100px of motion. Seconds are simply the unit GSAP makes use of to measure timelines.
The division itself is apparent time = distance / pace. An merchandise that has to journey 400px will get a length of 4, an merchandise that has to journey 800px will get 8. That’s the level of it: each slide strikes on the identical charge regardless of how tall it’s. If we picked durations by hand, slides of various sizes would drift aside right away.
Throughout twelve slides our gallery is round 5,200px, so loop.length() finally ends up close to 52. A fifty-two second lap no one ever waits by way of.
Why 100 and never one other quantity? As a result of it makes the gallery observe the wheel one to 1. We are going to see the opposite half of this within the slider’s scroll() technique later, the place we divide the scrolled pixels by the identical 100. Scroll 300px, the playhead strikes 3, the gallery strikes 300px.
Consider these two 100s are actually one quantity residing in two recordsdata. config.pace appears like a knob you may flip, however in case you move pace: 2 the gallery turns into 200px per playhead second whereas scroll() nonetheless divides by 100, so a 300px scroll would transfer the gallery 600px. You’d have doubled the sensitivity, not the pace.
Making it infinite
The timeline loops, however the playhead can nonetheless fall exterior 0 to the max. gsap.utils.wrap folds any out-of-range worth again in:
/** Construct vertical loop from the gallery slides */
createLoop() {
const gallery = doc.querySelector(".gallery");
// The hole between slides doubles because the loop's backside padding
const hole = parseFloat(getComputedStyle(gallery).rowGap);
// A paused one lap timeline; its playhead is our scroll place
this.loop = verticalLoop(".gallery__slide", {
repeat: -1,
paused: true,
paddingBottom: hole,
});
// Wrapping the playhead previous both finish is what makes it infinite
this.wrap = gsap.utils.wrap(0, this.loop.length());
}
this.wrap(-3) on a 52 second loop offers again 49. So our playhead worth can go anyplace, constructive or unfavorable or hundreds, and we all the time hand the timeline a authorized time.
Concerning the paddingBottom: hole, we move it rowGap so it reads 12vh from the CSS. However contained in the helper, totalHeight is constructed from offsetTop, which is measured from the web page and never from the gallery’s content material field. Meaning the gallery’s personal padding-top: 14vh is already contained in the quantity earlier than paddingBottom will get added.
So the empty area on the seam, the place the final slide leaves and the primary one comes again round, is that 14vh + 12vh = 26vh. In all places else the slides sit 12vh aside, in order that one be part of is greater than twice as roomy.
I left it that method. Scroll a full lap and you’ll really feel it, a barely longer pause proper earlier than the sequence begins over, which is strictly the second you’ll in any other case catch it repeating.
Scrub Proxy
Now the half that offers the gallery its really feel. As an alternative of setting loop.time() from the wheel delta straight, we animate a plain object towards the goal and let GSAP’s easing easy it:
/** Create the eased playhead that smooths scroll enter into loop time */
createScrub() {
// Proxy object standing in for the scroll place
this.playhead = { time: 0 };
// Eases the playhead towards the most recent scroll goal
this.scrub = gsap.to(this.playhead, {
time: 0,
length: 0.75,
ease: "power3.out",
paused: true,
onUpdate: () => {
this.loop.time(this.wrap(this.playhead.time));
this.applyParallax();
},
});
}
On this code we get the inertia, interruption dealing with, and a single render callback with out writing a lerp loop or working our personal requestAnimationFrame.
Driving it’s simply as small:
/** Transfer the scrub goal by the scrolled distance */
scroll({ deltaX, deltaY }) {
if (!this.enabled()) return;
// Swipes (x or y) all drive the gallery
const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
// Slides journey 100px per playhead second, so px / 100 offers time
this.scrub.vars.time += delta / 100;
this.scrub.invalidate().restart();
}
A GSAP tween data its begin values the primary time it renders, and invalidate() throws these away so the tween reads playhead.time once more as its new begin. Then restart() replays the 0.75s ease from wherever the playhead is now towards the brand new goal. Scroll rapidly and each gesture retargets a tween that’s already shifting, which is strictly the momentum we wish.
Concerning the delta/100, the gallery covers 100px per playhead second so dividing scrolled pixels by 100 turns them into playhead time, and the gallery strikes so far as you scrolled.
Enter through Observer
I take advantage of Observer as a result of it’s normalises wheel, contact and pointer occasions into one API:
/** Hear for wheel and contact enter */
createObserver() {
this.observer = Observer.create({
goal: window,
kind: "wheel,contact",
preventDefault: true,
onChange: (self) => {
this.scroll(self);
},
});
}
preventDefault: true is what makes the CSS from Half 0 stick. overflow: hidden stops the doc from scrolling, however the browser nonetheless fires wheel and contact occasions with its personal default habits hooked up, like pull to refresh, rubber banding or again swipe. Cancelling them right here means the gesture reaches our scrub and nothing else.
Parallax with out seams
Every slide will get a pace multiplier, cycled from a small array:
/** Give every slide its personal journey pace for a way of depth */
createParallax() {
// Pace multipliers: > 1 quicker, < 1 slower
const speeds = [1.3, 0.8, 1.15, 0.7, 1.25, 0.85];
this.parallax = gsap.utils.toArray(".gallery__slide").map((slide, i) => ({
el: slide,
issue: speeds[i % speeds.length] - 1,
offset: 0,
seen: false,
}));
// Scatter the resting positions too
this.applyParallax();
}
I retailer pace - 1 as a substitute of pace on objective, that method the issue turns into how a lot additional to maneuver on prime of what the loop already does. An element of 0 simply means it strikes precisely with the loop, so the offsets keep small.
The values alternate quick and gradual as a substitute of ramping up, so no two slides subsequent to one another drift in sync. One factor value figuring out: i % speeds.size repeats each six slides, so slide 1 and slide 7 find yourself shifting identically. That’s superb right here since they sit about 2,500px aside and are by no means on display on the identical time, however it will present up in case your slides bought smaller or your viewport bought taller. If you’d like zero repeats in a single lap, simply make the array so long as your slide depend.
Now the half that took me a number of tries. The apparent strategy, offsetting every slide by progress * issue, offers you a visual soar each time a slide wraps, as a result of the offset is largest proper when the merchandise teleports from prime to backside. So no matter we do, the offset needs to be zero on the precise second a slide wraps:
/** Offset every slide by its pace issue, report viewport enters/leaves */
applyParallax(rapid = false) {
const modifications = [];
this.parallax.forEach((merchandise) => {
const rect = merchandise.el.getBoundingClientRect();
// The slide's place with out our offset
const loopTop = rect.prime - merchandise.offset;
// Offset by the pace issue; zero on the wrap level
merchandise.offset = merchandise.issue * (loopTop + rect.peak);
gsap.set(merchandise.el, { y: merchandise.offset });
// Test visibility from the ultimate place
const prime = loopTop + merchandise.offset;
const seen = prime < window.innerHeight && prime + rect.peak > 0;
// Accumulate the slides that entered or left
if (seen !== merchandise.seen) {
merchandise.seen = seen;
modifications.push({ el: merchandise.el, seen, prime });
}
});
if (modifications.size) this.onToggle?.(modifications, rapid);
}
loopTop + rect.peak is the space from the highest of the viewport right down to the underside fringe of the slide. That worth hits zero precisely when the slide has cleared the highest of the display, which is the place the loop wraps it. Multiply by the issue and the additional offset fades to nothing proper on the seam, so the wrap turns into invisible.
Subtracting the earlier body’s merchandise.offset from rect.prime is what retains this secure. getBoundingClientRect() stories the place together with the offset we set final body, so we undo it to get again the pure loop place earlier than figuring out the brand new one. With out that, the offset compounds and the slides fly off display in a few second. Then the loop writes yPercent, the parallax writes y, and GSAP composes each into the identical remodel.
The second half of the strategy is a free experience. We’re already measuring each rectangle every body, so detecting viewport entries and exits prices two comparisons. Word that it assessments prime, the place after the parallax offset, which is the place the slide actually is on display.
As an alternative of appearing on that itself, the slider simply stories it: this.onToggle?.(modifications, rapid) is the entire interface between the slider and the reveal. One callback, one array of { el, seen, prime }. The optionally available chaining means the slider nonetheless works superb with no reveal hooked up.
3. Reveal
The reveal is fairly easy. When a slide enters the viewport, fade it in. When it leaves, reset it so the animation can play once more subsequent time. On an infinite loop, “subsequent time” occurs loads, so resetting correctly just isn’t optionally available.
constructor() {
// Reveal targets per slide
this.objects = new Map();
gsap.utils.toArray(".gallery__slide").forEach((slide) => {
const wrapper = slide.querySelector(".gallery__img-wrapper");
// Break up the caption into characters
const chars = new SplitText(slide.querySelector("span"), {
kind: "chars",
}).chars;
// Resting state: picture and caption invisible
gsap.set(wrapper, { autoAlpha: 0 });
gsap.set(chars, { autoAlpha: 0 });
this.objects.set(slide, { wrapper, chars });
});
}
A Map keyed by the slide aspect offers us O(1) lookup later with out storing state on the DOM node.
Oh, autoAlpha is GSAP shorthand for opacity plus visibility. It tweens opacity and units visibility: hidden the second opacity reaches zero, then again to inherit as quickly because it leaves. One property doing the work of two, and the browser skips portray something absolutely clear, which on an infinite loop is many of the gallery more often than not.
Dealing with a batch of modifications is the place the choreography lives:
/** Present slides that entered, prime to backside, reset those that left */
toggle(modifications, rapid = false) {
modifications
.filter((change) => change.seen)
.kind((a, b) => a.prime - b.prime)
.forEach((change, i) => this.present(change.el, i * 0.12, rapid));
modifications
.filter((change) => !change.seen)
.forEach((change) => this.conceal(change.el));
}
Sorting by prime earlier than staggering is the small element that makes this really feel deliberate. The slider stories modifications in DOM order, however on an infinite loop, DOM order has nothing to do with display order. The slide on the prime of the viewport may be the ninth within the markup. Sorting by actual display place means the stagger all the time cascades downward, whichever method you scroll.
The reveal itself is 2 tweens, the picture after which the caption a beat behind:
/** Fade the picture in, then fade the caption in character by character */
present(slide, delay, rapid = false) {
const { wrapper, chars } = this.objects.get(slide);
// The viewport moved across the slide, so there is no such thing as a entrance to play
if (rapid) {
gsap.set([wrapper, ...chars], { autoAlpha: 1, overwrite: true });
return;
}
gsap.to(wrapper, {
autoAlpha: 1,
length: 1,
ease: "power2.out",
delay,
overwrite: true,
});
gsap.to(chars, {
autoAlpha: 1,
length: 0.4,
ease: "none",
stagger: 0.025,
delay: delay + 0.2,
overwrite: true,
});
}
Scroll quick and a slide can go away and are available again earlier than its fade-in finishes, stacking competing tweens on the identical goal and inflicting flicker. overwrite: true kills each different tween on the identical targets the second a brand new one begins, no matter which properties they contact. That’s stronger than the standard "auto", which solely overwrites tweens touching the identical properties. It’s superb right here, since these are the one tweens that ever contact these components.
Hiding is instantaneous, for a similar motive:
/** Reset immediately so the reveal replays on the subsequent entry */
conceal(slide) {
const { wrapper, chars } = this.objects.get(slide);
gsap.set(wrapper, { autoAlpha: 0, overwrite: true });
gsap.set(chars, { autoAlpha: 0, overwrite: true });
}
Off-screen components don’t want an exit animation, as a result of no one can see them anyway. They only should be reset cleanly and instantly so the subsequent entry begins from a identified state.
4. Transition
The thought behind the transition is to have it morph into the place and measurement of the popup with out trying like a transition. It data the present state, and even after we change the DOM or CSS, GSAP’s Flip handles it, animating from the recorded state to the brand new one by evaluating the 2 snapshots and producing the transforms routinely.
However there’s a tough half right here. The factor being animated just isn’t the factor we recorded. The thumbnail stays within the gallery, and a separate full-screen determine is what finally ends up seen. Flip handles that with matching IDs:
// The preview's Flip id
this.preview.dataset.flipId = "preview";
wrapper.dataset.flipId = "preview";
// Seize the thumbnail's bounds earlier than the format modifications
const state = Flip.getState(wrapper);
Two completely different components sharing one data-flip-id. Flip sees the ID transfer from one aspect to a different and animates the second out of the primary one’s recorded bounds.
This solely works as a result of precisely one thumbnail claims the ID at a time. That’s why we assign it on open and delete it on reset as a substitute of placing it within the markup, twelve thumbnails all carrying flipId: "preview" would depart Flip no method to know which one we meant.
Opening
/** Animate to preview */
async open(slide, index) {
if (this.state !== "closed") return;
this.state = "opening";
this.activeSlide = slide;
await this.fillContent(slide, index);
// A detailed() touchdown whereas the preview decoded has already reset us
if (this.state !== "opening") return;
const { wrapper, caption, others } = this.components();
wrapper.dataset.flipId = "preview";
// Seize the thumbnail's bounds earlier than the format modifications
const state = Flip.getState(wrapper);
// Present the element format and conceal the thumbnail, killing its reveal tween
gsap.set(this.content material, { show: "block" });
gsap.killTweensOf(wrapper);
gsap.set(wrapper, { autoAlpha: 0 });
// ...
}
There are two ordering guidelines hiding in these strains.
The primary one is strict: wrapper.dataset.flipId = "preview" needs to be set earlier thanFlip.getState(wrapper). The state object data every aspect’s Flip ID at seize time, and Flip auto-generates one for any aspect that doesn’t have it but. Set the ID after and your snapshot carries a generated ID that matches nothing, Flip.from finds no companion, and also you get a silent no-op. Nothing errors, the animation simply doesn’t occur, which is the toughest sort of Flip bug to seek out.
The second is the final Flip rule: snapshot first, mutate second, animate third. On this demo the 2 gsap.set calls after it are innocent as a result of .content material is place: mounted, so exhibiting it might probably’t transfer the gallery. However holding the seize on the prime remains to be the suitable behavior.
gsap.killTweensOf(wrapper) handles an actual edge case. You may click on a slide whereas its personal reveal fade remains to be working. With out killing that tween first it retains animating autoAlpha again to 1 on a component we simply hid, and the thumbnail ghosts by way of below the preview.
Each tween sits at place 0 so the entire thing reads as one gesture as a substitute of a sequence of steps. power4.inOut offers the morph a heavy curve that fits a picture the dimensions of the viewport, and the fades use a gentler power2.out in order that they don’t pull consideration away from it.
absolute: true tells Flip to place the aspect completely whereas it animates. With out it, a morph that modifications a component’s format place can push its siblings round mid-flight.
That final tween does greater than it appears. Each the thumbnail picture and the preview picture sit at remodel: scale(1.2) inside an overflow: hidden wrapper, so a sixth of every dimension is all the time cropped away. Animating the inside picture to scale: 1 whereas the wrapper grows means the body and its contents transfer at completely different charges, and the picture appears prefer it settles again into itself because the body expands. It’s the identical concept because the parallax, simply inside one aspect.
The textual content
The element textual content fades in per character, per line:
// Break up the textual content into strains and characters
this.break up = new SplitText(".content__back, .content__group.lively > *", {
kind: "strains,chars",
charsClass: "char",
});
// ...the timeline proven above...
// Fade the textual content in character by character, one line after one other
this.break up.strains.forEach((line, i) => {
this.tl.fromTo(
line.querySelectorAll(".char"),
{ autoAlpha: 0 },
{
autoAlpha: 1,
length: 1,
ease: "power3.out",
stagger: 0.01,
},
// Preview delay + line index * stagger delay
0.8 + i * 0.06,
);
});
Splitting into strains,chars offers two ranges to work with. The characters get a good 0.01s stagger so a line seems virtually as one piece, and the strains are offset by 0.06s so the paragraph builds downward.
The 0.8 begin places the textual content about two thirds by way of the 1.2s morph. Begin at zero and the display feels busy, await the morph to complete and the entire thing feels gradual. Overlapping is what makes it learn as one steady transfer.
Creating the break up after show: block issues, SplitText wants the aspect rendered to know the place strains truly break.
Closing
Closing is not only the opening timeline reversed, as a result of what we’d like is completely different. The preview is already a positioned aspect with identified bounds, so there’s nothing to report, and Flip.match() takes aspect A and animates it into aspect B’s present place:
this.tl = gsap
.timeline({ onComplete: () => this.reset() })
// Fade the textual content out line by line
.to(
this.break up.strains,
{ autoAlpha: 0, length: 0.4, stagger: 0.04, ease: "power1.out" },
0,
)
// Morph the preview again into the thumbnail's bounds
.add(
Flip.match(this.preview, wrapper, {
length: 1,
ease: "power3.inOut",
absolute: true,
}),
0,
)
.to(this.previewImg, { scale: 1.2, length: 1, ease: "power3.inOut" }, 0)
// Fade the opposite slides and caption again in
.to(others, { autoAlpha: 1, length: 0.5, ease: "power2.out" }, 0.5)
.to(caption, { autoAlpha: 1, length: 0.4, ease: "power2.out" }, 0.6);
The shut is quicker than the open, 1s towards 1.2s, and it fades entire strains as a substitute of particular person characters. Getting out of the way in which ought to all the time be faster than arriving. The opposite slides come again at 0.5, midway by way of the morph, as a result of bringing them in at 0 makes the display crowded whereas the preview remains to be massive.
4 states, one guard
Folks click on issues twice. They hit Escape mid-animation. They click on a second slide whereas the primary remains to be opening. A boolean can’t describe any of that, so the transition tracks 4 states:
// Transition state = closed | opening | open | closing
this.state = "closed";
open() refuses to start out except the state is closed, and shut() reads the state and picks a method:
/** Animate the preview again into its slide */
shut() {
// Shut mid-animation
if (this.state === "opening") {
this.state = "closing";
// Nonetheless ready on the decode, so there is no such thing as a timeline to rewind
if (!this.tl) {
this.reset();
return;
}
this.tl.reverse();
return;
}
if (this.state !== "open") return;
this.state = "closing";
// ...construct the shut timeline
}
Urgent Escape mid-open rewinds the opening timeline as a substitute of constructing an in depth timeline towards bounds which might be nonetheless shifting. That’s why the open timeline carries onReverseComplete: () => this.reset(), a cancelled open has to wash up precisely like a completed shut.
reset() places the DOM again:
/** Restore the DOM */
reset() {
const { wrapper } = this.components();
// The thumbnail solely carries the Flip id whereas its slide is expanded
delete wrapper.dataset.flipId;
// Revert break up
this.break up?.revert();
this.break up = null;
// Cover the overlay and clear model the animations left
gsap.set(this.content material, { show: "none" });
gsap.set(this.preview, { clearProps: "all" });
gsap.set(this.previewImg, { clearProps: "all" });
gsap.set(wrapper, { clearProps: "all" });
this.activeSlide = null;
this.tl = null;
this.state = "closed";
this.onClose?.();
}
break up.revert() restores the unique textual content nodes, which issues each for the subsequent open and for display readers and textual content choice, neither of which enjoys a paragraph shredded into one
per character. clearProps: "all" removes each inline model the animations wrote, so the subsequent open begins from CSS and never from leftovers.
The way it works
The three modules speak to one another by way of two small contracts in index.js:
// Infinite scroll-driven slider; stories viewport enters/leaves to the reveal
const slider = new Slider({
enabled: () => transition.state === "closed",
onToggle: (modifications, rapid) => reveal.toggle(modifications, rapid),
});
// ...
slides.forEach((slide, index) => {
// Solely hand over the gallery if the transition goes handy it again
const open = () => {
if (transition.state !== "closed") return;
slider.cease();
transition.open(slide, index);
};
slide.addEventListener("click on", open);
});
enabled is a operate and never a boolean, so it will get evaluated on each scroll occasion and all the time displays the present state. Scrolling whereas the preview is open does nothing.
slider.cease() does two issues, and it's value seeing them aside:
/** Freeze the inertia in place so a transition can take over */
freeze() {
this.scrub.pause();
// Sync the goal to the place we stopped, so the subsequent scroll ramps from right here
this.scrub.vars.time = this.playhead.time;
this.scrub.invalidate();
}
/** Hand the gallery over to a transition */
cease() {
this.observer.disable();
this.freeze();
}
/** Take the gallery again as soon as a transition has completed with it */
begin() {
this.observer.allow();
}
Pausing by itself would depart the scrub’s goal someplace forward of the place the gallery truly stopped, and the subsequent scroll after closing the preview would soar ahead to meet up with it. Syncing the goal to the true place means scrolling picks up precisely the place it left off.
A notice on the rapid flag threading by way of applyParallax, toggle and present. On a resize the loop will get rebuilt and each slide is re-measured, so slides that have been already on display come again reported as recent entries and the reveal replays its entire fade-in. The flag says: nothing entered the viewport, the viewport moved round it, so snap to the top state as a substitute of animating.
Identical story with freeze() and cease(). Each pause the scrub, however cease() additionally disables the Observer and begin() turns it again on when the transition resets. I solely discovered that one by breaking it: with the Observer nonetheless listening in the course of the preview, preventDefault: true retains swallowing wheel occasions despite the fact that enabled() makes scroll() return early, so the element view couldn't scroll both.
The guard within the click on handler got here out of the identical hunt. slider.cease() used to run earlier than transition.open() had determined whether or not it will settle for, and open() bails if the state just isn't closed. Click on a second slide mid-animation and the Observer went off with nothing left to show it again on, so the gallery simply died. Checking the state first means we solely hand over when one thing is there handy again.
And open() is async now as a result of the preview picture has by no means held that src, so it has no decoded body and paints clean for the primary frames of the morph. Awaiting previewImg.decode() fixes that, nevertheless it opens a window the place an in depth can land earlier than the timeline exists, which is what the additional guards in open() and shut() are for.
Refinements
Two issues that apply to all three modules moderately than any certainly one of them.
Efficiency. The per-frame work is one getBoundingClientRect() and one gsap.set() per slide. applyParallax interleaves these reads and writes, which is the basic format thrashing sample, so twelve slides means twelve pressured reflows a body. Nice at this measurement, not superb at eighty. When you scale it up, measure every part into an array first, then write from that array.
In any other case the gallery solely animates remodel and opacity. The one exception is the Flip morph, which animates width and peak as a result of scale: false is the default. That does set off format, nevertheless it retains the picture crisp as a substitute of stretching a bitmap, and it runs for 1.2 seconds on a single aspect. Price it.
Construction. Every module owns one factor. The slider exposes cease(), begin() and freeze(), the reveal exposes toggle(), the transition exposes open(), shut() and a readable state. None of them import one another, all of the wiring is in index.js. Delete the transition and you continue to have an infinite parallax gallery. Delete the reveal and the slider by no means notices.
Accessibility
A gallery that hijacks the wheel wants care right here, so let me be straight about what the demo does and the place it stops.
Each slide will get tabindex="0" and function="button" in index.js, and listens for Enter and Area in addition to click on, as a result of native buttons reply to each. Escape closes the preview, and the button is labelled “Again (Esc)” so the shortcut is discoverable. Each gallery picture has an actual alt, and fillContent() copies it onto the preview so the outline follows the photograph into the element view.
Two issues it doesn't do. There isn't any keyboard scrolling, as a result of Observer solely listens for wheel and contact and there's no native scroll to fall again on, so arrow keys do nothing and tabbing to a slide additional down focuses one thing you can not see. And there's no lowered movement dealing with, which a 1.2 second full-screen morph and a gallery with momentum each deserve.
Neither is tough. Route key occasions into the identical scroll() technique the Observer feeds, and use gsap.matchMedia() to shorten the scrub, flatten the parallax elements and compress the timeline with timeScale. I left them out so each line right here is code you may truly learn within the repo, however I'd add each earlier than delivery this.
Wrap-Up
Three results, three modules, and many of the attention-grabbing work sitting within the seams between them. The slider turns a paused timeline right into a scroll place and anchors its parallax to the wrap level so the seam stays invisible. The reveal orders entries by display place, and since the slider stories its first batch throughout setup, the intro and the scroll reveal are the identical code. The transition strikes a photograph between a thumbnail and a full-screen preview with Flip.from and Flip.match, guarded by 4 states so impatient clicking can not break it.
The smallest knobs are essentially the most enjoyable to show. Change the speeds array and the entire sense of depth modifications. Swap power4.inOut for expo.inOut and the morph goes from consider to sudden. Transfer the 0.8 textual content offset by a tenth of a second and watch how otherwise the element view lands.