DEV Community

Cover image for How to actually animate a text gradient in CSS
Thomas C. Haflich
Thomas C. Haflich

Posted on

How to actually animate a text gradient in CSS

Cover photo by Kristopher Roller via Unsplash.

Since you all liked my iridescent shambling monstrosity, I figured it was worth a second swing. Thanks to @erinjzimmer for letting me know that it's possible to animate the gradient background by animating background-position.

So, instead of writing a humorous article where I make the worst technically-functional CSS possible, let's try to make this thing not terrible. I swear, the only JavaScript I used this time was for the buttons.

I don't have a problem, I can stop whenever I want.

Since we figured out how to get the rainbow text last time, let's jump to the good parts.

Animating background position

To use transitions with this, it's very likely that I would have had to resort to similar tactics as in the previous post (yikes) to make sure that the gradient moved in one direction continuously. But instead of going through all that effort, I decided to use @keyframes, which turned out to be as easy as forgetting to add files before you commit.

@keyframes whoosh {
  0% { background-position-x: 0 }
  100% { background-position-x: 100% }
}

.animated-thing {
  animation-name: whoosh; // references @keyframes name
  animation-duration: 2000ms; // or whatever
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}

Enter fullscreen mode Exit fullscreen mode

There's an important caveat to be mentioned to make this look good, though.


A... catveat? Eh?

The cat image is 200px wide, and the window is either 400px wide or 500px wide. (Click to toggle.) When the container is 400px wide, the animation is smooth and you don't notice it suddenly resetting to the beginning when it loops. When it's 500px wide, the end of the animation is 100px offset and the jump is very noticeable.

Using gifs for illustration:

A cat walking on a spinning chair. The gif loop is very good.

When the loop resets, it's in the same position as when it started, making the looping is much less noticeable. (There may still be a jump, but it should be very small.)

A fluffy white cat sitting in a tree. At the last minute, the camera turns downward.

Despite the cuteness of this fluffy cloud-friend, the jump is very noticeable due to the camera turning downward sharply at the last moment. Would pat cat, would not animate smoothly.

Another issue is that setting the background to be exactly the same width as its container in this case causes it to not animate. I guess CSS thinks it has nowhere to go! So it needs to be some exact fraction or exact multiple, but not equal. And it needs to reset to the exact same position it started in.

Animating background position with gradients

To make CSS actually animate it, I set the background-size to 200% width (and 100% height). That was the easy part.

To get the gradient to animate smoothly was trickier.

  $first-color: #996699;
  $second-color:  #B39500;
  $third-color:  #009980;
  $fourth-color: #006699;
  $angle: 75deg;

  background-image: linear-gradient(
    $angle,
    // so you can see the repetition of colors easily
    $first-color,
    $second-color,
    $third-color,
    $fourth-color,
    // repeat a second time
    $first-color,
    $second-color,
    $third-color,
    $fourth-color,
    // back to the first color
    $first-color
  );
}

Enter fullscreen mode Exit fullscreen mode

This is how the gradient is set up so that it doesn't appear to "jump" when the loop resets. It needs to repeat twice, but also repeat the first bit of the loop. In the final pen, this was turned into a mixin.

@mixin flag-gradient($direction: null, $color-stops...) {
  $grads: $color-stops;
  @each $stop in $color-stops {
    $grads: append($grads, $stop);
  }
  $grads: append($grads, nth(nth($grads, 1), 1));

  // [ clipped ]

  background-image: linear-gradient($direction, $grads);
}
Enter fullscreen mode Exit fullscreen mode

This does all that repetition work for you. It copies the arg list sent in, then loops over it to append each of the new color stops (hmu if you know a better way to do that part), Then picks out the color part of the first color stop and tacks that onto the end. That way you only need to specify your actual flag colors.

Also take note of the angle you put the gradient towards - the steeper it is, the more noticeable the jump will be. Somewhere between 75 and 90 is nearly invisible but still looks tilty enough to be aesthetically pleasing. The smaller the width the sharper this angle needs to be, as well. 90 is safe!

If you're viewing the inline pen on dev.to, you'll see the 90 degree variant, because of this bit:

@mixin flag-gradient($direction: null, $color-stops...) {
  // [ clipped ]

  @if $direction == null {
    $direction: 75deg;
    @media only screen and  (max-width : 800px) {
      $direction: 80deg;
    }
    @media only screen and  (max-width : 500px) {
      $direction: 90deg;
    }
  }

  // [ clipped ]
}
Enter fullscreen mode Exit fullscreen mode

Since the animation quality varies so sharply based on the angle & width, I auto adjusted it if you don't send an override.

Adding more flags 🏳️‍🌈 (refactoring)

After I got the initial rainbow animation going, it seemed... too easy. I needed to work harder in the eternal struggle against CSS. I wondered, was this able to be generalized? Could I show even more pride?

A code screenshot showing the bi pride flag using multiple colors in a row for color stops.

In the bi pride flag, I discovered another interesting caveat. In order to make the jumps less noticeable, the gradient should be as smooth as possible. Using percentage based color stops generally turns out poorly; just plain duplicating the colors turned out to have better results.

Trans pride flag colors (blue, pink, and white) against a white background.

Hm, I'd really like to see a text shadow on there to make the white bits not blend into the background so much. White shows up on a few different flags, so it's definitely a general issue.

The text is now covered entirely in darkness. Oh no!
That went well.

This is actually entirely logical, if you think about it. The entire reason the gradient text trick works in the first place is by making the text itself transparent, and clipping the background; not by actually applying the background to the text.

I did look into fixing this. StackOverflow suggests:

Basically, the idea is to have 2 div overplayed on each other. The lower one provides the background and the shadow. And the the div above just uses a mask to cut out the exact same text from the background image, so that it covers the text of lower element, but not the shadow:

2 div overplayed on each other

2 div overplayed on each other

2 div overplayed on each other

A gif of a dog hopping away, with lots of "nope" text overlaid.

Not going down that road again! You can't make me!

The text with trans-pride gradient now has a dark background

Close enough.

After a bit of playing around with various flag designs and colors, I settled on needing four different "themes", based around light/dark and warm/cool. That's what all this is for:

$background-map: (
    light-cool: #ecf0f1,
    light-warm: #f2eded,
    dark-cool: #10181e,
    dark-warm: #0e070a,
);

@function get-opposite-tone($tone) {
  @if $tone == 'light' {
    @return 'dark';
  }
  @return 'light';
}

@mixin set-background($tone: 'light', $temp: 'cool') {
  $primary: null;
  $secondary: null;

  background-color: map-get($background-map, #{$tone}-#{$temp});
  & > #picker > button {
    background-color: map-get($background-map,
      #{get-opposite-tone($tone)}-#{$temp}
    );
    &.highlight {
      @if $tone == 'light' {
        background-color: lighten(map-get($background-map,
        #{get-opposite-tone($tone)}-#{$temp}
      ), 12);
      } @else {
        background-color: darken(map-get($background-map,
          #{get-opposite-tone($tone)}-#{$temp}
        ), 24);
      }
    }
    color: map-get($background-map, #{$tone}-#{$temp});
  }

  // [ clipped ]
}
Enter fullscreen mode Exit fullscreen mode

At this point, all I needed was 8 lines of JavaScript (... plus jQuery) to hook the buttons up.

Frodo from LOTR in front of the volcano: "It's done."

Wrap-up fun facts

  • The buttons on the side use CSS Grid for layout and it was extremely easy and convenient
  • Did you know there's not one standard "lesbian" pride flag? I picked the lipstick lesbian flag because that was easy to find color definitions for and seemed to be the most popular
  • SCSS arglists are a thing
  • You can't put an @media query statement inside an @function (lmao I tried just to see if it would work, but the compiler throws an exception)

Dazzling, luminescent glitter text reading "peace out homies"

Top comments (2)

Collapse
 
ashleemboyer profile image
Ashlee (she/her)

My favorite quote 😂

which turned out to be as easy as forgetting to add files before you commit

Collapse
 
erinjzimmer profile image
🌈 Erin Zimmer

Awesome! Yeah, the reason I had my animation running in both directions was because I couldn't work out how to deal with the skip when the animation reset. But now I know :-)