Create a Animated Counter in Stimulus

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>

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)
  }
}

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)
  }
}

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)
  }
}

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
  }
}

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"
  }
}

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"
  }
}

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>

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)
  }
}

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.

Get UI & Product Engineering Insights for Rails Apps (and product updates!)

Published at . Have suggestions or improvements on this content? Do reach out.

UI components Library for Ruby on Rails apps

$ 99 one-time
payment

Explore
  • One-time Payment

  • Access to the Entire Library

  • Built using ViewComponent

  • Designed with Tailwind CSS

  • Enhanced with Hotwire

Fractional Rails UI Product Engineer

$ 2k month

Hire
  • UI Modernization

  • Fractional UI and feature improvement

  • JavaScript untaming

  • No full-time commitment

Launch a Rails SaaS app in a month

$ 15k one-time

Book a call
  • Modern Rails app

  • Ready for paying customers in one month

  • 2 - 3 core features

  • You own every line of code