You know those wipe transitions between scenes in Star Wars movies? Have you ever thought it would be awesome to recreate them with CSS? Probably not, but, well, here we are. Let’s do it.

Spoiler alert: this article is secretly a tutorial on animating CSS masks.

A Simple Gradient Mask

CSS masking allows you to control which pixels of an element are visible and which are transparent — masked, if you will. A mask comes in the form of an image or gradient. When a mask is applied to an element, it acts as a sort of map that determines the visibility of every pixel of the element.

Masking is not always binary. Pixels can be partially masked, resulting in semi-transparent pixels.

As an example, the demo below uses CSS masking to cross-fade two scenes together.

See the Pen Simple Gradient Mask by Will Boyd (@lonekorean) on CodePen.

The first scene has the Death Star. The second scene has Jyn Erso, and is positioned directly on top of the first scene. A gradient mask is used on the second scene to make its left side transparent, revealing the first scene.

Here’s how it’s set up in the HTML.

<div class="wrapper">
  <div class="scenes">
    <div class="scene-1"></div>
    <div class="scene-2"></div>
  </div>
</div>

And here’s the CSS. It’s mostly positioning and sizing, along with setting the scene images, but pay attention to the -webkit-mask-image declaration on .scene-2.

.wrapper {
  width: min(1000px, 100%);
}

.scenes {
  position: relative;
  aspect-ratio: 2.4 / 1;
}

.scene-1, .scene-2 {
  position: absolute;
  inset: 0;
  background-size: cover;
}

.scene-1 {
  background-image: url(scene-1.jpg);
}

