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, I 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, however, 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:
TL;DR Check out a live demo of the crawl here and the complete repo here.
For this tutorial, we’ll use Create React App to quickly bootstrap a React application, so be sure to install that if you haven’t already.
Note: This tutorial was partly inspired by a post by Christopher Kade that I read on dev.to. Check out that original post for a version of the Star Wars crawl that uses CSS keyframe animation instead of GreenSock.
Getting Started
We’ll start by creating a new React app:
create-react-app star-wars-crawl-greensock
Then start your app:
cd star-wars-crawl-greensock && yarn start
We’ll keep it simple and work with the default files in the React app we just scaffolded, but we’ll swap the logo 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
:
/* 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 simplify the default JSX in App.js
:
// App.js
import React, { Component } from "react"; import './App.css';
import logo from './logo.svg';
class App extends Component { render() { return ( <div> <p>Hello, GreenSock World!</p> </div> ); } } export default App;
Note that you want to replace the default logo.svg
file in the src
directory with our Star Wars-style logo available here.
At this point, our React app should look like this:
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:
- The “A long time ago in a galaxy far, far away.…” text first
- The logo entrance and exit
- 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 add the intro text to our JSX, and describe how we want to animate it in componentDidMount
:
// App.js
import React, { Component } from "react"; import { TweenLite } from "gsap"; import "./App.css"; class App extends Component { constructor(props) { super(props); this.intro = React.createRef(); // shiny new React 16.3 ref API! }
componentDidMount() { TweenLite.to( this.intro.current, 4.5, { opacity: 1, delay: 1 } ); TweenLite.to( this.intro.current, 1.5, { opacity: 0, delay: 5.5 } ); } render() { return ( <div className="container"> <section className="intro" ref={this.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 center the intro text, adjust its size, and change its colour to blue:
/* 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; }
In App.js
we have our first encounter with TweenLite
from GSAP. This animation tool is very flexible.
In a nutshell, it allows you to tween one or more properties of an object (or array of objects) over a specified time period. It’s .to()
method accepts three arguments: the reference to the element you wish to animate (accessed via a ref), the length of the animation, and an object describing the properties of the animation.
We use TweenLite.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.
Hmm, calculating delay times is a bit akward. Can we do better? Luckily, GSAP provides a better way in the form of TimelineLite
.
Let’s refactor componentDidMount
:
// ...
import { TimelineLite } from "gsap"; // import TimelineLite instead
class App extends Component {
// ...
componentDidMount() { const tl = new TimelineLite(); tl .to(this.intro.current, 4.5, { opacity: 1, delay: 1 }) .to(this.intro.current, 1.5, { opacity: 0 }); }
// ...
}
Much better! TimelineLite
acts like 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 animation on to this as we go.
Animate the Logo
To give logo the appearance of appearing and gradually drawing back in space away from us, we’ll need to animate its scale and opacity.
Start by adding another section below the intro to contain the logo.svg
:
<section className="logo" ref={this.logo}> <img src={logo} alt="Code Wars logo" /> </section>
Add the logo section’s class to the same rules that apply to the intro:
.intro, .logo { left: 50%; opacity: 0; position: absolute; transform: translate(-50%, -50%); top: 50%; z-index: 200; }
And add a few additional styles to centre the logo.svg
in its parent section:
.logo { align-items: center; display: flex; justify-content: center; width: 18rem; }
Onto the JS—create the logo ref in the App
constructor:
this.logo = React.createRef();
And chain on our additional animations in componentDidMount
:
import { Power2, TimelineLite } from "gsap"; // import Power2!
class App extends Component {
// ...
componentDidMount() { const tl = new TimelineLite();
tl .to(this.intro.current, 4.5, { opacity: 1, delay: 1 }) .to(this.intro.current, 1.5, { opacity: 0 }) .set(this.logo.current, { opacity: 1, scale: 2.75 }) .to(this.logo.current, 8, { scale: 0.05, ease: Power2.easeOut }) .to(this.logo.current, 1.5, { opacity: 0 }, "-=1.5"); }
// ...
}
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. Luckily, TimelineLite
makes it easy work around this by chaining a separate .to()
method to animate the opacity of the logo to 0
, passing a fourth argument of '-=1.5'
to it which 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
ultimately felt most like original animation to me, but feel free to experiment with others (and don’t forget to import from any curves you use from the gsap
library).
Animate the Crawl
We’re almost the there! Add a final section
containing the crawl text:
<section className="crawl"> <div className="content" ref={this.content}> <h1 className="title">Episode 7</h1> <h2 className="subtitle">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>
Replicate the crawl text from your favourite episode, 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:
/* helps fade the text out as it recedes back in space */ .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%; }
.title { font-weight: 600; margin-bottom: 5rem; text-align: center; }
.subtitle { 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; }
Add another ref to the App
constructor:
this.content = React.createRef();
And chain a final .to()
to scroll the text back into space:
componentDidMount() { const tl = new TimelineLite();
tl .to(this.intro.current, 4.5, { opacity: 1, delay: 1 }) .to(this.intro.current, 1.5, { opacity: 0 }) .set(this.logo.current, { opacity: 1, scale: 2.75 }) .to(this.logo.current, 8, { scale: 0.05, ease: Power2.easeOut }) .to(this.logo.current, 1.5, { opacity: 0 }, "-=1.5") .to(this.content.current, 200, { top: "-170%" }); // ADD THIS! }
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 a recent release of Chrome that blocked non-muted, autoplaying audio (which was partially rolled back, but will eventually be reimplemented).
Ultimately, 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. So we can also call this finishing touch a bonus round for learning how to deal with the new “autoplaying normal” in Chrome ?
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:
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 constructor, and we’ll also need to managed some state with respect to whether the audio is muted (of course starting off as true
):
constructor(props) { super(props); this.intro = React.createRef(); this.logo = React.createRef(); this.content = React.createRef(); this.audio = React.createRef(); // LAST REF TO ADD!
this.state = { muted: true }; }
We’ll also need to add some JSX for the audio
element and a button
to trigger the unmute action just after crawl section
:
<audio ref={this.audio} muted> <source type="audio/mpeg" src="https://ia801307.us.archive.org/28/items/JohnWilliamsStarWarsMainThemeFULL/John%20Williams%20-%20Star%20Wars%20Main%20Theme%20(FULL).mp3" /> </audio> <button className="volume" type="button" onClick= {this.onVolumeClick} > {this.state.muted ? ( <img src={volumeOff} alt="Volume is off" /> ) : ( <img src={volumeOn} alt="Volume is on" /> )} </button>
You likely noticed a reference to an onVolumeClick
method above, so we’ll need to add that to the App
class as well:
onVolumeClick = () => { if (this.state.muted) { this.audio.current.muted = false; } else { this.audio.current.muted = true; }
this.setState({ muted: !this.state.muted }); }
And some CSS to style the SVG icon button:
.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 sychronized point in the GSAP animation, which is right as the intro animation completes and the logo animation starts. Luckily the object of animation properties that .to()
accepts can contain an onComplete
method where we can trigger the audio to begin playing.
tl .to(this.intro.current, 4.5, { opacity: 1, delay: 1 }) .to(this.intro.current, 1.5, { opacity: 0, onComplete: () => { this.audio.current.play(); } // Add this to autoplay the theme music }) .set(this.logo.current, { opacity: 1, scale: 2.75, delay: 0.5 // A slight delay here syncs better with the audio }) .to(this.logo.current, 8, { scale: 0.05, ease: Power2.easeOut }) .to(this.logo.current, 1.5, { opacity: 0 }, "-=1.5") .to(this.content.current, 200, { top: "-170%" })
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!
At this point 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.
Mandi Wise
Program Director for Web & App Development at RED Academy