React-React spark scroll v2.0.3: Scroll-based actions and animations for react

icon
Latest Release: v2.0.3

react-spark-scroll

React port of spark-scroll.

The future!

This repo has been around for a little while now. However, recently I re-created the demo utilizing a drastically different approach which was inspired by react-motion. You can find this experimental demo in the examples/demo-functional dir. It completely does away with animators and direct DOM manipulation in favor of pure functional elegance. Compatibility considerations and performance implications, etc. have not been explored. Going forward, it's likely that the old way will be deprecated and this new approach will take it's place. Update: performance suffers significantly because of repeated dom-diffing, so I will probably break this out into it's own repo instead. Update2: It's been broken out into is own project, react-track

install

# gsap and gsap-animator will be included as a dependency:
npm install react-spark-scroll-gsap

Start with the GSAP version of the library, but note that you can use Rekapi or your own animator if you have a preference.

Tradeoffs:

  • GSAP is much easier to configure. That's because rekapi has some additional configuration necessary (see #3) beyond npm install spark-scroll-rekapi. If you're in the quick-and-dirty experimentation stage, use gsap to get up and running faster.

  • Although I haven't done any benchmarks I suspect that rekapi is marginally faster than GSAP. That's because rekapi was built around the concept of timeline-based animation and spark-scroll is all about treating the scroll position as a timeline. Update: I performed some unscientific tests and GSAP actually seems to perform significantly better

  • GSAP supports animating SVGs. This is the main deciding factor for me. If I don't need SVG animation I prefer using rekapi although it's not a strong preference.

  • Rekapi and GSAP have different licenses.

Alternative installations:

# rekapi will be included as a dependency:
npm install react-spark-scroll-rekapi

or

# in this case you will have to manually setup an animator
npm install react-spark-scroll

demo

You can read all of the documentation below, but first checkout the demo and the source code. It's so declarative you might not even need documentation ;-)

build and run the examples

git clone https://github.com/gilbox/react-spark-scroll.git
cd react-spark-scroll/
npm i
npm run examples
open http://localhost:8080/webpack-dev-server/

why?

I was curious to find out how difficult it would be to create complex animations with React. At first, I thought that React's lack of a direct equivalent to angular's attribute-type directive (restrict: 'A') would be a major drawback. However, using higher-order components to generate variations of the same component turned out to be a remarkably elegant solution. Ie., <SparkScroll.div />, <SparkScroll.span />, <SparkScroll.h1 />, etc...

The one place where angular might have an advantage is through it's ability to facilitate more expressive syntax. For example, to toggle a class in angular:

<!-- angular: -->
<section
  class="pin"
  spark-trigger="pin-cont"
  spark-scroll="{
      topTop: { 'downAddClass,upRemoveClass': 'pin-pin' },
      bottomBottom: {  'downAddClass,upRemoveClass': 'pin-unpin' }
  }">

...vs in react:

<SparkScroll.section
  className={cx("pin",{
    'pin-pin':this.state.pinPin,
    'pin-unpin':this.state.pinUnpin})}
  proxy="pin-cont"
  timeline={{
     topTop: {
       onDown: () => this.setState({pinPin:true}),
       onUp:   () => this.setState({pinPin:false})
     },
     bottomBottom: {
       onDown: () => this.setState({pinUnpin:true}),
       onUp:   () => this.setState({pinUnpin:false})
     }
  }}>

Note that a proxy is used to provide a canonical scroll position. This is useful because it's very common for the top of the element to change during scrolling.

It is much much much easier to reason about what is actually happening in the react version. All the tricks employed by angular to achieve the expressiveness is not worth the confusion it often creates for developers, IMO. I no longer have a strong opinion about which way is better. Also, it's actually possible to achieve the same angular syntax in React but I'm not sure if that's a good idea.

setup

