Shopify Builds>LUS Brands>Theme-wide section reveal that replaces Dawn's per-element scroll animations

Theme-Wide Section Reveal That Replaces Dawn's Per-Element Scroll Animations

One entrance animation for every top-level section on the storefront, applied from the layout rather than authored into each section — with the hide decided before first paint, the reveal driven by an intersection observer, and reduced-motion and no-JavaScript visitors never hidden from anything.

Dawn animates on scroll only where an author has sprinkled its trigger classes into a section's markup, which no theme with dozens of bespoke sections applies consistently. This script hides every top-level section before paint from an inline style gated on prefers-reduced-motion, mirrors it with a noscript rule, tags and observes the sections, and hands over inside one animation frame — so nothing blinks and no exit path can leave the page hidden.

The Problem

Dawn's scroll animation is opt-in per element. A section slides in on entry only if its author put scroll-trigger and animate--slide-in on the right wrapper, and cascaded children only if data-cascade was added too. That works for the sections Shopify ships. It does not scale to a theme where most sections were written by different people at different times, because consistency then depends on every author remembering the same three attributes — and any section that forgets simply doesn't animate, which reads as a bug next to one that does.

LUS Brands wanted a single motion behavior across the storefront: every section enters the same way, nothing is special-cased in section markup, and a new section gets the behavior by existing. The question was where to put a rule like that so it never depends on the section knowing about it.

The Constraint

The hide has to happen before the first paint, and it has to be undone by something that might never run. Apply the hidden state from JavaScript and the shopper sees the section painted, then yanked invisible, then animated back — a blink on every page. Apply it from CSS with nothing conditional and a shopper whose script fails to load never sees the content at all. Both are unacceptable on a storefront, and they pull in opposite directions.

Motion preference is the third constraint. A visitor who has asked their operating system for reduced motion should see the page as if the system didn't exist: no hide, no observer, no transition, and no window in which their content is invisible.

And the hero must not animate. It is the first thing on the page; hiding it for even a frame is the one thing the whole build exists to avoid.

What We Built

theme.liquid loads de-global-section-reveal.min.js in place of Dawn's animations.js. The layout gives <html> a de-global-reveal-boot class and emits an inline <style id="de-global-section-reveal-boot-style"> that hides the direct .shopify-section children of #MainContent. That rule is wrapped in @media (prefers-reduced-motion: no-preference), so a reduced-motion visitor's browser never applies it, and it is mirrored by a <noscript> block forcing every section to opacity: 1 and transform: none, so a browser with scripting off never applies it either.

The script then does four things in order. It walks #MainContent's direct .shopify-section children and tags each with Dawn's own class vocabulary — scroll-trigger animate--slide-in scroll-trigger--offscreen — plus a marker of its own, de-section-reveal--enhanced. It observes each one with an IntersectionObserver whose rootMargin is 0 0 -40px 0, so a section reveals once it is meaningfully on screen rather than at its first pixel. It adds de-global-reveal-hydrated to <html> and removes the boot class inside a requestAnimationFrame, so the switch from "hidden by boot style" to "hidden by offscreen class" lands in one frame and nothing flashes between them. And it calls endBoot() on every exit path — no #MainContent, reduced motion, normal completion — so there is no route through the script that leaves the boot state on.

Any section can opt out by carrying a data-de-section-reveal-skip attribute anywhere inside it. The hero does. The main collection grid does too, because it runs its own row-level reveal and would otherwise animate twice.

A paired stylesheet, de-global-section-reveal.css, supplies the eased, delayed transition the sections use once they're tagged. The hide and the slide themselves come from Dawn's own animation rules, which is the reason the script tags sections with Dawn's class names rather than inventing its own.

LAYOUT · BEFORE FIRST PAINTSCRIPT · AFTER LOAD Inline boot style hides #MainContent > .shopify-section @media (prefers-reduced-motion: no-preference) <noscript> twin opacity: 1; transform: none <html class="de-global-reveal-boot"> the state the script must end reduced motion? yes → skip everything #MainContent present? no → nothing to observe Tag + observe sections scroll-trigger--offscreen rootMargin 0 0 -40px 0 Hand over in one frame requestAnimationFrame: +hydrated, −boot endBoot() called on every exit: · reduced motion · no #MainContent · normal completion the page can never be left hidden if scripting is off if scripting runs script reads noyesthen exitexitexit
The hide is decided before paint, by CSS the reduced-motion visitor never receives and the no-script visitor has overridden. The script's only obligation is to end the boot state — and it does so on every path out, which is what makes a theme-wide hide safe to ship.

Why This Way

Reusing Dawn's own class names — scroll-trigger, animate--slide-in, scroll-trigger--offscreen — means the vendor's animation CSS does the visual work. The local stylesheet, de-global-section-reveal.css, only softens the timing with an eased, delayed transition. Nothing had to be written about how a section slides in; only about when it is allowed to.

Putting the decision in the layout rather than in the sections is what makes it uniform. A section doesn't know it animates. It is a direct child of #MainContent, so it does. Opting out is an attribute placed in the section's markup, which the section author can add without touching the script.

The cost lands in the handover. There is a window between the boot style hiding a section and the script tagging it with the offscreen class, and if those two states disagree for a frame the shopper sees a flash. The requestAnimationFrame around the class swap is there to close that window, and the whole design is only as good as that one frame. We also gave up per-element cascade: children inside a section don't stagger, because only top-level sections are observed. Uniform motion was the brief, and stagger was the thing Dawn's system did that made it inconsistent.

Implementation Notes

  • prefers-reduced-motion: reduce short-circuits init() and ends the boot state immediately, so a reduced-motion visitor is never hidden from anything, even for a frame.
  • A per-element guard, el.dataset.deSectionRevealApplied, prevents double-tagging when init() runs again after shopify:section:load in the theme editor.
  • Only direct children of #MainContent are observed. Nested sections and app blocks inside them are not animated independently.
  • endBoot() runs on every exit path — no #MainContent, reduced motion, normal completion — so no route through the script leaves the boot class on <html>.
  • The paired stylesheet de-global-section-reveal.css supplies the eased, delayed transition; the hide and the slide themselves are Dawn's rules.
  • de_collection_banner carries the skip attribute; main-collection-product-grid sets it when the collection has products, because that grid runs its own row-level reveal.

Edge Cases

  • Scripting disabled: the <noscript> rule forces opacity: 1 and transform: none on every section, so the page is fully visible with no animation.
  • Reduced motion requested: no hide, no observer, no transition. The visitor gets the page as it is.
  • Theme editor section reload: init() re-runs and skips already-tagged elements, so an edited section doesn't get tagged twice or observed twice.
  • A section that scrolls back out is re-hidden — the observer re-adds the offscreen class until it unobserves on the first full reveal — so the entrance plays once per section, not once per viewport crossing.
  • A page with no #MainContent — a layout variant, an app-rendered route — ends the boot state at once and does nothing else.

Platform Primitives Used

  • intersection-observer — one observer over every top-level section, with a negative bottom rootMargin so a section reveals when it is genuinely in view, unobserved after its first reveal.
  • liquid-schema-settings — the reveal is not a section setting; the opt-out is a data attribute a section renders from its own markup. The primitive appears here because the collection grid decides whether to emit that attribute from its settings.

Where It Runs

Globally. The boot style, the <noscript> twin and the script all live in theme.liquid, so every page of the storefront gets the same behavior and there is no template list. What varies per page is only which sections have opted out.

What This Demonstrates

  • Accessibility remediation at component level — the primary capability: motion held behind the visitor's own prefers-reduced-motion preference, decided in CSS before any script runs, with a no-script path that shows everything.

How We Know

One JavaScript asset from the theme, roughly 100 lines, plus the loader branch and boot style in the layout, plus four client working-session records. Those records show animation being treated as a whole-page decision from the first design conversation, with specific asks following — a directional scroll effect on a product-page element, parallax treatment options — and "smoothing" the animations named as a later step. None of them specifies a theme-wide reveal replacing Dawn's per-element system; that shape is a code-side decision, and this page reconstructs it from the asset and the layout. The client record explains why motion mattered. The code explains what was done about it.

Related Builds

The Buy-vs-Build Question

There is no app for this, and there shouldn't be. An animation app injects a script that hides and reveals elements after the page has painted, which is the blink this build exists to prevent, and whether it consults the visitor's motion preference is up to the app. What was needed was a rule in the layout, not a product. Where remediation belongs in the theme and where a tool earns its place is the question on the accessibility remediation decision page.

Provenance & Evidence

  • Client: LUS Brands — loveurcurls.com
  • Surface: Global — loaded from the layout on every page
  • Templates served: none listed; the behavior is mounted by theme.liquid, not by a template
  • Complexity: Medium
  • Attribution: Deploi-authored. The script, the boot style, the <noscript> twin and the opt-out attribute are ours. It runs in a theme built on Dawn 15.4.1 and deliberately reuses Dawn's animation class names so the vendor's CSS performs the slide.
  • Status: Live, verified 2026-09-07
  • Evidence: One JavaScript asset and the layout, read from the theme, plus four client working-session records
  • Confidence: Strong — the client record establishes the motion requirement; the code shows how it was met
  • Primary capability: Accessibility remediation at component level

Ready for Motion That Respects the Visitor's Settings?

You dream it. We build it. If your storefront animates some sections and forgets others — or animates for people who asked it not to — Contact us today and we'll put one rule in the layout that every section inherits.

More builds