Pre-Opening Teaser Landing Site for an Exhibition
A teaser landing built from six photographs, two months before opening
Six photographs, two months to opening
A pre-opening teaser landing page for an art gallery. No admin page, no accounts, no reservations. The exhibition details are written once and do not change.
The layout is built mobile-first, and the dedicated brand typeface was required (which is why LGSmHaR_0.eot sits next to LGSmHaR.woff). The material on hand was six renderings of the space, a video embed, and the exhibition overview copy, and anything not in use stays hidden on screen.

Immersion without hijacking the scroll
I pulled out full-page scroll hijacking (fullPage.js) and went back to ordinary document scroll
Once the second section had to hold a video embed and a collapsible architect profile, a section model that snaps to exactly one screen could not hold the content. I figured immersion survives without taking the scroll away, as long as the background stays alive. The prototype was already built on fullPage.js with the background logic wired into the onLeave / afterLoad hooks, so the whole effect had to be rewritten.
I moved the mosaic effect off DOM span tiles and onto a single <canvas>
Tile count grows with viewport width. At 15 across that is more than 200 tiles per section, and shaking all of them with animate() dropped frames on the very first screen. Shipping with jQuery alone and no build step was the agreed constraint, so I could not bring in an animation library.
The final build ships as plain static files with no bundler. parcel stayed behind in the prototype repo
The deliverable is one index.html, three CSS files, and a handful of images. "Fix it and upload" was worth more than what hashed filenames buy. The exhibition overview and the profile copy were not final until the last week.
The background is six jpgs and CSS animation, not video
A transform: scale(1.2) zoom, a background-position drift, and a 12-second opacity cross-fade offset by -4s / -8s make a three-image loop run without a seam. It also sidesteps mobile autoplay policy and a video of tens of MB. The trade is that six full-screen jpgs all load on first paint.

Background pinned, document scrolling freely
There are three repos, but their roles do not overlap. lg-signature is the spike where I tried the effect on fullPage.js and parcel, lg-signature-front is the markup and CSS original with the background engine taken out (the background declarations on .sec1 are still there, commented out), and lg is the final build that merges the two.
The final build is three layers deep. At the bottom is #bg, three background groups nailed to the viewport with position: fixed and z-index: -1. One group cross-fades three .layer images on a 12-second cycle; the first group drifts sideways and the rest zoom in. Above that is #wrap, a single <canvas> the script creates and appends at runtime. On top, #top is just a document that scrolls.
One scroll handler is all that joins the three layers. A tick throttled with requestAnimationFrame reads the position of the three sections, swaps the hide class only at the moment a boundary is crossed, and reruns runMosaic(). The background and the document know nothing about each other. That separation is why nothing on the background side had to change when the second section grew longer, or when expanding the profile changed its height.

Deleting 200 tiles from the DOM and moving them onto one canvas
Split the screen into a grid, create that many translucent <span> elements, and fade them out one at a time with animate({opacity: 0}) in shuffled order. That is what I did first too. The prototype append()ed roughly 200 span nodes per section at 15 across and erased one every 20ms with setInterval(..., 20). Every tile got its own compositing layer, so mobile dropped frames from the first one, and the afterLoad hook started a fresh interval each time a section was entered while dropping the old handle, so they kept piling up.
I threw the DOM tiles away, made a viewport-sized <canvas>, filled it with rgba(255,255,255,0.3), and erased shuffled grid cells one at a time with clearRect(). Tile count splits 6 / 12 / 16 by width, and the erase interval is divided along with it: 20 / (horiBoxAmount / 6).
Once the thing being erased is pixels rather than nodes, the reflows and the 200 compositing layers go away and what is left is a partial repaint of one canvas. The interval correction matters most: at 6 tiles across versus 16, the total count differs by nearly seven times. Fix the interval and the reveal takes seven times longer on desktop, which reads as a slow page. Moving count and interval by the same ratio keeps the reveal time steady regardless of screen size.
Catching section boundaries by a sign flip instead of a threshold
Read scrollTop in a scroll handler and hardcode a number per section, like if (scrollTop > 800). Change the screen height and you have to measure again, and you need separate guarding so the background swap does not rerun on every one of the dozens of events a trackpad fires per second.
Each tick I compute getBoundingClientRect().top - bodyRect.top for the three sections, multiply it by the previous value, and only look at whether the sign flipped (prevSectionNpos * sectionNpos <= 0). The group changes only when some section flipped, and prevMatch holds the last group number so the same group is skipped. Scroll events pass through once per frame behind a ticking flag and requestAnimationFrame.
A sign crossing expresses "a boundary was just passed" without a pixel constant. There is no number to tune, so the code is unchanged when screen height changes, and a fast flick that jumps hundreds of pixels in a frame still does not miss a boundary. prevMatch blocks the rerun, so a finger wobbling near a boundary does not restart the mosaic every time. The rAF throttle was the ceiling: never pay for a background swap more often than the display refreshes.
What a user can do
A stale coordinate, an endless timer
Honestly, I left a fair amount behind.
bodyRect bothers me most. I measure document.body.getBoundingClientRect() once at load and keep using it, so the reference point drifts when a background image arrives late and changes the document height, or when the device is rotated. In the same vein, onresize() refreshes only the tile count and leaves the canvas width / height alone, so rotating from portrait to landscape leaves the canvas stretched. If I did it again I would fold both into one function bound to resize, and move position detection to IntersectionObserver. I was doing by hand something the browser already does well.
The setInterval inside runMosaic() does not hold its handle either. I came through the prototype's pile-up problem and left a smaller version of the same thing behind. Cross a boundary back and forth quickly and they overlap.
The six background jpgs all land on first paint with no preload and no lazy loading, even though the third group is not needed until you have scrolled a long way down. Finally, nothing looks at prefers-reduced-motion. On a page where the whole screen zooms and cross-fades every 12 seconds, that is not a matter of taste, it is a missing piece.









