MUST

·

Scroll · foundation

The reveal system

Ninety percent of the motion on a client site is something appearing as you scroll. The mistake almost everyone makes is building that as a separate Webflow interaction per element — forty interactions, forty places to change the easing, and a grid where six cards each fade in on their own timer. This is the alternative: one script, driven by a data-reveal attribute, using ScrollTrigger.batch() so everything that crosses the trigger line in the same frame animates as a single staggered group. Add the attribute in the Designer, get the motion for free, forever.

Open the live demo →

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 · matchMedia

Build time

25 min once. Two minutes on every project after that.

No-code?

Partly. The GSAP Interactions panel does scroll-in reveals with stagger, but per-component — it cannot express “every element on the site with this attribute”. Use the panel for one-off hero choreography, this script for the other 90%.

01

Webflow structure

element · class · attribute

<b>Section</b> <i>section_services</i> <b>Div</b> <i>padding-global</i> <b>Div</b> <i>container-large</i> <b>Div</b> <i>services_head</i> <b>Text</b> <i>services_eyebrow</i> <u>data-reveal="fade"</u> <b>Heading H2</b> <i>heading-style-h2</i> <u>data-reveal</u> <b>Paragraph</b> <i>text-size-medium</i> <u>data-reveal</u> <b>Collection List Wrapper</b> <i>services_list-wrap</i> <b>Collection List</b> <i>services_list</i> ← display: grid <b>Collection Item</b> <i>services_item</i> <u>data-reveal</u> ← attribute goes HERE <b>Div</b> <i>services_card</i> <b>Text</b> <i>services_no</i> <b>Heading H4</b> <i>services_card-title</i> <b>Paragraph</b> <i>services_card-body</i>

02

Styles to set in the Designer

services_list | Display <code>grid</code>, 3 columns desktop / 2 tablet / 1 mobile. The reveal script does not care about the layout — that is the point. services_item | Nothing motion-related. Do <strong>not</strong> set opacity 0 on it in the Designer — the head CSS below does that conditionally so the page still works when JS is blocked. any [data-reveal] element | Give it real height and padding. Elements that collapse to 0px never trigger reliably.

03

Building it with the Interactions panel

no code, free plan
  1. For a one-off hero: select each element → Interactions → trigger Scroll into view → pick an Animation Preset — Fade in, Slide in up, or Stagger fade in for a parent with children.
  2. Set From = Opacity 0 / Move Y 34px, To = Opacity 100 / Move Y 0, duration 0.7–0.9s, Ease Power2/3 Out.
  3. For a Collection List grid, apply the interaction to the Collection Item, not the card inside it, and use Stagger fade in so the grid arrives as one wave rather than card by card on separate timers.
  4. Turn on conditional playback → No animation for reduced motion on every one of these.

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

  • This is one interaction per component, copy-pasted by hand — there's no single rule that reveals "every element with this attribute" site-wide. Fine for a hero; on a 40-section page you're maintaining forty near-identical interactions instead of one script.
  • No ScrollTrigger.batch() equivalent — the panel can't guarantee that six cards crossing the trigger line in the same frame animate as one staggered group rather than six independently-timed ones.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

Any element | data-reveal | (empty) | Default: fade + 34px up. You will use this one 80% of the time. Eyebrows, small labels | data-reveal | fade | Opacity only. Anything under ~14px: movement reads as jitter. Images, cards | data-reveal | scale | Settles 1.04 → 1. Reads like a camera focusing. Section headers | data-reveal | left | Horizontal entrance. Sparingly, and only where there is room to the left. Figures, media | data-reveal | clip | Wipes up with clip-path instead of moving. The most expensive-looking of the five.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

<!-- Page settings → Inside <head> tag --> <style> /* Hide only once we know JS is alive, so a blocked script can never leave a client staring at an empty homepage. */ html.js [data-reveal] { opacity: 0; } html.js [data-reveal="clip"] { opacity: 1; clip-path: inset(0% 0% 100% 0%); } [data-reveal] { will-change: transform, opacity; } </style> <script>document.documentElement.classList.add("js");</script>

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag (or Site settings → Footer code to run site-wide) --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const mm = gsap.matchMedia(); mm.add({ motion: "(prefers-reduced-motion: no-preference)", reduced: "(prefers-reduced-motion: reduce)" }, (ctx) => { const { reduced } = ctx.conditions; const items = gsap.utils.toArray("[data-reveal]"); if (!items.length) return; // Accessibility is not optional, and it is three lines. if (reduced) { gsap.set(items, { opacity: 1, clipPath: "none" }); return; } const FROM = { up: { opacity: 0, y: 34 }, fade: { opacity: 0 }, scale: { opacity: 0, scale: 1.04 }, left: { opacity: 0, x: -36 }, right: { opacity: 0, x: 36 }, clip: { clipPath: "inset(0% 0% 100% 0%)" } }; items.forEach(el => gsap.set(el, FROM[el.dataset.reveal] || FROM.up)); // batch() collects every element that crosses the start line in the same // frame and animates them as ONE tween — that is what makes a grid feel // like a wave instead of six unrelated fades. ScrollTrigger.batch(items, { start: "top 88%", once: true, onEnter: batch => gsap.to(batch, { opacity: 1, y: 0, x: 0, scale: 1, clipPath: "inset(0% 0% 0% 0%)", duration: 0.9, ease: "expo.out", stagger: { each: 0.07 }, overwrite: true, onComplete() { gsap.set(batch, { clearProps: "willChange" }); } }) }); }); }); </script>

07

Gotchas that will cost you an hour

  1. Put the attribute on the Collection Item, not the card inside it. Webflow wraps CMS items in a div you do not style; attribute the inner card and the grid gap animates too, so the layout jumps.
  2. Anything that loads after DOMContentLoaded needs a refresh. Finsweet CMS Load, a lazy embed, a tab that reveals content — call ScrollTrigger.refresh() when it finishes or every trigger below it is measured against the old page height.
  3. start: "top 88%" beats "top 80%" on mobile. At 80% a phone-height card is already half read before it animates. Test on a real device, not the Designer preview.
  4. Never set opacity 0 on the class in the Designer. It looks identical right up until a CSP header or an ad-blocker kills the script and the client's homepage is blank. The html.js guard costs one line.
  5. Classic IX2 on the same element will fight this. Webflow interactions write inline transforms too. One system per element — attribute reveals, or the panel. Never both.
  6. once: true is a feature, not a shortcut. Replaying a reveal every time the user scrolls back up is the fastest way to make a site feel cheap.
08

Push it further

  • Read data-reveal-delay="0.2" inside onEnter to offset one element without building a new interaction.
  • Replace ease: "expo.out" with a CustomEase defined once — a single house ease across a site is a real brand decision.
  • Add a data-reveal="lines" case that hands off to SplitText (next lesson) so headings reveal per line through the same system.

Webflow Motion Lab — GSAP 3.15, all plugins free.

1

/ 30