// the require statement returns a factory function, which we can call
// with an options object. `invalidateAutomatically:true` is a very
// common option.
//
// Note: You should normally call this factory only once, so in an application
// with multiple JS files that need SparkScroll, it should
// probably live in it's own file (see the examples/demo/app-spark.js)
var {SparkScroll, SparkProxy, sparkScrollFactory} =
  require('react-spark-scroll/spark-scroll-rekapi')({
    invalidateAutomatically: true
  });

// (optional)
// We can wrap any component using the factory methods
// Assume that `MyClass` is a React class we created
SparkScroll['MyClass'] = sparkScrollFactory(MyClass);

var App = React.createClass({
  render() {
    return (

      <SparkScroll.h1
        timeline={{
          topBottom: {opacity: 0},
          centerCenter: {opacity: 1}
        }}>fade</SparkScroll.h1>

      <SparkScroll.MyClass
        myClassProperty="some value that MyClass requires"
        timeline={{
          'topTop+100': {width: '0%', backgroundColor: '#5c832f'},
          'topTop+250': {width: ['100%', 'easeOutQuart'], backgroundColor: '#382513'}
        }} />
    )
  }
});

usage

Basic Callback Example

<SparkScroll.h1
  timeline={{
    120:{ onUp: _ => console.log('scrolling up past 120!') },
    121:{ 'onUp,onDown': e => console.log('going ' + (e==='onUp' ? 'up!':'down!')) }
  }}>
  This Title is Sparky
</h1>

Formula Example

<SparkScroll.h1
  timeline={{
    topTop:{ onUp: _ => console.log('scrolling up past element top hit top of viewport!') },
    'bottomBottom+50':{ 'onUp,onDown': e => console.log('going ' + (e==='onUp' ? 'up!':'down!')) }
  }}>
  This Title is Sparky
</h1>

Animated Example (with formulas)

<SparkScroll.h1
  timeline={{
    topTop:{ color: '#f00', marginLeft: '50px' },
    topBottom:{ color: '#000', marginLeft: '0px' }
  }}>
  This Title is Spark Animated
</h1>

Animated Less-Basic Example with easing (no formulas)

<SparkScroll.h1
  timeline={{
    ease:'easeOutQuad',
    120:{opacity:'0'},
    121:{opacity:'0.8', top:'151px', color:'#fff'},
    140:{opacity:'1.0', top:'0px', color:'#444'}
  }}>
  This Title is Sparky
</h1>

Animated Example with Override element-wide easing at a specific keyframe (with formulas)

<SparkScroll.h1
  timeline={{
    ease:'easeOutQuad',
    topTop:{opacity:'0'},
    centerCenter:{opacity:'0.8', top:'151px', color:'#fff'},
    bottomBottom:{opacity:'1.0', top:'0px', color:'#444', ease: 'linear'}
  }}>
  This Title is Sparky
</h1>

Callback on Scroll Event

The callback property expects a function. The function will be called for every frame of scrolling. react-spark-scroll internally debounces scroll events so the callback will not necessarily be called on all native scroll events.

Every time the function is called, it is provided one argument, ratio which is a decimal value between 0 and 1 representing the progress of scroll within the limits of the maximum and minimum scroll positions of the timeline property. The simplest use of the callback property would look something like this:

<SparkScroll.div
  callback={ ratio => console.log('callback @ ' + ratio) }
  timeline={{ topBottom:0, topTop:0 }} />

When react-spark-scroll calls the callback function, the ratio is calculated based on the current scroll position, and the topBottom and topTop formulas.

Note that in the preceding example instead of assigning an object to the keyframes (topBottom and topTop), we simply assign 0. However, if we wanted to use a callback while at the same time taking advantage of action and animation properties we could do something like this:

<SparkScroll.h1
  callback={ ratio => console.log('callback @ ' + ratio) }
  timeline={{
    topTop:{ opacity: 0 },
    topCenter:{ opacity: 0.3 },
    topBottom:{ opacity: 1, onUp: _ => console.log('scrolling up') }
  }}>
  This Title is Spark
