Clip reveal with counter-scale
Everyone animates a mask open. What separates this from that is the counter-scale: while the clip-path expands, the image inside scales from 1.35 down to 1 over the same duration with the same ease. The two motions cancel, so the photograph appears fixed in space while a curtain slides off it — the way a real reveal works. Animate the mask alone and the image looks like it is being squeezed out of a slot; animate the scale alone and it looks like a zoom. Together they read as film. This costs one extra tween and it is the single highest ratio of perceived quality to effort in this entire lab.
How to build
Panel + script
Free plan gets you part of the way
The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.
How the two routes work →Plugins
ScrollTrigger
Build time
30 min including the four directions.
No-code?
Close. The GSAP panel can animate clip-path and can animate a child's scale in the same timeline, so a single instance is buildable. The attribute-driven four-direction version is code.
Webflow structure
element · class · attribute<b>Div</b> <i>reveal_frame</i> <u>data-clip="up"</u> ← overflow hidden + fixed aspect-ratio <b>Image</b> <i>reveal_img</i> ← the counter-scale target <b>Div</b> <i>reveal_frame</i> <u>data-clip="left" data-clip-scrub</u> <b>Image</b> <i>reveal_img</i> <i>values:</i> <u>up</u> (curtain rises) <u>down</u> <u>left</u> (wipes right) <u>right</u>
Styles to set in the Designer
reveal_frame | <code>position: relative; overflow: hidden;</code> and a fixed <code>aspect-ratio</code>. Without a fixed ratio the frame is sized by the image, and a scaled image will change that size mid-tween. reveal_img | <code>width: 100%; height: 100%; object-fit: cover</code>. Nothing else — the script owns its transform. reveal_frame (hero only) | Set <code>clip-path: inset(0% 0% 100% 0%)</code> directly in the Designer for above-the-fold frames, so there is no flash of the un-masked image before the script runs.
Building it with the Interactions panel
no code, free plan- Select reveal_frame → Interactions → trigger Scroll into view (once) → animate Clip from your chosen direction's inset (e.g. 0% 0% 100% 0% for "up") to 0% 0% 0% 0%.
- In the same step — same position on the timeline as the clip — select the child reveal_img and animate Scale from 135% to 100%.
- Match duration (≈1.25s) and easing (Ease Expo Out) exactly between the two — a mismatch of even 0.1s breaks the illusion in the panel exactly as much as it does in code.
- For a hero frame above the fold, set the resting Clip value directly in the Designer so there's no flash of the full image before the interaction fires.
What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
- One direction per interaction — the attribute-driven data-clip="up|down|left|right" switch that lets one script serve every frame on the site has no panel equivalent; each direction is a separately built, near-identical interaction.
- No attribute-driven scrub toggle — a scrubbed vs. once-only reveal are two interactions to build and maintain separately in the panel.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesreveal_frame | data-clip | up | Curtain rises from the bottom. The default and the right choice 70% of the time. reveal_frame | data-clip | left / right | Horizontal wipe. Reads as more editorial; use it where the layout already has a strong horizontal axis. reveal_frame | data-clip | down | Wipes downward. Use for something arriving from above — a hero under a nav, for instance. reveal_frame | data-clip-scrub | (empty) | The mask follows the scrollbar instead of firing once. One per page, maximum.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const FROM = { up: "inset(0% 0% 100% 0%)", down: "inset(100% 0% 0% 0%)", left: "inset(0% 100% 0% 0%)", right: "inset(0% 0% 0% 100%)" }; gsap.matchMedia().add({ motion: "(prefers-reduced-motion: no-preference)", reduced: "(prefers-reduced-motion: reduce)" }, ctx => { const frames = gsap.utils.toArray("[data-clip]"); if (ctx.conditions.reduced) { gsap.set(frames, { clipPath: "inset(0% 0% 0% 0%)" }); return; } frames.forEach(frame => { const img = frame.querySelector("img"); const dir = frame.dataset.clip || "up"; const scrub = frame.hasAttribute("data-clip-scrub"); gsap.timeline({ defaults: { duration: 1.25, ease: "expo.out" }, scrollTrigger: scrub ? { trigger: frame, start: "top 85%", end: "top 25%", scrub: 1 } : { trigger: frame, start: "top 82%", once: true } }) // Both tweens at position 0, same duration, same ease. That is the rule. .fromTo(frame, { clipPath: FROM[dir] }, { clipPath: "inset(0% 0% 0% 0%)" }, 0) .fromTo(img, { scale: 1.35 }, { scale: 1 }, 0); }); }); }); </script>
Gotchas that will cost you an hour
- Same duration, same ease, same start position. If the mask and the scale drift apart by even 0.1s the illusion collapses and it reads as two unrelated animations happening at once.
- Scale down to exactly 1, never past it. Ending at 1.02 leaves the image permanently soft on a retina screen, and nobody can tell you why the photography looks cheap.
- Set the closed state in the Designer for anything above the fold. Otherwise there is one frame of fully-visible image before JS runs — most obvious on a slow 3G first load, which is exactly when a client is watching.
- clip-path on the frame, not on the image. Clipping the image directly means the counter-scale moves the clip too, and the mask edge wobbles.
- Safari needs the same number of arguments in both inset() values. Going from inset(0 0 100%) to inset(0%) silently fails to interpolate. Always write all four.
- Do not scrub more than one of these per page. Scrubbed reveals make the reader responsible for the animation; three of them and the page feels like work.
Push it further
- Add a third tween at position 0: the caption sliding up from behind the frame's bottom edge. It costs nothing and finishes the lockup.
- Use inset(0% 0% 0% 0% round 12px) to animate the corner radius open at the same time. Subtle, and very hard to place if you have not seen it before.
- Stagger a grid with ScrollTrigger.batch from the reveal system instead of per-element triggers — the two systems compose without any changes.
Webflow Motion Lab — GSAP 3.15, all plugins free.
16
/ 30