@ihmeuw/scrollyteller

A JavaScript library to build scrollytelling visualizations from tabular data

Usage no npm install needed!

<script type="module">
  import ihmeuwScrollyteller from 'https://cdn.skypack.dev/@ihmeuw/scrollyteller';
</script>

README

ScrollyTeller

ScrollyTeller is a JavaScript library that dynamically builds the HTML for a scrolling data story from a .csv file, and provides functionality for linking stateful JSON triggers to events dispatched when each scrolling text 'narration' comes into view.

ScrollyTeller image

Check out the ScrollyTeller tutorial, then come back for code examples and the full documentation on this page.


Use cases and functionality

  • Separation of stories into multiple sections with different graphs and narrations.
  • Separation of story sections allows multiple developers to work on different sections of a data story without interfering with one another.
  • Customizing story narration to an audience: user-specific narration.csv files can tell a story differently based on audience, using the same rendering code, with different actions based on a user's area of interest or expertise.
  • Changing the spacing between narration text, or the 'pacing' of a data story.

Data stories that have used ScrollyTeller

Documentation

Terminology

Term Description
Narration The scrolling text content that 'narrates' a data story.
Narration Block A group of text and links, contained in a <div>. Actions can be triggered when each narration block comes into view.
Narration Block Contents Each narration block contains: <h2> text (title), <p> text (subtitle), and an <a href=...> (link) with some text. The height of each narration block can also be controlled to change the pacing of the story.
Section A group of narration blocks with a corresponding graph element. A story can be one or multiple sections.
Graph Container A <div> element to hold a title, caption, and a graph, chart, or any other graphic to be triggered.
Graph A <div> element to hold a user defined graph, chart, or any other graphic to be triggered. The graph is entirely user controlled.
Scrollama The underlying JavaScript library used to control the triggering of actions when each narration block comes into view.

Building a scrolling data story using ScrollyTeller

A ScrollyTeller object is created with the constructor ScrollyTeller, which takes a configuration object as argument. To actually set up the story, including loading data, building the HTML, and setting up event listeners, call the asynchronous method render. The pseudo code below shows how a to create a ScrollyTeller instance from a configuration object and then render the HTML. The configuration object is described in much more detail below.
const myAppId = 'myAppId';
const storyConfiguration = {
  /** The id of the <div> that will hold this and all other sections */
  appContainerId: myAppId,
  /** build an array of story sections.
   * Each section object should be a valid section configuration with
   * the properties defined in the next section */
  sectionList: [ 
    {
      sectionIdentifier: 'myExampleSection0',
      /** ... section properties described below */
    },
    {
      sectionIdentifier: 'myExampleSection1',
      /** ... section properties described below */
    },
    {
      sectionIdentifier: 'myExampleSection3',
      /** ... section properties described below */
    ],
    /** optional function to receive the current sectionIdentifier,
     * narrationIndex, narrationId, and narrationClass
     * when narration blocks are entered */
    onNarrationChangedFunction: function ({
      sectionIdentifier,
      narrationIndex,
      narrationId,
      narrationClass,
    }) { console.log('in ', sectionIdentifier, narrationIndex); },
    /** optional parameter to scale scroll elements on mobile devices
        to create slower scrolling */
    mobileScrollHeightMultiplier: 1.5,
  },
};

/** create the ScrollyTeller object to validate the config */
const myScrollyTellerInstance = new ScrollyTeller(storyConfiguration);

/** parse data and build all HMTL */
myScrollyTellerInstance.render();
Each section in the sectionList array must have a unique property sectionIdentifier as well as the properties listed in the table below
  • The section properties tell ScrollyTeller where to fetch any narration and data from. The section configuration is also where the user can implement functions that will process the data, build a graph/chart instance for the section, and respond to scrolling and narration actions when each narration block is activated. A summary of each of the properties is described in the table below.