</h1>

Note that in this example, the callback's ratio argument is calculated using the topTop and topBottom formulas because they are at the extremes of the keyframe range for this element.

actions

Actions are triggered only when hitting a keyframe. An action can cause something to happen when scrolling up past the keyframe, down past the keyframe, or both. There are currently only two built-in actions: onUp and onDown which simply trigger a callback function.

custom actions

Custom actions may be added via the options object of the react-spark-scroll factory function, utilizing the actions property. For example, we could create a log action that simply logs a message to the console whenever it's activated:

var sparkScroll = require('react-spark-scroll/spark-scroll-rekapi')({
  actions: {
    log: {
      down(o) {
        console.log(`spark: hit keyframe [ ${o.formula} ] scrolling down. value: ${o.val}`);
      }
      up(o) {
        console.log(`spark: hit keyframe [ ${o.formula} ] scrolling up. value: ${o.val}`);
      }
    }
  }
}

And putting the new action to use might look like this:

<SparkScroll.h1
  timeline={{
    topBottom: {opacity: 0, log: 'foo'},
    centerCenter: {opacity: 1, log: 'bar'}
  }}>fade</SparkScroll.h1>

When scrolling up and down we'd see in the console:

spark: hit keyframe [ centerCenter ] scrolling down. value: bar
spark: hit keyframe [ topBottom ] scrolling down. value: foo
spark: hit keyframe [ topBottom ] scrolling up. value: foo
spark: hit keyframe [ centerCenter ] scrolling up. value: bar

formulas

Formulas are dynamically calculated keyframes. They usually require that you implement some form of invalidation, the simplest of which is setting the invalidateAutomatically option to true.

Here are all of the formulas that ship with react-spark-scroll:

const _sparkFormulas = {

  // top of the element hits the top of the viewport
  topTop(element, container, rect, containerRect, offset) {
    return ~~(rect.top - containerRect.top + offset);
  },

  // top of the element hits the center of the viewport
  topCenter(element, container, rect, containerRect, offset) {
    return ~~(rect.top - containerRect.top - container.clientHeight / 2 + offset);
  },

  // top of the element hits the bottom of the viewport
  topBottom(element, container, rect, containerRect, offset) {
    return ~~(rect.top - containerRect.top - container.clientHeight + offset);
  },

  // center of the element hits the top of the viewport
  centerTop(element, container, rect, containerRect, offset) {
    return ~~(rect.top + rect.height / 2 - containerRect.top + offset);
  },

  // center of the element hits the center of the viewport
  centerCenter(element, container, rect, containerRect, offset) {
    return ~~(rect.top + rect.height / 2 - containerRect.top - container.clientHeight / 2 + offset);
  },

  // center of the element hits the bottom of the viewport
  centerBottom(element, container, rect, containerRect, offset) {
    return ~~(rect.top + rect.height / 2 - containerRect.top - container.clientHeight + offset);
  },

  // bottom of the element hits the top of the viewport
  bottomTop(element, container, rect, containerRect, offset) {
    return ~~(rect.bottom - containerRect.top + offset);
  },

  // bottom of the element hits the bottom of the viewport
  bottomBottom(element, container, rect, containerRect, offset) {
    return ~~(rect.bottom - containerRect.top - container.clientHeight + offset);
  },

  // bottom of the element hits the center of the viewport
  bottomCenter(element, container, rect, containerRect, offset) {
    return ~~(rect.bottom - containerRect.top - container.clientHeight / 2 + offset);
  }
};

custom formulas

Formulas allow you to add keyframes to the timeline that are dynamically calculated based on any of the following objects:

  • element: DOM element
  • container: Body DOM element
  • rect: element's bounding rect
  • containerRect: container's bounding rect
  • offset: offset passed into the formula

Custom formulas can be added via the options object of the react-spark-scroll factory function, utilizing the formulas property. For example:

var sparkScroll = require('react-spark-scroll/spark-scroll-rekapi')({
  invalidateAutomatically: true
  formulas: {

    //similar to the built-in topBottom formula, except that offset
    // is calculated as a percentage of the viewport height

    topBottomPct: (element, container, rect, containerRect, offset) =>
      ~~(rect.bottom - containerRect.top + offset*containerRect.clientHeight/100)
  }
});

Custom Animation Engine

The factory method returned by require('react-spark-scroll') expects an options object where only one option is required: animator. animator should be an object with the property instance of type function. Invoking animator.instance() returns an instance of a Spark Scroll-compatible animator. Included with react-spark-scroll are two different animators: Rekapi and GSAP. Here is an example of how the GSAP animator can be used to bootstrap the factory method:

const _factory = require('react-spark-scroll');

function factory(options) {
  return _factory(assign({
    animator: {
      instance: () => new GSAPAnimator()
    }
  }, options));
}

Note that we've created another factory method to wrap the react-spark-scroll factory method so that additional options may be passed in.

As mentioned, react-spark-scroll already ships with options for two different animation engines, which you can include by manually installed the dependencies you need or simply:

require('react-spark-scroll-rekapi');

// OR:

require('react-spark-scroll-gsap');

If you wish to use a custom animation engine, your Animator class must support the following Rekapi-like interface:

const animator = new Animator(/* optional args */);
const actor = animator.addActor({ context: <dom element> })  // works just like rekapi.addActor(...)
actor.keyframe(...)
actor.moveKeyframe(...)
actor.removeAllKeyframes()
animator.update(...)       // works just like rekapi.update(...)

See below and the Rekapi docs for implementation details.

actor.keyframe(scrollY, animations, ease)

Creates a new keyframe. A keyframe should support the following properties...

  • scrollY The vertical scroll position (the library will treat this as time)

  • animations Simple object with css properties and values, for example:

    • {marginLeft: "0px", opacity: 1}
    • {borderRight: "5px", opacity: 0}
  • ease Simple object with property for each property in animations object (see above)

    • {marginLeft: "easeOutSine", opacity: "bouncePast"}
    • {borderRight: "linear", opacity: "easeinSine"}

actor.finishedAddingKeyframes

actors can optionally expose this function which will be called when parsing has completed

actor.moveKeyframe(from, to)

Moves a keyframe to a different time (scroll) value.

  • from Source keyframe

  • to Destination keyframe

animator.update(scrollY)

Updates the animation to a specific keyframe.

  • scrollY The vertical scroll position (the library will treat this as time)

TweenMax/TweenLite (GSAP)

The syntax when using TweenMax will differ slightly because TweenMax has some differences in the animation properties it supports. For example, while Rekapi supports the rotate property which takes a string value like 360deg, TweenMax instead supports rotation which takes a numeric value like 360. TweenMax also supports a rather different set of easing equations than Rekapi.

spark-scroll TweenMax demo

Note: I suspect that Rekapi is slightly faster than GSAP for scroll-based animation because it was built specifically for keyframe animations. However, if you are interested in animating SVG then use the GSAP animator because GSAP supports SVG animations but Rekapi does not.

As mentioned, the easiest way to use GSAP is via:

require('react-spark-scroll-gsap');

However, this will include TweenMax. To customize your build instead of the above, use:

require('react-spark-scroll');

Now you can include a subset of TweenMax since TweenMax isn't specified as a dependency of react-spark-scroll. TweenLite.js, CSSPlugin.js, and TimelineLite.js are the minimum subset of files required by GSAPAnimator. Load those files in however you wish, and then copy node_modules/react-spark-scroll/src/spark-scroll-gsap.js into your project and remove the require('gsap') line.

status

Completed:

  • Keyframe animations w/Rekapi
  • Formulas
  • Actions (only supports onUp and onDown with different callback semantics than spark-scroll)
  • onScroll callback prop (previously in angular was spark-scroll-callback attribute)
  • Custom formulas, actionProps
  • sparkSetup
  • SparkProxy (in angular called sparkTrigger)
  • publish to npm
  • Demo
  • Support for GSAP
  • README
  • Invalidation
    • Manual invalidation mechanism
    • Invalidation interval
    • Automatic invalidation on window resize

Todo:

  • Test on various browsers
  • Re-parsing of data when changed

Probably Won't do:

Contributing

Publishing to NPM

  • First make sure to bump the version number in package.json in accordance with semantic versioning practices. If you think a major version bump is warranted, go for it!

      # preparation
      npm run build-npm-all
      
      # actually publish to npm !!! VERY IMPORTANT Do NOT run `npm publish`, !!!
      npm run publish
    
  • Create a git tag and publish it

      git tag vVERSION.NUMBER.WHATEVER
      git push origin vVERSION.NUMBER.WHATEVER
    

Comments

  • spark-scroll-rekapi: lodash and underscore dependencies
    spark-scroll-rekapi: lodash and underscore dependencies

    May 25, 2015

    :crying_cat_face: Multi-faceted problem:

    • Currently spark-scroll has lodash dependency while Rekapi has underscore dependency.
    • Rekapi does not auto-install underscore

    For these two reasons npm install react-spark-scroll-react doesn't work out of the box. The work around is either:

    • Alias underscore (using alias feature of webpack or browserify, or whatever)
    • Manually install underscore via npm install underscore

    Don't have time to mess with this atm so creating this issue instead.

    bug help wanted 
    Reply
  • Change the container with gsap?
    Change the container with gsap?

    Mar 6, 2016

    I'm guessing if we could change the container (like a nested div) when we require SparkScroll in a component, instead of using the body as reference for the scrollTop value? Thank you by advance

    enhancement help wanted 
    Reply
  • [Suggestion]Make the doc more detailed
    [Suggestion]Make the doc more detailed

    May 13, 2017

    Thanks for this repo,it is really very very nice!But there maybe are a few things to make it better.

    1.just as #23 says,we need to remove the unknown props in html tag because react doesn't allow us to do this,if we need,we can add the data- prefix or wait the react 16 release(it will allow to you to make custom prop on html tag).

    2.the setup says:

    // (optional)
    // We can wrap any component using the factory methods
    // Assume that `MyClass` is a React class we created
    SparkScroll['MyClass'] = sparkScrollFactory(MyClass);
    
    <SparkScroll.MyClass
       myClassProperty="some value that MyClass requires"
         timeline={{
         'topTop+100': {width: '0%', backgroundColor: '#5c832f'},
         'topTop+250': {width: ['100%', 'easeOutQuart'], backgroundColor: '#382513'}
    }} />
    

    but there is no demo for it and the usage above probably is wrong because it will cause sparkAnimator.update is not a function error because we don't set the animator in the options by check the src code

    3.Finally,maybe we could also add something about SparkProxy and tell user we could use negative number of the timeline option's property in the doc.

    @gilbox Thanks again for creating this repo! :+1:

    bug enhancement help wanted 
    Reply
  • spark scroll doesn't work when inside parallax wrapper.
    spark scroll doesn't work when inside parallax wrapper.

    Jul 5, 2017

    When trying the demo from here: http://keithclark.co.uk/articles/pure-css-parallax-websites/

    animations stop working when inside a tag w/ these stylings:

    .parallax { height: 500px; /* fallback for older browsers */ height: 100vh; overflow-x: hidden; overflow-y: auto; -webkit-perspective: 300px; perspective: 300px; -webkit-perspective-origin-x: 100%; perspective-origin-x: 100%; }

    Reply
  • Spark Scroll animates 3 more times slowly after scroll comes to a complete stop.
    Spark Scroll animates 3 more times slowly after scroll comes to a complete stop.

    Mar 2, 2018

    [https://greensock.com/forums/topic/17956-weird-behavior-with-tweens-on-scroll/?tab=comments#comment-81978](GreenSock Forum)

    Any idea why Tweened elements animate 3 more times really slowly after scroll comes to a complete stop? This bug can be reproduced by going to my site here. And scrolling down to work section and stopping while divs are still being tweened.

    This only happens when translate3d properties are being animated.

    Here's some example code:

    <SparkScroll.h2 timeline={{ ease: 'easeOutCubic', topBottom: { opacity: 0, transform: 'translate3d(0,60px,0)', }, topCenter: { opacity: 1, transform: 'translate3d(0,0,0)' }, }} className="header" > work. </SparkScroll.h2>

    Reply
  • switches from React.createClass to extend React.Component
    switches from React.createClass to extend React.Component

    Jun 30, 2017

    React.createClass is deprecated.

    Reply
  • ReferenceError: setTimeout is not defined
    ReferenceError: setTimeout is not defined

    Sep 11, 2016

    I keep getting this issue.

    I'm including the library like this:

    var {SparkScroll, SparkProxy, sparkScrollFactory} =
      require('react-spark-scroll-gsap')({
        invalidateAutomatically: true
      });
    
                <SparkProxy.div proxyId="parallax" className="parallax-cont">
                  <SparkScroll.div
                  className="parallax-img"
                  proxy="parallax"
                  timeline={{
                    topBottom: {transform: 'translate3d(0px,000px,0px)'},
                    bottomTop: {transform: 'translate3d(0px,600px,0px)'}
                  }}>
    ...
                  </SparkScroll.div>
                </SparkProxy.div>
    
    Reply
  • Remove event listeners on unmounted components
    Remove event listeners on unmounted components

    Nov 1, 2016

    This PR fixes the cleanup method being called for every components using spark every time a component is unmounted. Since it was emitting a cleanup event every time componentWillUnmount was called it would trigger every event listeners registered being removed from the window event queue.

    Fixes #15.

    Reply
  • Optimize lodash import to reduce bundle size
    Optimize lodash import to reduce bundle size

    Jan 6, 2017

    Import lodash functions by pick, not the full lib. See analogously this: https://www.npmjs.com/package/babel-plugin-lodash

    Reply
  • setState warnings when transitioning between pages / components - window.eventListeners not clearing
    setState warnings when transitioning between pages / components - window.eventListeners not clearing

    Jan 26, 2016

    I'm using this library in a react router project, whenever we jump to a new page we get numerous setState warnings

    Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component. This is a no-op.

    It seems window.scroll and resize eventListeners aren't getting removed after the component is unmounted.? Any clues on what can be done to prevent it ?

    return (
      <SparkScroll.li
        className={cssClasses}
        timeline={{
          'bottomBottom+80': {
            onDown: () => this.setState({inView:true}),
            onUp:   () => this.setState({inView:false})
          }
        }}
        >
        <div>
          ...
        </div>
      </SparkScroll.li>
    )
    

    Thanks

    Reply
  • Isomorphic rendering support
    Isomorphic rendering support

    Jun 29, 2015

    Currently the library can't even be loaded when used on the server. It should not fail and instead fallback to React DOM elements when DOM is not available.

    The logic can be demonstrated by the following snippet from my code (but I do believe it should be part of the library):

    import React from 'react'
    import { canUseDOM } from '../../node_modules/react/lib/ExecutionEnvironment'
    
    let SparkScrollDiv
    
    if (canUseDOM) {
      const sparkScroll = require('react-spark-scroll-rekapi')({
        invalidateAutomatically: true
      })
      SparkScrollDiv = sparkScroll.SparkScroll.div
    } else {
      SparkScrollDiv = class {
        render() {
          let { children, ...other } = this.props
          return <div {...other}>{children}</div>
        }
      }
    }
    export { SparkScrollDiv }
    
    enhancement help wanted 
    Reply