.scene-2 {
  background-image: url(scene-2.jpg);
  -webkit-mask-image: linear-gradient(to right, transparent 33%, #fff 67%);
}

The mask is set on -webkit-mask-image and expressed as a linear-gradient() going from left to right. Let’s dissect it.

  • The first third is entirely transparent, so this portion of the scene is not visible.
  • The middle third progresses from transparent to opaque white, gradually fading the scene in.
  • The last third is entirely opaque white, causing this portion of the scene to be fully visible.

To sum it up, transparent pixels hide and opaque pixels show. The color doesn’t even matter. I like to use white, but any color will do. It’s all about the opacity.

It’s also worth mentioning that I’m using the vendor prefixed -webkit-mask-image (supported in all major browsers) but not mask-image (currently only recognized by Firefox and Safari). I’m doing this for the sake of brevity, but feel free to use them both together if you want.

.scene-2 {
  -webkit-mask-image: linear-gradient(to right, transparent 33%, #fff 67%);
  mask-image: linear-gradient(to right, transparent 33%, #fff 67%);
}

Horizontal Wipe Transition

Let’s build on top of what we’ve covered so far to create our first scene transition effect, a horizontal wipe. Take a look.

See the Pen Horizontal Wipe Transition by Will Boyd (@lonekorean) on CodePen.

The basic HTML and CSS set up is the same as last time. And once again, the mask is a linear-gradient() that fades from transparent to #fff in the middle.

So how is the fade animated across the scene? The small visualizer underneath gives you a hint. The mask is stretched to be wider than the actual scene, and is then animated to slide horizontally. Here’s the relevant CSS.

.scene-2 {
  background-image: url(scene-2.jpg);
  -webkit-mask-image: linear-gradient(
    to right,
    transparent 47.5%,
    #fff 52.5%
  );
  -webkit-mask-size: 210%;
  -webkit-mask-position: left;
}

/* these styles are activated on the .scene-2 element
   when the .scenes element is hovered or focused */
.scenes:is(:hover, :focus) .scene-2 {
  -webkit-mask-position: right;
  transition: -webkit-mask-position 2s linear;
}

The mask is sized with -webkit-mask-size: 210% — this provides a width of 100% for the scene to initially be fully transparent + 10% for the fade + another 100% for the scene to end up fully opaque.

To reveal the scene, we transition the value of -webkit-mask-position so the mask shifts from left-aligned to right-aligned. Note that the background-image doesn’t move, only the mask does.

CSS masking doesn’t just mask an element’s background, it masks the entire element and everything in it. See that “BB-8 and Rey” text on the second scene? That’s an <h2> element within the scene element, also being affected by the mask.

Iris Wipe Transition

Next up is an iris wipe, fading a scene in from the middle out. Heads up, the animation relies on the CSS properties and values API, which is not yet ready in Firefox and Safari, but is expected to come in the future.

See the Pen Iris Wipe Transition by Will Boyd (@lonekorean) on CodePen.

The basic HTML and CSS are still the same, but this time we’re using a radial-gradient() as the mask. We’ll need to approach the animation differently, since animating the mask’s position won’t give us the effect we want. This is a job for CSS custom properties.

The plan is to define the radial-gradient() with a custom property in it. Then we can animate that custom property to animate the gradient. But before we can do that, we need to register the custom property with @property.

@property --radius {
  syntax: '<percentage>';
  inherits: true;
  initial-value: -5%;
}

This tells the browser that there’s a custom property named --radius that holds percentage values and defaults to a value of -5%. With that done, we can animate --radius with a simple keyframes animation.

@keyframes scene-transition {
  to { --radius: 105%; }
}

Here’s the CSS for .scene-2 to bring it all together.

.scene-2 {
  background-image: url(scene-2.jpg);
  z-index: -1;
  -webkit-mask-image: radial-gradient(
    circle,
    #fff calc(var(--radius) - 5%),
    transparent calc(var(--radius) + 5%)
  );
}

.scenes:is(:hover, :focus) .scene-2 {
  z-index: 1;
  animation: scene-transition 2s linear forwards;
}

As --radius animates, so do the color-stop positions in the radial-gradient(). They are calculated to be -5% and +5% of the value of --radius to create the gradual fade that gives the wipe a soft edge.

The fade is why the value of --radius goes from -5% to 105%. We pull back an extra 5% at the start to ensure all pixels are fully hidden, then progress an extra 5% at the end to ensure all pixels are fully visible.

As mentioned earlier, the animation doesn’t work in Firefox and Safari. As a fallback, .scene-2 starts with z-index: -1 to hide it behind the first scene, which is flipped to z-index: 1 to show it on top of the first scene. So at least the scenes will still switch on all browsers.

Clock Wipe Transition

The clock wipe is probably the most iconic wipe transition from Star Wars. Let’s make it. Once again, we’ll be animating a CSS custom property.

See the Pen Clock Wipe Transition by Will Boyd (@lonekorean) on CodePen.

It uses a lot of the same techniques as the iris wipe, except this time we’re using conic-gradient() and animating an angle value.

@property --angle {
  syntax: '<angle>';
  inherits: true;
  initial-value: -10deg;
}

@keyframes scene-transition {
  to { --angle: 370deg; }
}

.scene-2 {
  background-image: url(scene-2.jpg);
  z-index: -1;
  -webkit-mask-image:
    conic-gradient(
      #fff 0deg,
      #fff calc(var(--angle) - 10deg),
      transparent calc(var(--angle) + 10deg),
      transparent 360deg
    ),
    conic-gradient(
      transparent 340deg,
      #fff 360deg
    );
}

.scenes:is(:hover, :focus) .scene-2 {
  z-index: 1;
  animation: scene-transition 2s linear forwards;
}

We’re using two gradients in -webkit-mask-image. The first conic-gradient() is animated to create the clock wipe effect, but it leaves a hard edge at its starting point (at 0deg). That’s why there’s a second conic-gradient() to create a small fade right before the hard edge and soften it out. The gradients combine together to create the mask for the scene.

Shape Zoom Transition

Now for something different. I call this one a “shape zoom” transition. It’s not in any Star Wars movie, but I think it’s a pretty cool effect. It doesn’t animate a CSS custom property, so it works in all major browsers.

See the Pen Shape Zoom Transition by Will Boyd (@lonekorean) on CodePen.

I’ll just go ahead and spill the CSS for animating .scene-2, then talk through it.

@keyframes scene-transition {
  25% {
    filter: brightness(100%);
  }
  100% {
    filter: brightness(100%);
    -webkit-mask-size: 1800%;
  }
}

.scene-2 {
  background-image: url(scene-2.jpg);
  filter: brightness(0%);
  -webkit-mask-image: url(jedi-crest.svg);
  -webkit-mask-size: 10%;
  -webkit-mask-position: center;
  -webkit-mask-repeat: no-repeat;
}

.scenes:is(:hover, :focus) .scene-2 {
  animation: scene-transition 4s cubic-bezier(1, 0, 1, 1) forwards;
}

No gradients this time. Instead, -webkit-mask-image is given an SVG of the Jedi crest, masking .scene-2 into the shape that you see. The mask is positioned in the center of the scene and sized to 10%. Adding filter: brightness(0%) removes all brightness, resulting in the Jedi crest being completely black.

The animation uses an aggressive cubic-bezier() curve that starts out really slow, then gets really fast.

-webkit-mask-size is animated from 10% to 1800% so that the narrow bit in the middle of the Jedi crest grows large enough to cover the entire scene, fully revealing Darth Maul.

filter: brightness() is animated from 0% to 100%, fading the scene from black back to normal. This part of the animation is set to finish at the 25% time mark, before -webkit-mask-size is done growing.

Until Next Time

We’ve covered quite a few CSS ingredients in this article, including different types of gradients, registering custom properties, and of course masking and animation.

I used Star Wars scenes as my examples, but my hope is that you can take these techniques and use them to create your own neat visual effects for other things.

Have fun with it. Thanks for reading!