Section Property Property function
sectionIdentifier Unique identifier to distinguish each section. No two sectionIdentifier's should be the same in the ScrollyTeller configuration object
cssNames Optional: instance of CSSNames class. Can be used to override the default naming of id's and css classes. If left undefined, the default naming will be used.
narration Defines the scrolling narration of the story. The narration can be either of the following 3 options: 1) a URL string with the absolute file path to a file of type 'csv', 'tsv', 'json', 'html', 'txt', 'xml', 2) an array of narration objects, or 3) a promise to return an array of narration objects. SEE DOCUMENTATION BELOW FOR SPECIFICATION OF THE NARRATION TABLE / ARRAY
data Optional: user defined data. Data can be either of the following 4 options: 1) a URL string with the absolute file path to a file of type 'csv', 'tsv', 'json', 'html', 'txt', 'xml', 2) an array of data objects, 3) a promise to return an array of data objects, or 4) undefined
reshapeDataFunction Optional: Called AFTER data is fetched as reshapeDataFunction(data) This method can be used to filter or reshape data after the datahas been fetched or parsed from a file. It should return the reshaped data, which will overwrite the data proprerty for this section.
buildGraphFunction Optional: called as buildGraphFunction(graphId, sectionConfig) AFTER the data is fetched and reshaped by reshapeDataFunction. This method should build an instance of the graph and return that instance, which will be stored as a property on this section configuration object within ScrollyTeller. The sectionConfig object will be passed as an arguments to the onScrollFunction and onActivateNarration functions for later access to the data and graph properties.
onActivateNarration Called when a narration block hits the top of the page, causing it to become active (and classed as graph-scroll-active. See argument list below, this function is called as onActivateNarrationFunction({ index, progress, element, graphContainerId, graphId, sectionConfig, trigger }), and can be used to handle scrolling actions.
onScrollFunction Called upon scrolling of the section when the section is active. See argument list below, this function is called as onScrollFunction({ index, progress, element, graphContainerId, graphId, sectionConfig, trigger }), and can be used to handle data loading, or graph show-hide actions for a given narration block.
onResizeFunction Called upon resize of the graph container onResizeFunction({ graphElement, graphId, sectionConfig }), and can be used to resize the chart appropriately when the container is resized.
convertTriggerToObject Optional: Option to parse the JSON trigger for narration steps to an object and update the state object, default is true.
Here's an example of a section configuration that gets added to storyConfiguration
const myAppId = 'myAppId';
const myExampleSection0Name = 'myExampleSection0';
const myExampleSection0 = {
    sectionIdentifier: myExampleSection0Name,
    narration: 'demo_app/exampleSection0/data/narrationSectionChart.csv',
    data: 'demo_app/exampleSection0/data/dataBySeries.csv',
    reshapeDataFunction:
      function processDataFunction(data) { return data; },

    buildGraphFunction:
      function buildChart(graphId, sectionConfig) {
        const myChart = {}; // build your own chart instance
        return myChart; // return it
      },

    onScrollFunction:
      function onScroll({ index, progress, element, trigger, graphContainerId, graphId, sectionConfig }) {
      },

    onActivateNarrationFunction:
      function onActivateNarration({ index, progress, element, trigger, direction, graphContainerId, graphId, sectionConfig }) {
      },

    onResizeFunction:
      function onResize({ graphElement, graphId, graphContainerId, sectionConfig }) {
        sectionConfig.graph.resize(graphElement.offsetWidth, graphElement.offsetHeight);
      },
  };

/** Now add the section configuration to the overall ScrollyTeller config */
const storyConfiguration = {
  appContainerId: myAppId,
  sectionList: [ 
    myExampleSection0,
    /** add another section here... */
  ],
};
Finally, pass the configuration to the ScrollyTeller constructor. The configuration will be validated, and will throw errors if the configuration is not valid.
/** create the ScrollyTeller object to validate the config */
const myScrollyTellerInstance = new ScrollyTeller(storyConfiguration);

/** parse data and build all HMTL (here we're throwing away the result of the Promise, and just calling the method) */
myScrollyTellerInstance.render();
See demo_app/app.js for fully implemented examples that handle scrolling and narration actions. Section configurations are created in demo_app/00_introduction/scrollyTellerConfig.js and demo_app/01_wealthAndHealthOfNations/scrollyTellerConfig.js.

Narration file/object format

See this google spreadsheet for a sample configuration file, which is the same file as: demo_app/00_introduction/data/narration.csv

If you are using a csv or tsv file, format your narration file as follows, keeping the header column names EXACTLY alike (they can be in any order). If narration objects are json, each narration block should have a property named in the same manner as the Column Headers below. Each row represents a unique narration block.
narrationId narrationClass marginTopVh marginBottomVh minHeightVh h2Text paragraphText hRef hRefText trigger graphTitle graphCaption
0 myClass 40 40 100 Some text that will be formatted as <h2> Some text that will be formatted as <p> www.link.com I'm a link to link.com
{ show_chart: true }
This is a Graph Title This is a graph caption
1 myOtherClass 40 40 200 More Narration... Here's why this chart is important!
{ show_data_1: true }
This is a Graph Title This is a graph caption

Here's a description of what each column header controls:

Column Header/Property Name Effect
narrationId Appended to the id field of each narration block as "narration_ + narrationId". Can be non unique. Provided mainly as a means of distinguishing narration blocks easily.
narrationClass Appended to the class name of each narration block. Can be non unique.
marginTopVh Specifies the size of the margin above any text in each narration block. Units are in viewport height vh, but are converted to pixels px upon load or resize to avoid issues with mobile browsers using a reduced view height.
marginBottomVh Specifies the size the margin below any text in each narration block. Units are in viewport height vh, but are converted to pixels px upon load or resize to avoid issues with mobile browsers using a reduced view height.
minHeightVh Specifies the minimum height of the narration block. Units are in viewport height vh, but are converted to pixels px upon load or resize to avoid issues with mobile browsers using a reduced view height.
h2Text Optional larger text at the top of each narration block. If unspecified, no <h2> text is added to the narration block
paragraphText Optional paragraph text below the h2Text in each narration block. If unspecified, no <p> text is added to the narration block
hRef & hRefText Optional link for each narration block. If either hRef or hRefText is unspecified, no <a> link is added to the narration block
trigger Optional user customizable JSON to help trigger actions such as chart visiblity, styles, data ranges, etc. Must be a valid JSON string with the optional keyword $progress that refers to scroll progress within the current narration block. Invalid JSON strings are indicated by console warnings.
graphTitle Optional User customizable graph title that is placed in the <div class=graph_container> container.
graphCaption Optional User customizable graph caption that is placed in the <div class=graph_container> container.

Auto-scrolling to a specific section or narration block

The method scrollTo on the ScrollyTeller object allows you to auto-scroll to a specific section or narration block. It disabled triggers during scrolling, only firing triggers for the target section or narration block. This functionality can be useful for hooking up with navigation controls. Note that this method is asynchronous, so if you need to script any actions following the scroll, you should await resolution of the Promise it returns.

/**
 * @param {string|number} sectionIdentifier - `sectionIdentifier` of the target section
 * @param {string|number|undefined} [narrationIdStringOrNumericIndex]
 *  - optional: if undefined, defaults to the first narration block of target section
 *              if number, argument is treated as the index of the narration block to scroll to
 *              if string, argument is treated as the `narrationId` of the target narration block
 * @param {object} [options] - optional: configuration object passed to `scrollIntoView`
 *              (https://github.com/KoryNunn/scroll-into-view)
 * @returns {Promise<void>} - returns empty promise
 */
async scrollTo(sectionIdentifier, narrationIdStringOrNumericIndex, options) { ... }

Auto-scrolling to the previous/next narration block

The methods scrollToPreviousNarration/scrollToNextNarration on the ScrollyTeller object allows you to auto-scroll to the previous/next narration blocks. ScrollyTeller automatically adds event listeners to scroll to the previous narration block with keyboard events <ArrowUp> and <ArrowLeft>, and to the next narration block with keyboard events <Space>, <ArrowDown>, and <ArrowRight>, but the asynchronous functions could be used for custom navigation functionality as well.

/**
 * Scrolls "up" to the previous narration block in the story
 * @return {Promise<void>} - returns empty promise
 */
async scrollToPreviousNarration() { ... }

/**
 * Scrolls "down" to the next narration block in the story
 * @return {Promise<void>} - returns empty promise
 */
async scrollToNextNarration() { ... }

Tracking Scroll Progress using Google Analytics

ScrollyTeller can automatically send google analytics events when sections or narration blocks are entered, and when the scrollTo() function is called. To enable these features, add the following booleans to the ScrollyTeller configuration object.

ScrollyTeller requires that the ga() function from google's analytics.js be in the global scope with valid credentials. See these instructions for how to set up google analytics. In the future, we hope to support the new gtag.js library in addition to analytics.js.

const myScrollyTellerConfig = {
  appContainerId: 'myAppId',
  /** section list */
  sectionList: [ /** ... see above for section list properties */ ],
  
  /** optional google analytics properties */
  sendSectionAnalytics: true, // send analytics events when a section is entered or exited
  sendNarrationAnalytics: true, // send analytics events when each narration block is entered or exited
  sendScrollToAnalytics: true, // send analytics events when scrollTo() is called
  maxTimeInSeconds: 60, // optionally cap the maximum amount of time that can be sent to google (think of this as the maximum user "idle" time
};

// call render methods...

Page Events that ScrollyTeller can send are:

ScrollyTeller config flag (sends events when true) EventCategory EventAction EventLabel EventValue
sendSectionAnalytics Time in Narration (seconds) [sectionIndex]--[sectionIdentifier] Exited [sectionIdentifier], narration #[exitingNarrationIndex] after (EventValue) seconds [seconds a user spent in section]
sendSectionAnalytics Section Entry Time (seconds since page load) [sectionIndex]--[sectionIdentifier] Entered section [enteringSectionId] (EventValue) seconds since page load [seconds since page load section was entered]
sendNarrationAnalytics Time in Narration (seconds) [sectionIndex]--[sectionIdentifier]--[narrationIndex] Exited [section], narration #[exitingNarrationIndex] after (EventValue) seconds [seconds a user spent in a given narration block]
sendNarrationAnalytics Narration Entry Time (seconds since page load) [sectionIndex]--[sectionIdentifier]--[narrationIndex] Entered narration #[enteringNarrationIndex] (EventValue) seconds since page load [seconds since page load narration block was entered]
sendScrollToAnalytics Scroll From-To (SectionIndex---SectionId---NarrationIndex) From [from section]--[narration index of exit] To [to section]--[narration index of entry] [seconds since page load that scrollTo() occurred]
sendScrollToAnalytics Scroll To-From (SectionIndex---SectionId---NarrationIndex) To [to section]--[narration index of entry] From [from section]--[narration index of exit] [seconds since page load that scrollTo() occurred]

All of the times are reported as PageEvents in seconds, and can be given an upper bound by specifying maxTimeInSeconds, which defaults to Infinity.


Sample implementations of reshapeDataFunction(), buildGraphFunction(), onActivateNavigationFunction(), and onScrollFunction()

reshapeDataFunction()

  • (uses lodash toNumber() and groupBy() functions to manipulate data)
  • Notice that the function returns a new "grouped" data set, which is stored in the sectionConfig object and passed as an argument to onScrollFunction and onActivateNavigationFunction below, where it is accessed as sectionConfig.data.
/**
 * Optional method to reshape the data passed into ScrollyTeller, or resolved by the data promise
 * @param {data} results - data passed into ScrollyTeller or the result of resolving a data promise
 * @returns {object|array} - an object or array of data of user-defined shape
 */
function reshapeDataFunction(results) {
  /** using d3 to convert d3.csv calls to promises */
  const parseTime = timeParse('%y');
  /** parse results and convert dates to years, close to number */
  const dataProcessed = results.map((datum) => {
    return {
      series: datum.series,
      date: parseTime(datum.date),
      close: toNumber(datum.close),
    };
  });
  /** set data to the results array and handle some date and number conversion */
  return groupBy(dataProcessed, 'series');
}

buildGraphFunction()

  • uses an imported SampleChart to build the chart element. An imported class SampleChart selects the <div> element to build the graph (chart) by using d3 to select #graphId
  • Notice that the function returns the chart instance, which is stored in the sectionConfig object and passed as an argument to onScrollFunction and onActivateNavigationFunction below, where it is accessed as sectionConfig.graph
/**
 * Called AFTER data is fetched, and reshapeDataFunction is called.  This method should
 * build the graph and return an instance of that graph, which will be passed as arguments
 * to the onScrollFunction and onActivateNarration functions
 * @param {string} graphId - id of the graph in this section. const myGraph = d3.select(`#${graphId}`);
 * @param {object} sectionConfig - the configuration object passed to ScrollyTeller
 * @param {string} [sectionConfig.sectionIdentifier] - the identifier for this section
 * @param {object} [sectionConfig.graph] - the chart instance, or a reference containing the result of the buildChart() function above
 * @param {object} [sectionConfig.data] - the data that was passed in or resolved by the promise and processed by reshapeDataFunction()
 * @param {object} [sectionConfig.scroller] - the scrollama object that handles activation of narration, etc
 * @param {object} [sectionConfig.cssNames] - the CSSNames object containing some useful functions for getting the css identifiers of narrations, graph, and the section
 * @param {object} [params.sectionConfig.elementResizeDetector] - the element-resize-detector object: see https://github.com/wnr/element-resize-detector for usage
 * @returns {object} - chart instance
 */
function buildGraphFunction(graphId, sectionConfig) {
  const graph = new SampleChart({
    container: `#${graphId}`,
  });
  return graph;
}

onActivateNarrationFunction()

  • This is called when a narration block is activated by hitting the top of the page. See below for how to access different properties from the sectionConfig object and other function parameters
/**
 * Called when a narration block is activated
 * @param {object} [params] - object containing parameters
 * @param {number} [params.index] - index of the active narration object
 * @param {number} [params.progress] - 0-1 (sort of) value indicating progress through the active narration block
 * @param {HTMLElement} [params.element] - the narration block DOM element that is currently active
 * @param {string|object} [params.trigger] - the trigger attribute for narration block that is currently active. Optionally converted to an object based on the value set for `convertTriggerToObject` in section config.
 * @param {object} [params.state] - the full state of all narration blocks before and including the active one. Not computed if the value for `convertTriggerToObject` in section config is set to false.
 * @param {string} [params.direction] - the direction the event happened in (up or down)
 * @param {string} [params.graphContainerId] - id of the graph container in this section. const graphContainer = d3.select(`#${graphContainerId}`);
 * @param {string} [params.graphId] - id of the graph in this section. const myGraph = d3.select(`#${graphId}`);
 * @param {object} [params.sectionConfig] - the configuration object passed to ScrollyTeller
 * @param {string} [params.sectionConfig.sectionIdentifier] - the identifier for this section
 * @param {object} [params.sectionConfig.graph] - the chart instance, or a reference containing the result of the buildChart() function above
 * @param {object} [params.sectionConfig.data] - the data that was passed in or resolved by the promise and processed by reshapeDataFunction()
 * @param {object} [params.sectionConfig.scroller] - the scrollama object that handles activation of narration, etc
 * @param {object} [params.sectionConfig.cssNames] - the CSSNames object containing some useful functions for getting the css identifiers of narrations, graph, and the section
 * @param {object} [params.sectionConfig.elementResizeDetector] - the element-resize-detector object: see https://github.com/wnr/element-resize-detector for usage
 * @returns {void}
 */
function onActivateNarrationFunction({ index, progress, element, trigger, direction, graphId, sectionConfig }) {
  /** data and graph instances */
  const myGraph = sectionConfig.graph;
  const myData = sectionConfig.data;

  /** retrieving properties from the active narration DOM element */
  const { className: activeNarrationClass, id: activeNarrationId } = element;

  /** retrieving the expected css naming conventions in the HTML built by ScrollyTeller */
  const {
    cssNames,
    sectionIdentifier,
    appContainerId // the id of the container <div> that holds all sections
  } = sectionConfig

  const mySectionId = cssNames.sectionId(sectionIdentifier);
  const mySectionClass = cssNames.sectionClass();
  const globalNarrationClass = cssNames.narrationClass();
  const myGraphClass = cssNames.graphClass(sectionIdentifier);
  /** same as the graphId function argument */
  const myGraphId = cssNames.graphId(sectionIdentifier);
}

onScrollFunction

  • Called when scrolling occurs within a section that is visible in the window. Receives a current progress representing the progress through the existing narration. The example below takes a range of data and interpolates a current x value based on progress.
/**
 * Called upon scrolling of the section
 * @param {object} [params] - object containing parameters
 * @param {number} [params.index] - index of the active narration object
 * @param {number} [params.progress] - 0-1 (sort of) value indicating progress through the active narration block
 * @param {HTMLElement} [params.element] - the narration block DOM element that is currently active
 * @param {string|object} [params.trigger] - the trigger attribute for narration block that is currently active. Optionally converted to an object based on the value set for `convertTriggerToObject` in section config.
 * @param {object} [params.state] - the full state of all narration blocks before and including the active one. Not computed if the value for `convertTriggerToObject` in section config is set to false.
 * @param {string} [params.graphContainerId] - id of the graph container in this section. const graphContainer = d3.select(`#${graphContainerId}`);
 * @param {string} [params.graphId] - id of the graph in this section. const myGraph = d3.select(`#${graphId}`);
 * @param {object} [params.sectionConfig] - the configuration object passed to ScrollyTeller
 * @param {string} [params.sectionConfig.sectionIdentifier] - the identifier for this section
 * @param {object} [params.sectionConfig.graph] - the chart instance, or a reference containing the result of the buildChart() function above
 * @param {object} [params.sectionConfig.data] - the data that was passed in or resolved by the promise and processed by reshapeDataFunction()
 * @param {object} [params.sectionConfig.scroller] - the scrollama object that handles activation of narration, etc
 * @param {object} [params.sectionConfig.cssNames] - the CSSNames object containing some useful functions for getting the css identifiers of narrations, graph, and the section
 * @param {object} [params.sectionConfig.elementResizeDetector] - the element-resize-detector object: see https://github.com/wnr/element-resize-detector for usage
 * @returns {void}
 */
function onScrollFunction({ index, progress, element, trigger, graphContainerId, graphId, sectionConfig, state }) {
  /** user sets state in the csv file as JSON trigger:
    {
      xStart: 1,
      xEnd: 500
    }
  */
  if (state.xStart && state.xEnd) {
      const currentX = Math.ceil((state.xEnd - state.xStart) * progress);
      console.log('Current x is: ', currentX);
  }
}

onResizeFunction

  • Called when the graph container (graph_container div, not the graph itself) is resized.
/**
 * Called when the graph container is resized
 * @param {object} [params] - object containing parameters
 * @param {number} [params.index] - index of the active narration object
 * @param {number} [params.progress] - 0-1 (sort of) value indicating progress through the active narration block
 * @param {HTMLElement} [params.element] - the narration block DOM element that is currently active
 * @param {string} [params.trigger] - the trigger attribute for narration block that is currently active
 * @param {string} [params.direction] - the direction the event happened in (up or down)
 * @param {string} [params.graphContainerId] - id of the graph container in this section. const graphContainer = d3.select(`#${graphContainerId}`);
 * @param {string} [params.graphId] - id of the graph in this section. const myGraph = d3.select(`#${graphId}`);
 * @param {object} [params.sectionConfig] - the configuration object passed to ScrollyTeller
 * @param {string} [params.sectionConfig.sectionIdentifier] - the identifier for this section
 * @param {object} [params.sectionConfig.graph] - the chart instance, or a reference containing the result of the buildChart() function above
 * @param {object} [params.sectionConfig.data] - the data that was passed in or resolved by the promise and processed by reshapeDataFunction()
 * @param {object} [params.sectionConfig.scroller] - the scrollama object that handles activation of narration, etc
 * @param {object} [params.sectionConfig.cssNames] - the CSSNames object containing some useful functions for getting the css identifiers of narrations, graph, and the section
 * @param {object} [params.sectionConfig.elementResizeDetector] - the element-resize-detector object: see https://github.com/wnr/element-resize-detector for usage
 * @returns {void}
 */
function onResizeFunction({  graphElement, graphContainerId, graphId, sectionConfig }) {
  sectionConfig.graph.resize(
    {
      width: graphElement.offsetWidth * 0.95,
      height: graphElement.offsetHeight * 0.95,
    }
  );
}