DEV Community

Cover image for Animate the Opening Star Wars Crawl with React Hooks and GreenSock
Mandi Wise
Mandi Wise

Posted on

Animate the Opening Star Wars Crawl with React Hooks and GreenSock

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 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

TL;DR Check out a live demo of the crawl here and the complete repo here.

Note: This tutorial was partly inspired by another post by Christopher Kade 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:

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

Then we'll start the app:

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, 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:

/* 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:

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 too.

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

Screenshot of updated React app with "Hello, GreenSock World!" rendered

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:

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:

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:

.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:

// ...

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:

// ...

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:

/* ... */

.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:

/* ... */

.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:

// ...

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

  // ...
}

export default App;

And chain on our additional animations in useEffect:

// ...

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 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:

// ...

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. Our implementation relies on a 3D transform with absolute positioning in App.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:

// ...

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:

// ...

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. 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:

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 and volume_off.svg files in the repo for this tutorial. Shout-out to Agarunov Oktay-Abraham 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):

// ...

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:

// ...

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:

/* ... */

.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:

// ...

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.

Top comments (5)

Collapse
 
ty_jones_b14bd583a597e0fd profile image
Ty Jones

Mandi. Thanks for putting this together, I have a mobile app built on Ionic using Angular and adapted this to work there. It adds a lot of fun. I did run into one issue though once it was running on the iphone. I had to increase the transform perspective distance. On the iphone, if I didn't have a higher perspective distance, about 3/4 of the way through the crawl, the text would disappear. Once I increased the perspective value, it was all good.

Collapse
 
bernardbaker profile image
Bernard Baker

Mandi I really like your article. And I'll be using it on a project. I recently built this e-commerce website which uses semantic-ui. Check it out... happyeverafter.online.

I think the use of React doesn't add dead weight to the page. It's a web framework that is easy to set up and familiar to many developers out there.

Collapse
 
grasmachien profile image
grasmachien

Nicely done!

But why are you using react for something like this? Besides you wanting to learn react I can see no benefit in using it in this case. It only adds dead weight to the page.

I'm assuming you are just comfortable with building things in react and chose it for that reason. I'm really interested btw, not hating :)

Collapse
 
mandiwise profile image
Mandi Wise

Thanks for your comment! For context, when I wrote the original version of this post last year it was just a little experiment combining React and GSAP (I was testing the waters for using GSAP with a larger React-based project). If the only intention is to recreate the Star Wars crawl, then vanilla JS would definitely get the job done without a lot of extra JS behind the scenes :)

Collapse
 
freetruckhorse profile image
freetruckhorse

A well explained and documented article! Thank you for this.
Looking forward to reading you again soon!