An animated character carousel with Angular and Greensock

A recent project needed a character selection carousel. The project was for a quiz-style game where players would enter their initials, choose their character and go onto the game proper. It was an aspect of the project I was most concerned about at the start of the project. Using the GreenSock library with Angular produced a high-quality result in very little time.
You can see the example code running live on GitHub.

The finished carousel
The finished carousel

Step 1. Basic layout

The first stage of the build is to layout some equally sized avatar images in a panel. I have used flexbox, with content justified to the centre.
This gives us the following initial layout:

Step 1 of the carousel build - the basic layout.
Step 1 of the carousel build - the basic layout.
<div class="page">
  <div class="carousel">
    <div>
      <img class="avatar-img" src="./assets/avatars/0.png"/>
    </div>
    <div>
      <img class="avatar-img" src="./assets/avatars/1.png"/>
    </div>
    <div>
      <img class="avatar-img" src="./assets/avatars/2.png"/>
    </div>
  </div>
</div>
.page {
  align-items: center;
  display: flex;
  flex-direction: column;
  height: 100%;
  justify-content: center;
}

.carousel {
  display: flex;
  height: 90%;
  justify-content: center;
  width: 90%;
}

.avatar-img {
  width: 100%;
}

(Source code for this step https://github.com/matthewgerrard/angular-greensock-avatar-carousel/tree/Step-1.)


Step 2. Add a button and get animating

The next step is to add some left/right buttons and get our first animation working. To keep things simple we'll get the right side movement working first.
Firstly, let's get the GreenSock library:

npm install gsap

Then we'll add a 'right' button to the page

<div class="page">
  <div class="carousel-and-buttons">
    <div class="carousel">
        ...
    </div>
    <button (click)="right()">&gt;</button>
  </div>
</div>
.carousel-and-buttons {
  align-items: center;
  display: flex;
  justify-content: center;
}

Ok - lets animate! There are 3 movements we'll need to cater for:

1. Moving the centre character to the right.

2. Moving the right character over to the left.

3. Moving the left character into the centre.

The first movement - centre character to the right

GreenSock allows us to apply animations by applying CSS rules to take a native page element from one state to another.

So firstly, we'll need to get a reference to the elements in our component. We can use angular @ViewChildren annotation to achieve this:

...
    <div class="carousel">
      <div #carouselItem>
        <img class="avatar-img" src="./assets/avatars/0.png"/>
      </div>
      <div #carouselItem>
        <img class="avatar-img" src="./assets/avatars/1.png"/>
      </div>
      <div #carouselItem>
        <img class="avatar-img" src="./assets/avatars/2.png"/>
      </div>
...
import {Component, ElementRef, QueryList, ViewChildren} from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {

  @ViewChildren('carouselItem')
  private carouselItems : QueryList<ElementRef>;

}

Ok, let's get some animation going on the button click, implementing the 'right' method:

public right(): void {
    const carouselNativeElements = this.carouselItems.toArray().map(el => el.nativeElement);
    const centerElement = carouselNativeElements[1];

    gsap.timeline({ repeat: 0})
      .to([centerElement], {
        duration: 1,
        ease: Sine.easeInOut,
        x: '+=100%'
      });
  }

Here we create a timeline, and ask GreenSock to take the centre element from it's current set of CSS properties, to the requested set; moving it right by 100% over a duration of 1 second:

The first animation - centre to right
The first animation - centre to right

The second and third movements

We'll use the same adjustments on the two other elements we'll need to move. The negative delay value causes the animations to play at the same time.

public right(): void {
    const carouselNativeElements = this.carouselItems.toArray().map(el => el.nativeElement);
    const leftElement = carouselNativeElements[0];
    const centerElement = carouselNativeElements[1];
    const rightElement = carouselNativeElements[2];

    gsap.timeline({ repeat: 0})
      .to([centerElement], {
        duration: 1,
        ease: Sine.easeInOut,
        x: '+=100%'
      }).to([rightElement], {
        delay: -1,
        duration: 1,
        ease: Sine.easeInOut,
        x: '-=200%'
      }).to([leftElement], {
        delay: -1,
        duration: 1,
        ease: Sine.easeInOut,
        x: '+=100%'
      });
The second and third movements
The second and third movements

Code can be found at https://github.com/matthewgerrard/angular-greensock-avatar-carousel/tree/Step-2


Step 3. Keep it spinning

That's the basics sorted, but we'll need to start tracking the centre element so that we can dynamically apply the animations based on the current positions of the elements:

.current-avatar {
  margin-top: 2rem;
}
...
    <button (click)="right()">&gt;</button>
  </div>
  <div class="current-avatar">Current avatar: {{currentAvatarIndex}}</div>
</div>
public currentAvatarIndex = 1;

  @ViewChildren('carouselItem')
  private carouselItems : QueryList<ElementRef>;

  public right(): void {
    const nextIndex = this.getPreviousIndex(this.currentAvatarIndex);

    const carouselNativeElements = this.carouselItems.toArray().map(el => el.nativeElement);
    const moveToCenterElement = carouselNativeElements[nextIndex];
    const moveToRightElement = carouselNativeElements[this.currentAvatarIndex];
    const moveToLeftElement = carouselNativeElements[this.getNextIndex(this.currentAvatarIndex)];

    gsap.timeline({ repeat: 0})
      .to([moveToRightElement], {
        duration: 1,
        ease: Sine.easeInOut,
        x: '+=100%'
      }).to([moveToLeftElement], {
        delay: -1,
        duration: 1,
        ease: Sine.easeInOut,
        x: '-=200%'
      }).to([moveToCenterElement], {
        delay: -1,
        duration: 1,
        ease: Sine.easeInOut,
        x: '+=100%'
      }).eventCallback('onComplete', () => {
        this.currentAvatarIndex = nextIndex;
      });
  }

  private getNextIndex(index: number): number {
    return ((index + 1) % this.carouselItems.length);
  }

  private getPreviousIndex(index: number) {
    return ((index + this.carouselItems.length - 1)
      % this.carouselItems.length);
  }

Here we track the index of the carousel item in the centre.

Each time the button is pressed we find the elements for each movement based on the current index and apply the movements.

Now we can "Just keep spinning. Just keep spinning. Just keep spinning, spinning, spinning. What do we do? We spin, spin" (to borrow a quote from Finding Nemo).

The first full spin
The first full spin

See https://github.com/matthewgerrard/angular-greensock-avatar-carousel/tree/Step-3 for the code.


Step 4. Both directions

Adding support for spinning the carousel in both directions uses the same code, only the direction and which elements the animations are applied to changes.

Additionally, I have introduced a variable to disable the buttons whilst the animations are running. It prevents an issue that occurs when a left/right button is pressed during an ongoing spin (and the distance changes are applied based on the elements intermediate positions!).


...
    <button (click)="left()" [disabled]="!enableCarouselButtons">&lt;</button>
    <div class="carousel">
...
    </div>
    <button (click)="right()" [disabled]="!enableCarouselButtons">&gt;</button>
...
enum Direction {
  Left = '-=',
  Right = '+='
}
...

export class AppComponent {

...

  public enableCarouselButtons = true;

...

  public right(): void {
    this.slide(Direction.Right);
  }

  public left(): void {
    this.slide(Direction.Right);
  }

  private slide(direction: Direction): void {
    this.enableCarouselButtons = false;

    const carouselNativeElements = this.carouselItems.toArray().map(el => el.nativeElement)
    const currentLeftAvatarIndex = this.getPreviousIndex(this.currentAvatarIndex);
    const currentRightAvatarIndex = this.getNextIndex(this.currentAvatarIndex);

    const currentLeftAvatar = carouselNativeElements[currentLeftAvatarIndex];
    const currentCentralAvatar = carouselNativeElements[this.currentAvatarIndex];
    const currentRightAvatar = carouselNativeElements[currentRightAvatarIndex];

    let moveAcrossBackAvatar;
    let moveAcrossBackDirection;
    let moveToSideDirection;
    let moveToCenterAvatar;
    let moveToCenterDirection;
    const moveToSideAvatar = currentCentralAvatar;

    let nextAvatarIndex;

    if (direction === Direction.Right) {
      moveAcrossBackAvatar = currentLeftAvatar;
      moveAcrossBackDirection = Direction.Right;
      moveToSideDirection = Direction.Left;
      moveToCenterAvatar = currentRightAvatar;
      moveToCenterDirection = Direction.Left;
      nextAvatarIndex = currentRightAvatarIndex;
    } else {
      moveAcrossBackAvatar = currentRightAvatar;
      moveAcrossBackDirection = Direction.Left;
      moveToSideDirection = Direction.Right;
      moveToCenterAvatar = currentLeftAvatar;
      moveToCenterDirection = Direction.Right;
      nextAvatarIndex = currentLeftAvatarIndex;
    }

    gsap.timeline({ repeat: 0})
      .to([moveToSideAvatar], {
        duration: 1,
        ease: Sine.easeInOut,
        x: moveToSideDirection + '100%'
      }).to([moveAcrossBackAvatar], {
        delay: -1,
        duration: 1,
        ease: Sine.easeInOut,
        x: moveAcrossBackDirection + '200%'
      }).to([moveToCenterAvatar], {
        delay: -1,
        duration: 1,
        ease: Sine.easeInOut,
        x: moveToCenterDirection + '100%'
      }).eventCallback('onComplete', () => {
        this.enableCarouselButtons = true;
        this.currentAvatarIndex = nextAvatarIndex;
      });
  }
Left/right spinning
Left/right spinning

Code for step 4 is available at https://github.com/matthewgerrard/angular-greensock-avatar-carousel/tree/Step-4


Step 5 Fine-tuning


We're looking good. I'll make a couple of changes to the animations to highlight the central avatar and make the side avatars less prominent.

To do this I'll make the side avatars smaller, grayscale and tune down the opacity.

This needs to be done when the component is initialised, and when the elements are spun. To ensure the elements are present on the page we need to use ngAfterViewInit.

export class AppComponent implements AfterViewInit {

...

  private readonly inactiveProperties = {
    filter: 'grayscale(100%)',
    scale: 0.5,
    opacity: 0.3
  }

...

  ngAfterViewInit(): void {
    const carouselNativeElements = this.getCarouselElements();
    const currentLeftAvatar = carouselNativeElements[0];
    const currentRightAvatar = carouselNativeElements[2];
    gsap.set([currentLeftAvatar, currentRightAvatar], this.inactiveProperties);
  }

...

  private slide(direction: Direction): void {
    this.enableCarouselButtons = false;

    const carouselNativeElements = this.getCarouselElements();
    const currentLeftAvatarIndex = this.getPreviousIndex(this.currentAvatarIndex);
    const currentRightAvatarIndex = this.getNextIndex(this.currentAvatarIndex);

    const currentLeftAvatar = carouselNativeElements[currentLeftAvatarIndex];
    const currentCentralAvatar = carouselNativeElements[this.currentAvatarIndex];
    const currentRightAvatar = carouselNativeElements[currentRightAvatarIndex];

    let moveAcrossBackAvatar;
    let moveAcrossBackDirection;
    let moveToSideDirection;
    let moveToCenterAvatar;
    let moveToCenterDirection;
    const moveToSideAvatar = currentCentralAvatar;

    let nextAvatarIndex;

    if (direction === Direction.Right) {
      moveAcrossBackAvatar = currentLeftAvatar;
      moveAcrossBackDirection = Direction.Right;
      moveToSideDirection = Direction.Left;
      moveToCenterAvatar = currentRightAvatar;
      moveToCenterDirection = Direction.Left;
      nextAvatarIndex = currentRightAvatarIndex;
    } else {
      moveAcrossBackAvatar = currentRightAvatar;
      moveAcrossBackDirection = Direction.Left;
      moveToSideDirection = Direction.Right;
      moveToCenterAvatar = currentLeftAvatar;
      moveToCenterDirection = Direction.Right;
      nextAvatarIndex = currentLeftAvatarIndex;
    }

    gsap.timeline({ repeat: 0})
      .to([moveToSideAvatar], {
        ...this.inactiveProperties,
        duration: 1,
        ease: Sine.easeInOut,
        x: moveToSideDirection + '100%'
      }).to([moveAcrossBackAvatar], {
        ...this.inactiveProperties,
        delay: -1,
        duration: 1,
        ease: Sine.easeInOut,
        x: moveAcrossBackDirection + '200%'
      }).to([moveToCenterAvatar], {
        filter: 'none',
        scale: 1.0,
        opacity: 1.0,
        delay: -1,
        duration: 1,
        ease: Sine.easeInOut,
        x: moveToCenterDirection + '100%'
      }).eventCallback('onComplete', () => {
        this.enableCarouselButtons = true;
        this.currentAvatarIndex = nextAvatarIndex;
      });
  }

...

  private getCarouselElements(): any[] {
    return this.carouselItems.toArray().map(el => el.nativeElement)
  }
The enhanced spin
The enhanced spin

Code for step 5 at https://github.com/matthewgerrard/angular-greensock-avatar-carousel/blob/Step-5/src/app/app.component.ts


Step 6. One last issue

With the enhanced animation almost finished, almost. As the elements overlap each other, and the opacity is reduced - you can see both elements.

And a final change, to adjust the z-index of each element so that they overlap as expected. The element moving to the centre on top, followed by the element moving to the side, and finally at the bottom, the element moving across the back.

The transparency issue close up
The transparency issue close up

To fix the transparency issue we need to create a backing mask image for each character.

This follows the exact outline of the character in white and will be placed exactly behind the character.

I can then apply the opacity to the character image, so when elements overlap the backing mask will prevent the character image behind from showing through.

An example avatar mask
An example avatar mask
...     
     <div #carouselItem>
        <img class="avatar-img" src="./assets/avatars/0.png"/>
        <img class="avatar-img-mask" src="./assets/avatars/0-mask.png"/>
      </div>
      <div #carouselItem>
        <img class="avatar-img" src="./assets/avatars/1.png"/>
        <img class="avatar-img-mask" src="./assets/avatars/1-mask.png"/>
      </div>
      <div #carouselItem>
        <img class="avatar-img" src="./assets/avatars/2.png"/>
        <img class="avatar-img-mask" src="./assets/avatars/2-mask.png"/>
      </div>
...
.carousel {
  display: flex;
  height: 90%;
  justify-content: center;
  position: relative;
  width: 90%;
}

.avatar-img {
  position: relative;
  width: 100%;
  z-index: 50;
}

.avatar-img-mask {
  top: 0;
  bottom: 0;
  left: 0;
  position: absolute;
  right: 0;
  width: 100%;
  z-index: 25;
}
...
timeline.set([moveAcrossBackAvatar], {
      zIndex: 0
    }).set([moveToCenterAvatar], {
      zIndex: 100
    }).set([moveToSideAvatar], {
      zIndex: 75
    }).to([moveToSideAvatar], {
      ...this.inactiveProperties,
      x: moveToSideDirection + '100%',
      ease: Sine.easeInOut,
      duration: 1
    }).to([moveToSideAvatar.firstChild], {
      ...this.opacityProperties,
      duration: 1,
      delay: -1
    }).to(moveAcrossBackAvatar, {
      x: moveAcrossBackDirection + '200%',
      duration: 1,
      delay: -1,
      ease: Sine.easeInOut,
    }).to(moveToCenterAvatar, {
      x: moveToCenterDirection + '100%',
      scale: 1,
      filter: 'none',
      ease: Sine.easeInOut,
      duration: 1,
      delay: -1,
    }).to(moveToCenterAvatar.firstChild, {
      opacity: 1,
      delay: -1
    }).eventCallback('onComplete', () => {
      this.enableCarouselButtons = true;
      this.currentAvatarIndex = nextAvatarIndex;
    });
  }
...

Which gives us our final, fully functioning carousel spinner!
The final code can be found at https://github.com/matthewgerrard/angular-greensock-avatar-carousel
The live preview can be found over on GitHub.
Thanks for reading!

Update!

I have now packaged the carousel as component on NPM:

https://www.npmjs.com/package/ng-character-select-carousel

This site uses cookies. Continue to use the site as normal if you are happy with this, or read more about cookies and how to manage them.

×