You can rebuild an award-winning while-scrolling animation in Webflow with no code at all — a gallery where each image scales up as it enters the viewport and scales back down as it leaves. The catch isn’t the animation itself. It’s the layout. Get the structure right and the interactions become almost trivial; get it wrong and no amount of tweaking will make it feel smooth on every screen.
Looking closely at the original, three things make it sneaky. The transform origin changes depending on whether an image is scaling in from the bottom or out at the top. Every image starts scaling the moment it scrolls into view and stops when it’s fully visible, regardless of screen size or aspect ratio. And the scaling makes each image feel like it briefly sticks to the edge of the viewport. None of these has an obvious native solution — until you change perspective.
That change of perspective is the whole lesson. A little lateral thinking, absolute positioning, one calc() expression, and Webflow’s native while-scrolling interactions are enough to push the platform surprisingly far.
How it works
Two images beat one impossible one. The transform origin has to differ for scaling in versus scaling out, and a single element can’t hold two origins. So each grid cell gets two copies of the image, wrapped in a div that becomes the real cell. One copy (cc1) carries the origin for scaling up from the bottom; the other (cc2) carries the origin for scaling down from the top. Only one is visible at any moment — you toggle them with a simple scroll-into-view interaction. The hard problem dissolves into two easy ones.
The gray rectangle — the key mental model. A while-scrolling timeline measures its 0%–100% against the viewport height, but an image’s height is driven by the viewport width, so there’s no percentage that stays correct across devices. The fix is to stop animating against the image and animate against the empty space instead. Picture the image sitting at the bottom of the viewport and a gray rectangle filling the space above it: that rectangle is fully visible exactly when the image should start scaling, and starts leaving exactly when the image is fully grown. Its height, by definition, is the viewport minus the image — calc(100vh - 100%) inside a wrapper set to the image’s height. That’s device-independent, and it gives 0% and 100% a concrete meaning at last.
Triggers, not images, drive the timeline. You build that rectangle as an absolutely-positioned div (the “trigger”) inside a position: relative image wrapper. One trigger sits above the image for the scale-up, a mirrored one sits below for the scale-down. The while-scrolling interaction is applied to the trigger with offsets set to “start when fully in view, end when it starts leaving.” The animation targets the trigger’s sibling image, so a single interaction, applied by class, drives every cell in the grid at once.
Empty cells as div blocks. The gallery grid is irregular — most cells are empty. Rather than hand-placing each image with grid positioning (a nightmare at scale), you fill occupied cells with images and empty cells with empty div blocks, in reading order. Filling the grid becomes a breeze.
CMS needs custom elements + custom attributes. A collection list can’t give each item its own combo class or grid slot from the Designer. The workaround pairs two of Webflow’s most powerful features: wrap each image in a custom element and bind its class attribute to a CMS text field (so each item can carry different transform-origin classes), and give each item a custom tag attribute equal to its slug so a code embed can target items one by one and set their margins from CMS number fields.
How to use it
-
Placeholder section. Add a section (no styling) with a container and a content wrapper set to
display: flex, centered,height: 100vh. It simply hides the gallery below the fold. -
Gallery grid. Add a second section with a container and a content wrapper set to
display: grid, four columns,gap: 25vw. Fill occupied cells with animg(width100%) and every empty cell with an empty div block, matching the original’s layout row by row. -
Double every image. Wrap each image in a div (this becomes the cell), then duplicate the image inside it. Give the first copy the
cc1combo class and the secondcc2(setcc2todisplay: none). Then add transform-origin combo classes per copy — e.g.cc-top-left/cc-top-rightfor the scale-up copies andcc-bottom-left/cc-bottom-rightfor the scale-down copies — reading the correct corners off the original for each cell. -
Build the triggers. Set the image wrapper to
position: relative. Inside it, add a div (position: absolute,width: 100%) and give it a height of:height: calc(100vh - 100%);Give it a
cc-abovecombo class and a bottom offset of100%so it sits above the image. Duplicate it ascc-belowwith a top offset of100%so it sits below. -
Scale-up animation. Select the above trigger and add a while-scrolling interaction (play scroll interaction), set to start when the element is fully in view and end when it starts scrolling out. Add one scale action on the sibling image:
0at the 0% keyframe,1at 100%. Target the plaingallery-section-imageclass (no combos) so both images in the cell scale, and set the trigger to apply by class — then copy the trigger into every cell. -
Scale-down animation. On the below trigger, add the same while-scrolling interaction but invert the scale:
1at 0%,0at 100%. Apply by class and copy into every cell. -
Swap the visible image. Add a scroll-into-view interaction on the above trigger with a hide/show action group: show
cc1(set toblock), hidecc2(set tonone). Add an empty scroll-out-of-view animation so the interaction resets and replays each pass. Do the mirror on the below trigger (showcc2, hidecc1). -
Clean up. Reset the triggers’ test opacity and background, and set
pointer-events: noneon the trigger’s base class so it never blocks clicks.
Making it CMS-driven
-
Flex, not grid. For a collection, set the list to the same content-wrapper class plus a
cc-flexcombo:display: flex, wrap, aligned top-left. Give each collection item the image-wrapper class pluscc-flexand this width:/* 4 items per row, 3 gaps of 25vw between them */ width: calc((100% - 75vw) / 4); -
Bind classes via custom elements. Wrap each of the two images in a custom element and bind its
classattribute to a CMS text field holding the exact base + combo class names (space-separated). Apply the scale transform to these wrappers rather than the images directly. -
Position each item from the CMS. Add
margin-rightandmargin-leftnumber fields to the collection and give each item a custom tag attribute equal to its slug. In a code embed, target that attribute and set the horizontal margins from the fields, where each empty cell is worth 25% of a row:/* margin-left / margin-right are CMS number fields (count of empty cells) */ margin: 0 calc(var(--margin-right) * 25%) 0 calc(var(--margin-left) * 25%); -
Responsiveness. On portrait, drop the grid to two columns and set empty cells to
display: none; for the CMS version, override the margins to0, set the item width tocalc((100% - 25vw) / 2), stretch the wrappers on the Y axis, and add animg-coverclass (object-fit: cover) so mixed aspect ratios still fill neatly.