- var ticks = 120;
- var tickLabels = 12;
- var tickInc = ticks / tickLabels;
main
	.speedometer
		.speedometer-inner
				- for (var i = 0; i < ticks + 1; ++i) {
						.tick
							- for (var j = 0; j <= tickLabels; ++j) {
								- if (i == tickInc * j) {
										span
								- }
							- }
				- }
				.arrow
				.unit
				.wpm
					span(id="h",class="_")
					span(id="t",class="_")
					span(id="o",class="_0")

	form(action="")
		label(for="typed-text")="Type something as fast as you can!"
		textarea(id="typed-text",type="text",name="typed-text",cols="32",rows="5",value="")
		button(type="reset")="Reset"
View Compiled
$ticks: 120;
$tickLabels: 12;
$tickInc: $ticks / $tickLabels;
$wpm: 0; // initial speed

* {
	border: 0;
	box-sizing: border-box;
	margin: 0;
	padding: 0;
}
:root {
	font-size: calc(16px + (24 - 16)*(100vw - 320px)/(1920 - 320));
}
body, label, textarea, button {
	font: 1em Hind, sans-serif;
	line-height: 1.5;
}
body, textarea {
	color: #171717;
}
body {
	background: #f1f1f1;
}
label, textarea, button {
	-webkit-appearance: none;
	appearance: none;
}
main {
	margin: auto;
	max-width: 30em;
	padding: 1.5em;
}
textarea, button {
	width: 100%;
}
textarea {
	box-shadow: 0 0 0 1px #d9d9d9;
	margin-bottom: 1.5em;
	padding: 0.75em;
}
button {
	background-color: #2762f3;
	border-radius: 0.375em;
	color: #f1f1f1;
	cursor: pointer;
	padding: 0.5em 1em;
	transition: background-color 0.15s linear;
	&:hover, &:focus {
		background-color: #0c48db;
	}
	&:active {
		transform: translateY(1px);
	}
}
.speedometer {
	font-family: "Play", sans-serif;
	margin: 0 auto 1.5em auto;
	overflow: hidden;
	width: 16em;
	height: 12em;
}
.speedometer-inner {
	background-image:
		radial-gradient(
			100% 100% at 50% 50%,
			#3d3d3d 7%,
			#242424 7.25%,
			#3d3d3d 8%,
			#3d3d3d 46.5%,
			#7f7f7f 45.5%,
			#fff 47.5%,
			#d9d9d9 48.75%,
			rgba(0,0,0,0.34) 49%,
			transparent 50%
	);
	color: #fff;
	position: relative;
	height: 16em;
	> div {
		position: absolute;
	}
}
.tick, .unit, .wpm {
	z-index: 0;
}
.tick {
	background-image: linear-gradient(transparent 4%, rgb(255,255,255) 4%, rgb(255,255,255) 8%, transparent 8%);
	top: 0;
	left: calc(50% - 0.09em);
	width: 0.1em;
	height: 100%;
	span {
		display: block;
		text-align: center;
		width: 1.8em;
	}
	@for $i from 1 through $ticks + 1 {
		&:nth-of-type(#{$i}) {
			transform: rotate(-121deg + (240 / ($ticks + 1)) * $i);
		}
	}
	@for $i from 1 through $tickLabels + 1 {
		&:nth-of-type(#{($i - 1) * $tickInc + 1}) span {
			transform: translate(-50%,2em) rotate(120deg - (($ticks / $tickLabels * 2) * ($i - 1)));
			&:before {
				content: "#{($i - 1) * $tickInc}";
			}
		}
	}
	&:nth-of-type(5n + 1) {
		background-image: linear-gradient(transparent 4%, rgb(255,255,255) 4%, rgb(255,255,255) 10%, transparent 10%);
		width: 0.1em;
	}
	&:nth-of-type(10n + 1) {
		background-image: linear-gradient(transparent 4%, rgb(255,255,255) 4%, rgb(255,255,255) 12%, transparent 12%);
		width: 0.15em;
	}
}
.arrow {
	background-color: rgb(255,222,24);
	border-radius: 50% 50% 0 0;
	box-shadow: 0 0 1px 1px rgb(160,64,0) inset, 0 0 1px 1px rgba(0,0,0,0.4);
	top: 33%;
	left: calc(50% - 0.225em);
	width: 0.45em;
	height: 5.4em;
	transform: rotate(0deg + (($wpm * 2) - 120)) translateY(-73%);
	transition: transform 1s linear;
	z-index: 1;
}
.unit {
	text-align: center;
	top: 31%;
	left: calc(50% - 1.5em);
	width: 3em;
	&:before {
		content: "WPM";
	}
}
.wpm {
	background-color: rgb(255,255,255);
	border-radius: 0.2em;
	box-shadow: 0 (0.05em) (0.05em) rgba(0,0,0,0.5) inset;
	color: rgb(0,0,0);
	overflow: hidden;
	top: 62%;
	left: calc(50% - 1.5em);
	padding: 0 0.4em;
	height: 1.2em;
	width: 3em;
	> span {
		/* Number sprites by lavarmsg from Vecteezy.com (https://www.vecteezy.com/vector-art/95999-digital-number-counter) */
		background: {
			image: url(https://static.vecteezy.com/system/resources/previews/000/095/999/original/vector-digital-number-counter.jpg);
			size: 6em auto;
		};
		display: inline-block;
		vertical-align: top;
		height: 100%;
		width: 0.7em;
		transform: translateY(0.12em);
	}
}
/* Number sprites */
$yPos1: -1.725em;
$yPos2: -2.87em;
._ {
	background-position: -3.65em $yPos2;
	opacity: 0.2;
}
._0 {
	background-position: -0.725em $yPos1;
}
._1 {
	background-position: -1.7em $yPos1;
}
._2 {
	background-position: -2.675em $yPos1;
}
._3 {
	background-position: -3.675em $yPos1;
}
._4 {
	background-position: -4.65em $yPos1;
}
._5 {
	background-position: -0.725em $yPos2;
}
._6 {
	background-position: -1.7em $yPos2;
}
._7 {
	background-position: -2.675em $yPos2;
}
._8 {
	background-position: -3.65em $yPos2;
}
._9 {
	background-position: -4.625em $yPos2;
}

@media screen and (prefers-color-scheme: dark) {
	body, textarea {
		color: #f1f1f1;
	}
	body {
		background: #171717;
	}
	textarea {
		background: #3d3d3d;
		box-shadow: 0 0 0 1px #3d3d3d;
	}
}
View Compiled
window.addEventListener("load",app);

function app() {
	var oldStrLen = 0,
		charsEachSec = [],
		getWPM = () => {
			let arrow = document.querySelector(".arrow"),
				display = [
					document.getElementById("h"),
					document.getElementById("t"),
					document.getElementById("o"),
				],
				strLen = document.querySelector("textarea").value.length,
				wpm = 0;

			// unless field is cleared, get WPM based on average characters typed per second
			if (strLen > 0) {
				let charsDurSec = strLen - oldStrLen,
					charSum = 0,
					wordLen = 5,
					maxWords = 60;

				charsEachSec.push(charsDurSec);

				// use last n words for average
				if (charsEachSec.length > maxWords)
					charsEachSec.shift();

				for (var c of charsEachSec)
					charSum += c;

				// calculate WPM
				let avgChars = charSum / charsEachSec.length,
					wps = avgChars / wordLen,
					wpmCalc = Math.round(wps * 60),
					hardLimit = 999;

				if (wpmCalc > 0 && wpmCalc <= hardLimit)
					wpm = wpmCalc;
				else if (wpmCalc > hardLimit)
					wpm = hardLimit;

			} else {
				charsEachSec = [];
			}

			// make old string length equal to newest one before calculating WPM again
			oldStrLen = strLen;

			// set ceiling for and rotate arrow
			let maxWpm = 120,
				arrowWpm = wpm < maxWpm ? wpm : maxWpm;

			arrow.style.transform = "rotate(" + ((arrowWpm * 2) - 120) + "deg) translateY(-72%)";

			// make WPM string, clean digits, redisplay digits
			let wpmStr = wpm.toString();

			for (var d of display)
				d.className = "_";

			for (var i in wpmStr)
				display[display.length - 1 - i].className = "_" + wpmStr[wpmStr.length - 1 - i];
		};
	
	// runtime loop
	var run = () => {
		getWPM();
		setTimeout(run,1e3);
	};
	run();
}

External CSS

  1. https://fonts.googleapis.com/css?family=Hind&amp;display=swap
  2. https://fonts.googleapis.com/css?family=Play&amp;display=swap

External JavaScript

This Pen doesn't use any external JavaScript resources.