<h1>
  Rolling Number<br />
  <span>Web Component</span>
</h1>

<p>&lt;rolling-number&gt; is an easy-to-use inline Web Component that shows a nice rolling digit animation and automatically adapts to the surrounding font style (family, color, size, etc.).</p>

<p>Find it on GitHub: <a href="https://github.com/layflags/rolling-number">https://github.com/layflags/rolling-number</a></p>

<h2>Playground</h2>
<form id="form">
  <input type="number" name="number" value="123" size="6" />
  <button type="button" id="randomize">rnd</button>
  <button type="submit">set</button>
</form>

<div class="output">
  <rolling-number class="rolling" style="--roll-duration:750ms">123</rolling-number>°C
</div>

<p style="font-family:serif;font-style:italic">
  It's around <rolling-number class="rolling" style="--roll-duration:1500ms">123</rolling-number>°C in hell!
<p>

<h2>Usage</h2>
<p>
  <textarea readonly rows="8">
<!-- value by fallback -->
<rolling-number>123</rolling-number>
  
<!-- value w/o fallback -->
<rolling-number value="123"></rolling-number>
  
<!-- customize roll duration -->
<rolling-number style="--roll-duration:750ms">123</rolling-number>
  </textarea>
</p>

<hr />

<p><small>2021 by <a href="https://www.layfla.gs" target="_blank">layflags</a></small></p>

<script>
  document.getElementById("form").addEventListener("submit", (evt) => {
    const {
      value
    } = evt.target.number;
    document.querySelectorAll(".rolling").forEach(node => {
      node.value = value;
    });
    evt.preventDefault();
  });
  document.getElementById("randomize").addEventListener("click", (evt) => {
    const randomValue = Math.round(Math.random() * 998) - 499;
    evt.target.form.number.value = randomValue;
    document.querySelectorAll(".rolling").forEach(node => {
      node.value = randomValue;
    });
  })
</script>
* {
  box-sizing: border-box;
}
html {
  min-height: 100vh;
  background: linear-gradient(45deg, lightgoldenrodyellow, lightcyan);
}
body {
  color: black;
  font-family: monospace;
  margin: 0;
  padding: 1em;
}
h1 {
  font-size: 200%;
  line-height: 1;
  margin-bottom: 1.4rem;
}
h1 span {
  font-size: 60%;
  color: blue;
  text-transform: uppercase;
}
form {
  display: flex;
  width: 100%;
  max-width: 600px;
  font-size: 200%;
  border-radius: 0.4rem;
  box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.2);
}
form input,
form button {
  font-family: inherit;
  font-size: inherit;
  border: none;
  padding: 0.2em 0.6em;
  border-radius: 0.4rem;
  border-left: 0.05em solid white;
  display: block;
}
form input {
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
  background-color: white;
  color: black;
  flex-grow: 1;
  width: 100%;
}
form button {
  background-color: blue;
  color: white;
  padding-right: 0.75em;
}
form button[type="button"] {
  border-radius: 0;
  background-color: green;
}
form button[type="submit"] {
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
}
form button:hover {
  background-color: darkblue;
}
form button:active {
  color: lightblue;
}
textarea {
  border: none;
  padding: 1em;
  border-radius: 0.4rem;
  width: 100%;
  max-width: 600px;
  font: inherit;
}
.output {
  font-size: 500%;
  margin: 1.4rem 0 0.4rem 0;
  color: green;
  background-color: lightgreen;
  display: inline-block;
  padding: 0 0.2em;
  border: 1px solid rgb(0, 180, 0);
  border-radius: 0.4rem;
}
function renderStyles() {
  return `
<style>
:host {
  --roll-duration: 1s;
}
.digit {
  width: 1ch;
  overflow: hidden;
  display: inline-flex;
  position: relative;
}
.value {
  color: transparent;
  position: relative;
}
.scale {
  user-select: none;
  position: absolute;
  left: 0;
  display: inline-flex
  align-items: center;
  justify-content: center;
  flex-direction: column;
  transition: transform var(--roll-duration);
}
.scale span:last-child { /* the minus (-) */
  position: absolute;
  bottom: -10%;
  left: 0;
}
[data-value="\u200B"] .scale { transform: translatey(10%); }
[data-value="0"] .scale { transform: translatey(0); }
[data-value="1"] .scale { transform: translatey(-10%); }
[data-value="2"] .scale { transform: translatey(-20%); }
[data-value="3"] .scale { transform: translatey(-30%); }
[data-value="4"] .scale { transform: translatey(-40%); }
[data-value="5"] .scale { transform: translatey(-50%); }
[data-value="6"] .scale { transform: translatey(-60%); }
[data-value="7"] .scale { transform: translatey(-70%); }
[data-value="8"] .scale { transform: translatey(-80%); }
[data-value="9"] .scale { transform: translatey(-90%); }
[data-value="-"] .scale { transform: translatey(-100%); }
</style>
  `.trim();
}

function renderDigit(value, index) {
  return `
<span class="digit" data-value="${value}" id="digit${index}">
  <span class="scale" aria-hidden="true">
    <span>0</span>
    <span>1</span>
    <span>2</span>
    <span>3</span>
    <span>4</span>
    <span>5</span>
    <span>6</span>
    <span>7</span>
    <span>8</span>
    <span>9</span>
    <span>-</span>
  </span>
  <span class="value">${value}</span>
</span>
  `.trim();
}

function renderRoot() {
  return `
${renderStyles()}
<span id="wrapper">
</span>
  `.trim();
}

function render($wrapper, nextState, prevState) {
  const { value, size } = nextState;
  if (size > prevState.size) {
    $wrapper.innerHTML = toDigits(NaN, size).map(renderDigit).join("");
    setTimeout(() => {
      render($wrapper, nextState, { ...prevState, size });
    }, 20);
  } else {
    toDigits(value, size).forEach((digit, index) => {
      const $digit = $wrapper.querySelector(`#digit${index}`);
      if ($digit) {
        $digit.dataset.value = digit;
        $digit.querySelector(".value").textContent = digit;
      }
    });
  }
}

function toDigits(num, size = 0) {
  const result = Number.isNaN(num) ? [] : num.toString().split("");
  const padSize = Math.max(0, size - result.length);
  return [...Array(padSize).fill("\u200B"), ...result];
}

function toSize(num) {
  return Number.isNaN(num) ? 0 : num.toString().length;
}

const INTERNAL = Symbol("INTERNAL");

class RollingNumber extends HTMLElement {
  static get observedAttributes() {
    return ["value"];
  }
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = renderRoot();
    this[INTERNAL] = {
      $wrapper: shadow.getElementById("wrapper"),
      state: { value: NaN, size: 0 },
      update(payload) {
        if ("value" in payload) {
          const { value } = payload;
          const size = toSize(value);
          const state = { ...this.state, value };
          const nextState = size > this.state.size ? { ...state, size } : state;
          render(this.$wrapper, nextState, this.state);
          this.state = nextState;
        }
      }
    };
  }
  get value() {
    return this[INTERNAL].state.value;
  }
  set value(value) {
    this[INTERNAL].update({ value: Number.parseInt(value) });
  }
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "value") {
      this.value = newValue;
    }
  }
  connectedCallback() {
    if (this.isConnected) {
      const input = this.getAttribute("value") || this.textContent;
      const value = Number.parseInt(input);
      this[INTERNAL].update({ value });
    }
  }
}

customElements.define("rolling-number", RollingNumber);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.