Scroll-reveal animation is a two-part contract: SCSS declares how a thing fades in, and JS decides when by toggling one class. You write the SCSS; the framework supplies the JS.
The contract: .animate → .is-animated
An element opts in by carrying the animate class in its markup. It starts hidden; when
is-animated is added, it plays. The fade() mixin from
lib/animate wires both states for you:
// lib/animate.scss (excerpt)
@mixin fade($direction: none, $duration: 1s, $delay: 0s, $target: self, $easing: ease-out) {
#{$selector} { // e.g. &.animate
animation-duration: $duration;
animation-fill-mode: forwards;
opacity: 0;
// $direction sets a from-state translate (up / down / left / right)
}
#{$applied_selector} { // e.g. &.animate.is-animated
animation-name: fade;
}
}
The mixin only ever sets the from-state (opacity 0, plus an optional directional
translate) and names the animation once is-animated lands. The fade keyframe
itself is emitted globally by Crucial.scss, so it ships
eagerly and every mixin call just references it by name:
// global/Crucial/Crucial.scss
@keyframes fade {
to {
opacity: 1;
translate: 0 0;
}
}
Who adds is-animated
The Animation component (components/Animation/Animation.js, selector .animate)
observes each opted-in element with the shared
global IntersectionObserver and adds the class the moment it
scrolls into view:
// components/Animation/Animation.js
static selector = '.animate'
animateIn() {
manipulateDOM(() => {
this.element.classList.add('is-animated')
})
}
A host discovers and lazy-loads it via the AnimatesOnScroll trait — mix that onto a
Module and every .animate descendant inside it boots the Animation component
automatically. The JS never touches a style; it only toggles the class the SCSS is
keyed to.
Fading in a section
Give the element animate in the view, then call the mixin with $target: self:
@use 'lib/animate' as animate;
.location-gallery {
opacity: 0;
@include animate.fade(up, $target: self);
}
That's it — hidden until it scrolls into view, then it fades up over its default
duration. Use $direction (up, down, left, right, none) for the entry
translate, $duration / $delay for timing, and $target to move the animated
selector onto the element itself (self), its children (children), or a custom child.
Why keyframe-global but mixin-local. A @keyframes can't be scoped, so emitting
fade once in Crucial avoids every component redeclaring it. The mixin stays local
because the from-state (how far and which way it translates, how long it takes) is a
per-component design decision. The .animate / .is-animated split keeps the reveal
trigger in JS and the reveal expression in SCSS.
Related
- The
lib/directory — where thefademixin lives. - The state-class contract — the general rule
.animate/.is-animatedis an instance of. - Observers — the global IntersectionObserver the Animation component uses.