<h1>
Rolling Number<br />
<span>Web Component</span>
</h1>
<p><rolling-number> 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);
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.