Create a Animated Counter in Stimulus

Rails Designer - Feb 27 - - Dev Community

This article was originally published on Rails Designer


Having a general purpose counter Stimulus controller in your arsenal always comes in handy. Show a:

  • price drop animation, for a discount (not $99, but only $49); great to capture attention;
  • timer, show how much time has passed.

Let's go over how to create such feature with Stimulus. First, as often when writing a Stimulus controller, let's start with the HTML:

<p data-controller="counter" data-counter-start-value="10" data-counter-end-value="0"></p>
Enter fullscreen mode Exit fullscreen mode

Reading this HTML you should already grasp what it should do (one of the beautiful things about Stimulus, I think). Let's create the most basic version to countdown from 10 to 0:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    start: Number
  }

  connect() {
    this.timer = setInterval(() => {
      this.element.textContent = this.startValue--
    }, 1000)
  }
}
Enter fullscreen mode Exit fullscreen mode

It works, but it is not very sophisticated. For example: it doesn't take the end value into account. Let's add that:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    start: Number,
    end: Number
  }

  connect() {
    this.timer = setInterval(() => {
      this.element.textContent = this.startValue--

      if (this.startValue === this.endValue) {
        clearInterval(this.timer)
      }
    }, 1000)
  }
}
Enter fullscreen mode Exit fullscreen mode

As I wrote in my Why disconnect in Stimulus controllers why clearing interval is important, let's add it in the disconnect lifecycle method:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    start: Number,
    end: Number
  }

  connect() {
    this.timer = setInterval(() => {
      this.element.textContent = this.startValue--

      if (this.startValue === this.endValue) {
        clearInterval(this.timer)
      }
    }, 1000)
  }

  disconnect() {
    if (this.timer) clearInterval(this.timer)
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's add a callback method that automatically updates the elements' content.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    start: Number,
    end: Number
  }

  connect() {
    this.timer = setInterval(() => {
      this.startValue--

      if (this.startValue === this.endValue) {
        clearInterval(this.timer)
      }
    }, 1000)
  }

  disconnect() {
    if (this.timer) clearInterval(this.timer)
  }

  // private

  startValueChanged() {
    this.element.textContent = this.startValue
  }
}
Enter fullscreen mode Exit fullscreen mode

This leaves a nice place to make other changes whenever the start value changes; maybe add some CSS to highlight the change. It will also help us a bit in a moment. Read more about Stimulus' callbacks.

What about you want to count up, instead of just down? Would be nice if it counts down if the start value is higher then the end value and vice versa if the other way around:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    start: Number,
    end: Number,
    direction: { type: String, default: "auto" }
  }

  connect() {
    this.timer = setInterval(() => {
      this.startValue = this.direction === "up"
        ? this.startValue + 1
        : this.startValue - 1

      if (this.startValue === this.endValue) {
        clearInterval(this.timer)
      }
    }, 1000)

  }

  disconnect() {
    if (this.timer) clearInterval(this.timer)
  }

  // private

  startValueChanged() {
    this.element.textContent = this.startValue
  }

  get direction() {
    if (this.directionValue !== "auto") {
      return this.directionValue
    }

    return this.startValue < this.endValue ? "up" : "down"
  }
}
Enter fullscreen mode Exit fullscreen mode

The direction() getter checks if the direction is specified. If not, it determines it based on the start- and end values. Now the counter can count up ánd down. Cool!

There is one thing that can be improved: the hard-coded interval value of 1000. Let's say you want to display a huge discount on an amount, quickly counting down from the original price to discounted one, makes for a cool effect. Let's make that adjustment:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    start: Number,
    end: Number,
    direction: { type: String, default: "auto" },
    interval: { type: Number, default: 1000 }
  }

  connect() {
    this.timer = setInterval(() => {
      this.startValue = this.direction === "up"
        ? this.startValue + 1
        : this.startValue - 1

      if (this.startValue === this.endValue) {
        clearInterval(this.timer)
      }
    }, this.intervalValue)

  }

  disconnect() {
    if (this.timer) clearInterval(this.timer)
  }

  // private

  startValueChanged() {
    this.element.textContent = this.startValue
  }

  get direction() {
    if (this.directionValue !== "auto") {
      return this.directionValue
    }

    return this.startValue < this.endValue ? "up" : "down"
  }
}
Enter fullscreen mode Exit fullscreen mode

Awesome, now you can use it like this:

<p data-controller="counter" data-counter-start-value="99" data-counter-end-value="49" data-counter-interval-value="10"></p>
Enter fullscreen mode Exit fullscreen mode

And there you have, a simple counter controller with some niceties. But you don't have to stop there! You can use JavaScript's Intl.NumberFormat object to format the number displayed. This is how you can use it:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  // …

  startValueChanged() {
    this.element.textContent = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(this.startValue)
  }
}
Enter fullscreen mode Exit fullscreen mode

Be sure, to check all the options for the NumberFormat object!

Also explore some CSS transitions (sliding up or down) when the start value changes; this can easily be added in the startValueChanged() method.

For a little bonus, if you want a continuous counter, even between page redirects, you can use data-turbo-permanent:

<p id="counter" data-turbo-permanent>
  Time is running out! You only got
  <span data-controller="counter" data-counter-start-value="60" data-counter-end-value="0"></span> seconds to complete this task.
</p>

Enter fullscreen mode Exit fullscreen mode
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .