Animate the Opening Star Wars Crawl with React Hooks and GreenSock
Tags: react, javascript, animation
Published: 2019-12-20 19:41:57

_The following post is adapted from a tutorial I wrote on another blog a year and a half ago. Much has changed since then, so I decided to modernize the code here to use GSAP 3.x and React hooks instead of a class-based component. Enjoy!_

---

Do you like Star Wars? Do you enjoy animating things in a web browser?

So do I!

I’ve wanted to dig into [the GreenSock library](https://greensock.com/) for a while now, so of course, my first instinct was to try recreating the Star Wars opening crawl with it.

GreenSock (aka GSAP) is a library that makes animating DOM elements with JavaScript exceptionally easy. At first, the library can seem a bit daunting, but if you can wrap your head around a few core concepts you’ll be up and running in no time.

Here’s what we’re working towards:

![Screenshot of finished Star Wars crawl animated with React Hooks and Greensock](https://thepracticaldev.s3.amazonaws.com/i/40hdeujj10hyiyzui2wt.png)

**TL;DR** Check out a [live demo of the crawl](https://mandiwise.github.io/star-wars-crawl-greensock-with-hooks) here and the [complete repo here](https://github.com/mandiwise/star-wars-crawl-greensock-with-hooks).

**Note:** This tutorial was partly inspired by [another post by Christopher Kade](https://dev.to/christopherkade/developing-the-star-wars-opening-crawl-in-htmlcss-2j9e) that I saw here on dev.to. Check out that original post for a version of the Star Wars crawl that uses CSS keyframe animation instead of GSAP.

## Getting Started

We’ll start by creating a new React app:

```sh
npx create-react-app star-wars-crawl-greensock-with-hooks
```

Then we'll start the app:

```sh
cd star-wars-crawl-greensock-with-hooks && yarn start
```

For the most part, we'll work with the default files in the React app we just scaffolded, but we’ll swap the `logo.svg` file for a Star Wars-inspired one and add additional SVGs for a volume button at the end.

The original opening crawl used [various styles of Franklin Gothic](http://www.theforce.net/fanfilms/postproduction/crawl/opening.asp), but we’ll keep it simple and use Open Sans, which is a readily-available, close approximation, and Saira Extra Condensed (both available on Google fonts).

We’ll start by adding our fonts and a minimal reset and to `index.css`:

```css
/* Fonts */

@import url("https://fonts.googleapis.com/css?family=Open+Sans:400,600,700|Saira+Extra+Condensed:500");

/* Minimal Reset */

html,
body,
div,
h1,
h2,
p,
section,
audio {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 100%;
  font: inherit;
  vertical-align: baseline;
}

html {
  font-size: 24px;
}

body {
  align-items: center;
  background: black;
  color: rgb(229, 177, 58);
  display: flex;
  font-family: "Open Sans", sans-serif;
  font-weight: 400;
  height: 100vh;
  justify-content: center;
  line-height: 1.25;
  overflow: hidden;
}

div[id="root"] {
  width: 100%;
}
```

Now would be a good time to wipe out all of the default styles in `App.css` as well, because we’ll be adding our own CSS to this file as we go along.

Lastly, we’ll edit the default code in `App.js` to be the following:

```jsx
import React from "react";

import "./App.css";
import logo from "./logo.svg";

function App() {
  return (
    <div>
      <p>Hello, GreenSock World!</p>
    </div>
  );
}

export default App;
```

Note that you'll also want to replace the default `logo.svg` file in the `src` directory with [our Star Wars-style logo available here](https://github.com/mandiwise/star-wars-crawl-greensock-with-hooks/blob/master/src/logo.svg) too.

At this point, our React app should look like this:

![Screenshot of updated React app with "Hello, GreenSock World!" rendered](https://thepracticaldev.s3.amazonaws.com/i/okkhuruyggc21sjplwn4.png)

Before we dive into GSAP, it’s a good idea to have a game plan. There are three separate animations we need to create. We’ll need to animate:

1. The “A long time ago in a galaxy far, far away...” text first
2. The logo entrance and exit
3. And finally, the episode number/name and the main text crawl

Let’s tackle each of these in order...

## Animate the Intro Text

Now for the fun part. Start by installing the GreenSock library in your app:

```sh
yarn add gsap
```

Next, we’ll import `gsap`, create a ref, add the intro text to our JSX, and describe how we want to animate it in the `useEffect` hook in `App.js`:

```jsx
import gsap from "gsap";
import React, { useEffect, useRef } from "react";

import "./App.css";
import logo from "./logo.svg";

function App() {
  const intro = useRef();

  useEffect(() => {
    gsap.to(intro.current, { opacity: 1, delay: 1, duration: 4.5 });
    gsap.to(intro.current, { opacity: 0, delay: 5.5, duration: 1.5 });
  }, []);

  return (
    <div className="container">
      <section className="intro" ref={intro}>
        <p>
          A long time ago, in a galaxy far,
          <br /> far away....
        </p>
      </section>
    </div>
  );
}

export default App;
```

We’ll also need more CSS to horizontally and vertically centre the intro text, adjust its size, and change its colour to blue in `App.css`:

```css
.container {
  height: 100vh;
  position: relative;
  width: 100%;
}

.intro {
  left: 50%;
  opacity: 0;
  position: absolute;
  transform: translate(-50%, -50%);
  top: 50%;
  z-index: 200;
}

.intro p {
  color: rgb(75, 213, 238);
  font-size: 1.25rem;
}
```

As a substitution for using `componentDidMount` to kick-off the animation we use the `useEffect` hook to transition the intro text from `opacity: 0` to `opacity: 1` and back again. This effect has no dependencies so we pass it an empty array as a second argument. It's important to include the dependency array argument in this call to `useEffect` because we only want our animation to start and run with the first render. We also need to call the `useRef` hook to create a ref to attach to the underlying `section` DOM node we want to animate.

In `App.js` we have our first encounter with GSAP. The `gsap` object's `to` method allows us to tween one or more properties of a specified target. It accepts two arguments: the reference to the element we want to animate (accessed via a ref) and a `vars` object describing the properties of the animation. If you used previous versions of GSAP, then you will see that it can be used as a replacement for `TweenLite.to()` or `TweenMax.to()`, but we add the `duration` length in the `vars` object now instead of including it as a separate argument.

We use `gsap.to()` twice so we can first animate the opacity of the intro text in for 4.5 seconds after a 1-second delay, then fade it out after a 5.5-second delay (because we need to wait for the first animation to complete before the second one begins).

Calculating delay times this way seems a bit awkward though...perhaps we can do better? Luckily, GSAP provides a more streamlined API for timing multiple animations using `gsap.timeline()`.

Let’s refactor `useEffect` in `App.js`:

```jsx
// ...

function App() {
  // ...

  useEffect(() => {
    let tl = new gsap.timeline();

    tl
      .to(intro.current, { opacity: 1, delay: 1, duration: 4.5 })
      .to(intro.current, { opacity: 0, duration: 1.5 });
  }, []);

  // ...
}

export default App;
```

Much better! A GSAP `timeline` acts as a container for sequencing a series of tweens over time, starting one after another by default (with the ability to overlap them if necessary). We’ll chain the rest of our animations onto this as we go.

## Animate the Title

To give the title the appearance of dramatically popping onto the screen and gradually drawing back in space we’ll need to animate the logo's scale and opacity.

Start by adding another `section` directly below the intro to contain the `logo.svg` in `App.js`:

```jsx
// ...

function App() {
  // ...
  
  return (
    <div className="container">
      {/* ... */}
      <section className="title" ref={title}>
        <img src={logo} alt="Code Wars title" />
      </section>
    </div>
  );
}

export default App;
```

Add the `title` class to the same rules that apply to the `intro` in `App.css`:

```css
/* ... */

.intro, 
.title {
  left: 50%;
  opacity: 0;
  position: absolute;
  transform: translate(-50%, -50%);
  top: 50%;
  z-index: 200;
}

/* ... */
```

And then add a few additional styles to center the `logo.svg` in its parent `section`:

```css
/* ... */

.title {
  align-items: center;
  display: flex;
  justify-content: center;
  width: 18rem;
}
```

Onto the JS now—in `App.js` we'll need to create a `title` ref for the new `section` so we can animate it:

```jsx
// ...

function App() {
  const intro = useRef();
  const title = useRef();

  // ...
}

export default App;
```

And chain on our additional animations in `useEffect`:

```jsx
// ...

function App() {
  // ...

  useEffect(() => {
    let tl = new gsap.timeline();

    tl
      .to(intro.current, { opacity: 1, delay: 1, duration: 4.5 })
      .to(intro.current, { opacity: 0, duration: 1.5 })
      .set(title.current, { opacity: 1, scale: 2.75 })
      .to(title.current, { scale: 0.05, ease: "power2", duration: 8 })
      .to(title.current, { opacity: 0, duration: 1.5 }, "-=1.5");
  }, []);

  // ...
}

export default App;
```

As you can see, we chain the `.set()` method to flip the opacity of the logo to `1` and set the scale of the logo to `2.75` instantly after the intro animation completes.

We then use the `.to()` method to scale the logo down to `0.05` over 8 seconds. Simultaneously reducing the logo opacity over the same duration won’t look right because we don’t want to fade it out until it’s far off in the distance. GSAP's `timeline` makes it easy to work around this by chaining a separate `.to()` method to animate the opacity of the logo to `0`, passing a third argument of '-=1.5' so it will start that animation 1.5 seconds before the end of the previous animation.

We also have our first encounter with a [GSAP easing curve](https://greensock.com/docs/v3/Eases) in our logo animation properties. I experimented with a few different curves and `power2` felt the most like original animation to me, but feel free to experiment with others.

## Animate the Crawl

We’re almost there! Add a final `section` containing the crawl text in `App.js`:

```jsx
// ...

function App() {
  // ...
  
  return (
    <div className="container">
      {/* ... */}
      <section className="crawl">
        <div className="content" ref={content}>
          <h1 className="episode-number">Episode X</h1>
          <h2 className="episode-title">THE APP AWAKENS</h2>
          <p>The Development Team Lead has vanished. In her absence, the sinister FUNCTIONAL BUG has risen from the ashes of the CI Tool and will not rest until the last developer has been destroyed.</p>
          <p>With the support of the QA TEAM, the Software Developer leads a brave RESISTANCE. He is desperate to find his Lead and gain her help in restoring peace and justice to the repository.</p>
          <p>The Developer has sent his most daring editor theme on a secret mission to the production branch, where an old ally has discovered a clue to the Lead’s whereabouts....</p>
        </div>
      </section>
    </div>
  );
}

export default App;
```

Replicate the crawl text from your favourite episode, or have some fun with this!

The CSS for the crawl is tricky but luckily [doesn’t involve any fancy camera rigging](https://gizmodo.com/5542745/so-thats-how-they-filmed-the-star-wars-opening-crawl). Our implementation relies on a 3D transform with absolute positioning in `App.css`:

```css
/* ... */

.container:before {
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 1) 0%,
    rgba(0, 0, 0, 1) 20%,
    rgba(0, 0, 0, 0) 40%,
    rgba(0, 0, 0, 0) 100%
  );
  bottom: 0;
  content: " ";
  left: 0;
  right: 0;
  position: absolute;
  top: 0;
  z-index: 100;
}

.crawl {
  font-size: 300%;
  bottom: 0;
  height: 80rem;
  left: 50%;
  position: absolute;
  transform: translateX(-50%) perspective(300px) rotateX(28deg);
  transform-origin: 50% 100%;
  width: 90%;
}

.content {
  position: absolute;
  top: 100%;
}

.episode-number {
  font-weight: 600;
  margin-bottom: 5rem;
  text-align: center;
}

.episode-title {
  font-family: "Saira Extra Condensed", sans-serif;
  font-size: 250%;
  font-weight: 500;
  line-height: 1;
  margin-bottom: 7rem;
  transform: scale(1, 1.5);
  text-align: center;
}

.content p {
  font-weight: 700;
  line-height: 1.33;
  margin-bottom: 4rem;
  text-align: justify;
}
```

The `:before` pseudo-element on the `container` class helps fade the text out as it recedes back in space.

Next, we'll add a third ref to the component in `App.js`:

```jsx
// ...

function App() {
  const intro = useRef();
  const title = useRef();
  const content = useRef();

  // ...
}

export default App;
```

And chain a final `.to()` to scroll the text back into space:

```jsx
// ...

function App() {
  // ...

  useEffect(() => {
    let tl = new gsap.timeline();

    tl
      .to(intro.current, { opacity: 1, delay: 1, duration: 4.5 })
      .to(intro.current, { opacity: 0, duration: 1.5 });
      .set(title.current, { opacity: 1, scale: 2.75 })
      .to(title.current, { scale: 0.05, ease: "power2", duration: 8 })
      .to(title.current, { opacity: 0, duration: 1.5 }, "-=1.5")
      .to(content.current, { top: "-170%", duration: 200 });
  }, []);

  // ...
}

export default App;
```

To scroll the text back, we simply need to animate its `top` property over approximately 200 seconds.

## Finishing Touch (Adding Music)

Our animation looks pretty good at this point, but a Star Wars-inspired crawl wouldn’t be complete without a soundtrack to accompany it.

This would have a much easier to accomplish prior to [the release of Chrome that blocked non-muted, autoplaying audio](https://developers.google.com/web/updates/2017/09/autoplay-policy-changes). To make our soundtrack Chrome-friendly we’ll need to start autoplaying the theme music at a point synchronized with our animation (it will start muted) and give the viewer the ability to unmute the audio if they wish.

We’ll start by importing two SVGs to `App.js` to use as a button to indicate to the viewer whether the audio is muted. We'll also need to import `useState` from React now too:

```jsx
import gsap from "gsap";
import React, { useEffect, useRef, useState } from "react";

import "./App.css";
import logo from "./logo.svg";
import volumeOff from "./volume_off.svg";
import volumeOn from "./volume_on.svg";

// ...
```

You can find the [`volume_on.svg`](https://github.com/mandiwise/star-wars-crawl-greensock-with-hooks/blob/master/src/volume_on.svg) and [`volume_off.svg`](https://github.com/mandiwise/star-wars-crawl-greensock-with-hooks/blob/master/src/volume_off.svg) files in the repo for this tutorial. Shout-out to [Agarunov Oktay-Abraham](https://thenounproject.com/agarunov/) for the Noun Project icons.

We’ll need to add a final ref to the component in `App.js`, and we’ll also need to manage some state with respect to whether the audio is muted (initiating it as `true`):

```jsx
// ...

function App() {
  const intro = useRef();
  const title = useRef();
  const content = useRef();
  const audio = useRef();

  const [muted, setMuted] = useState(true);

  // ...
}

export default App;
```

We’ll also need to add some JSX for the `audio` element and a `button` to trigger the unmute action just after the crawl `section`:

```jsx
// ...

function App() {
  // ...
  
  return (
    <div className="container">
      {/* ... */} 
      <audio ref={audio} muted>
        <source
          type="audio/mpeg" src="https://ia801501.us.archive.org/23/items/StarWars_20180709/Star%20Wars.mp3" />
      </audio>
      <button 
        className="volume" 
        type="button" 
        onClick={() => {
          audio.current.muted = !muted;
          setMuted(!muted);
        }}
      >
        {muted ? (
          <img src={volumeOff} alt="Volume is off" />
        ) : (
          <img src={volumeOn} alt="Volume is on" />
        )}
      </button>
    </div>
  );
}

export default App;
```

And some CSS to style the SVG icon button in `App.css`:

```css
/* ... */

.volume {
  background: transparent;
  border: 0;
  bottom: 10px;
  cursor: pointer;
  left: 10px;
  position: absolute;
  z-index: 1000;
}

.volume img {
  height: 24px;
}
```

Lastly, we need to start playing the audio at a synchronized point in the GSAP animation, which is right as the intro animation completes and the logo animation starts. The object of animation properties that `.to()` accepts can contain an `onComplete` method where we can trigger the audio to begin playing:

```jsx
// ...

function App() {
  // ...

  useEffect(() => {
    let tl = new gsap.timeline();

    tl
      .to(intro.current, { opacity: 1, delay: 1, duration: 4.5 })
      .to(intro.current, { 
        opacity: 0, 
        duration: 1.5,
        onComplete: () => {
          audio.current.play(); // autoplay the theme music
        }
      })
      // add a short delay on the next step to sync better with the audio
      .set(title.current, { opacity: 1, scale: 2.75, delay: 0.5 }) 
      .to(title.current, { scale: 0.05, ease: "power2", duration: 8 })
      .to(title.current, { opacity: 0, duration: 1.5 }, "-=1.5")
      .to(content.current, { top: "-170%", duration: 200 });
  }, []);

  // ...
}

export default App;
```

I found that adding a slight delay on the logo animation at this point helped sync up the opening note of the theme with the beginning of the animation, but you may want to play around with this a bit more.

Congrats on making it to the end! By now, you hopefully have an idea of how easy it is to get started with GreenSock—and we’ve only scratched the surface of what it’s capable of. Have fun making your own Star Wars-inspired crawls, and feel free to post any questions in the comment section below.