CSS Stopwatch by Joe Crawford
Source Code & Credits
Why?
This demonstrates a few things. One is the power of
the :has()
operator in CSS. Another is animation
in CSS whereby animation-play-state
can be set
based on CSS selector. Strictly speaking the data-attributes in the HTML are
not updated, but they might be setting content
based on
a CSS custom property.
HTML
<time datetime="00:00:00">
<span class="tens-minutes" data-digit="0"></span>
<span class="ones-minutes" data-digit="0"></span>
<span data-digit=":" class="seconds">:</span>
<span class="tens-seconds" data-digit="0"></span>
<span class="ones-seconds" data-digit="0"></span>
<span data-digit="." class="period">.</span>
<span class="ones-tenths-of-a-second" data-digit="0"></span>
<span class="ones-hundredths-of-a-second" data-digit="0"></span>
</time>
<label>
<input type="checkbox">
<span class="when-on">Stop</span>
<span class="when-off">Start</span>
</label>
SCSS
@use "sass:math";
html {
color-scheme: light dark;
height: 100%;
}
* {
box-sizing: border-box;
}
body {
--background-color: light-dark(#ccc, #282828);
--default-color: light-dark(#dedede, #333);
--on-color: light-dark(#000, #eee);
--state: paused;
&:has(label input:checked) {
--state: running;
}
margin: 0;
padding: 2vmin;
background: var(--background-color);
font-family: sans-serif;
display: grid;
height: 100%;
main {
display: grid;
justify-content: center;
align-items: center;
gap: 2vmin;
}
code {
font-size: 1rem;
}
h1 {
font-weight: 100;
margin: 0;
}
}
details {
width: 100%;
border: 2px dotted var(--on-color);
overflow: auto;
summary {
cursor: pointer;
padding: 1rem;
font-size: 1rem;
opacity: 0.8;
}
> *:not(summary) {
margin-inline-start: 1rem;
}
pre {
padding: 0;
line-height: 1;
background: linear-gradient(color-mix(in srgb, red 10%, #0000),color-mix(in srgb, yellow 10%, #0000) 0) 0 0 / 100% 2lh;
font-size: 1rem;
code {
line-height: 1;
font-size: 1rem;
}
}}
label {
cursor: pointer;
display: block;
padding: 1rem;
font-family: system-ui, sans-serif;
text-align: center;
border: 1px solid;
border-radius: 0.3rem;
text-transform: uppercase;
font-size: 2rem;
background: linear-gradient(
20deg,
oklch(from currentColor 0.7 c h),
#0000,
oklch(from currentColor 0.7 c h)
);
input[type="checkbox"] {
display: none;
}
.when-on {
display: none;
}
.when-off {
display: block;
}
&:has(input:checked) {
filter: invert(1);
.when-on {
display: block;
}
.when-off {
display: none;
}
}
}
@mixin number-none {
--A: var(--default-color);
--B: var(--default-color);
--C: var(--default-color);
--D: var(--default-color);
--E: var(--default-color);
--F: var(--default-color);
--G: var(--default-color);
}
@mixin number-0 {
/* 0 */
--A: var(--on-color);
--B: var(--on-color);
--C: var(--on-color);
--D: var(--default-color);
--E: var(--on-color);
--F: var(--on-color);
--G: var(--on-color);
}
@mixin number-1 {
/* 1 */
--A: var(--default-color);
--B: var(--default-color);
--C: var(--on-color);
--D: var(--default-color);
--E: var(--default-color);
--F: var(--on-color);
--G: var(--default-color);
}
@mixin number-2 {
/* 2 */
--A: var(--on-color);
--B: var(--default-color);
--C: var(--on-color);
--D: var(--on-color);
--E: var(--on-color);
--F: var(--default-color);
--G: var(--on-color);
}
@mixin number-3 {
/* 3 */
--A: var(--on-color);
--B: var(--default-color);
--C: var(--on-color);
--D: var(--on-color);
--E: var(--default-color);
--F: var(--on-color);
--G: var(--on-color);
}
@mixin number-4 {
/* 4 */
--A: var(--default-color);
--B: var(--on-color);
--C: var(--on-color);
--D: var(--on-color);
--E: var(--default-color);
--F: var(--on-color);
--G: var(--default-color);
}
@mixin number-5 {
/* 5 */
--A: var(--on-color);
--B: var(--on-color);
--C: var(--default-color);
--D: var(--on-color);
--E: var(--default-color);
--F: var(--on-color);
--G: var(--on-color);
}
@mixin number-6 {
/* 6 */
--A: var(--on-color);
--B: var(--on-color);
--C: var(--default-color);
--D: var(--on-color);
--E: var(--on-color);
--F: var(--on-color);
--G: var(--on-color);
}
@mixin number-7 {
/* 7 */
--A: var(--on-color);
--B: var(--default-color);
--C: var(--on-color);
--D: var(--default-color);
--E: var(--default-color);
--F: var(--on-color);
--G: var(--default-color);
}
@mixin number-8 {
/* 8 */
--A: var(--on-color);
--B: var(--on-color);
--C: var(--on-color);
--D: var(--on-color);
--E: var(--on-color);
--F: var(--on-color);
--G: var(--on-color);
}
@mixin number-9 {
/* 9 */
--A: var(--on-color);
--B: var(--on-color);
--C: var(--on-color);
--D: var(--on-color);
--E: var(--default-color);
--F: var(--on-color);
--G: var(--default-color);
}
time {
gap: 1vw;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
span {
color: #0000;
--w: 12vw;
--h: 30vw;
--a: 2vw;
--b: 7vw;
--inset-h: 1vw;
--inset-v: 1.2vw;
--mid-correct: 0.2vw;
width: var(--w);
height: var(--h);
transform: skew(-3deg);
background:
/* top horiz bar A */ conic-gradient(
from 45deg at 0 50%,
var(--A, var(--default-color)) 25%,
#0000 0
)
left var(--inset-h) top 0 / var(--b) var(--a) no-repeat,
conic-gradient(
from 225deg at 100% 50%,
var(--A, var(--default-color)) 25%,
#0000 0
)
right var(--inset-h) top 0 / var(--b) var(--a) no-repeat,
/* middle horiz bar D */
conic-gradient(
from 45deg at 0 50%,
var(--D, var(--default-color)) 25%,
#0000 0
)
left var(--inset-h) bottom 50% / var(--b) var(--a) no-repeat,
conic-gradient(
from 225deg at 100% 50%,
var(--D, var(--default-color)) 25%,
#0000 0
)
right var(--inset-h) bottom 50% / var(--b) var(--a) no-repeat,
/* bottom horiz bar G */
conic-gradient(
from 45deg at 0 50%,
var(--G, var(--default-color)) 25%,
#0000 0
)
left var(--inset-h) bottom 0 / var(--b) var(--a) no-repeat,
conic-gradient(
from 225deg at 100% 50%,
var(--G, var(--default-color)) 25%,
#0000 0
)
right var(--inset-h) bottom 0 / var(--b) var(--a) no-repeat,
/* left top vert bar B */
conic-gradient(
from 135deg at 50% 0,
var(--B, var(--default-color)) 25%,
#0000 0
)
left 0 top var(--inset-v) / var(--a) var(--b) no-repeat,
conic-gradient(
from 315deg at 50% 100%,
var(--B, var(--default-color)) 25%,
#0000 0
)
left 0 top calc((50% - (var(--b) / 2) - var(--mid-correct))) / var(--a)
var(--b) no-repeat,
/* left top vert bar C */
conic-gradient(
from 135deg at 50% 0,
var(--C, var(--default-color)) 25%,
#0000 0
)
right 0 top var(--inset-v) / var(--a) var(--b) no-repeat,
conic-gradient(
from 315deg at 50% 100%,
var(--C, var(--default-color)) 25%,
#0000 0
)
right 0 top calc((50% - (var(--b) / 2) - var(--mid-correct))) / var(--a)
var(--b) no-repeat,
/* left bottom vert bar E */
conic-gradient(
from 315deg at 50% 100%,
var(--E, var(--default-color)) 25%,
#0000 0
)
left 0 bottom var(--inset-v) / var(--a) var(--b) no-repeat,
conic-gradient(
from 135deg at 50% 0,
var(--E, var(--default-color)) 25%,
#0000 0
)
left 0 bottom calc((50% - (var(--b) / 2) - var(--mid-correct))) / var(--a)
var(--b) no-repeat,
/* left bottom vert bar F */
conic-gradient(
from 315deg at 50% 100%,
var(--F, var(--default-color)) 25%,
#0000 0
)
right 0 bottom var(--inset-v) / var(--a) var(--b) no-repeat,
conic-gradient(
from 135deg at 50% 0,
var(--F, var(--default-color)) 25%,
#0000 0
)
right 0 bottom calc((50% - (var(--b) / 2) - var(--mid-correct))) / var(--a)
var(--b) no-repeat;
&[data-digit=":"],
&[data-digit="-"] {
--w: 2.3vmin;
background: linear-gradient(
#0000 33%,
var(--on-color) 0 40%,
#0000 0 63%,
var(--on-color) 0 70%,
#0000 0
);
}
&[data-digit="."] {
--w: 2.3vmin;
background: linear-gradient(#0000 93%, var(--on-color) 0);
}
}
[data-digit] {
@include number-none;
}
[data-digit="0"] {
@include number-0;
}
[data-digit="1"] {
@include number-1;
}
[data-digit="2"] {
@include number-2;
}
[data-digit="3"] {
@include number-3;
}
[data-digit="4"] {
@include number-4;
}
[data-digit="5"] {
@include number-5;
}
[data-digit="6"] {
@include number-6;
}
[data-digit="7"] {
@include number-7;
}
[data-digit="8"] {
@include number-8;
}
[data-digit="9"] {
@include number-9;
}
}
@keyframes seconds {
0% {
background: linear-gradient(
#0000 33%,
var(--on-color) 0 40%,
#0000 0 63%,
var(--on-color) 0 70%,
#0000 0
);
}
50% {
background: linear-gradient(
#0000 33%,
var(--default-color) 0 40%,
#0000 0 63%,
var(--default-color) 0 70%,
#0000 0
);
}
}
.seconds {
animation: 2s seconds infinite;
animation-play-state: var(--state);
}
@keyframes period {
0% {
background: linear-gradient(#0000 93%, var(--on-color) 0);
}
50% {
background: linear-gradient(#0000 93%, var(--default-color) 0);
}
}
.period {
animation: 1s period infinite;
animation-play-state: var(--state);
}
@keyframes zero-to-ten {
0% {
@include number-0;
}
10% {
@include number-1;
}
20% {
@include number-2;
}
30% {
@include number-3;
}
40% {
@include number-4;
}
50% {
@include number-5;
}
60% {
@include number-6;
}
70% {
@include number-7;
}
80% {
@include number-8;
}
90% {
@include number-9;
}
}
@keyframes zero-to-five-in-60 {
@for $i from 0 through 59 {
$percentage: math.div(100%, 60) * $i;
#{$percentage} {
@if $i < 10 {
@include number-0;
} @else if $i < 20 {
@include number-1;
} @else if $i < 30 {
@include number-2;
} @else if $i < 40 {
@include number-3;
} @else if $i < 50 {
@include number-4;
} @else if $i < 60 {
@include number-5;
} @else if $i < 70 {
@include number-6;
} @else if $i < 80 {
@include number-7;
} @else if $i < 90 {
@include number-8;
} @else if $i < 100 {
@include number-9;
}
}
}
}
@keyframes zero-to-ten-in-600 {
$framecount: 600;
@for $i from 0 through ($framecount - 1) {
$percentage: math.div(100%, $framecount) * $i;
$factor: 60;
#{$percentage} {
/* Frame #{$i} of #{$framecount} */
@if $i < ($factor * 1) {
@include number-0;
} @else if $i < ($factor * 2) {
@include number-1;
} @else if $i < ($factor * 3) {
@include number-2;
} @else if $i < ($factor * 4) {
@include number-3;
} @else if $i < ($factor * 5) {
@include number-4;
} @else if $i <($factor * 6) {
@include number-5;
} @else if $i < ($factor * 7) {
@include number-6;
} @else if $i < ($factor * 8) {
@include number-7;
} @else if $i <($factor * 9) {
@include number-8;
} @else if $i < ($factor * 10) {
@include number-9;
}
}
}
}
@mixin digital-clock-frame {
animation-name: var(--name);
animation-delay: 0;
animation-iteration-count: infinite;
animation-play-state: var(--state);
animation-fill-mode: forwards;
animation-timing-function: steps(var(--frame-count), jump-end);
animation-duration: var(--total-animation-cycle-length);
}
.ones-seconds {
--frame-count: 10;
--total-animation-cycle-length: 10s;
--name: zero-to-ten;
@include digital-clock-frame;
}
.tens-seconds {
--frame-count: 60;
--total-animation-cycle-length: 60s;
--name: zero-to-five-in-60;
@include digital-clock-frame;
}
.ones-minutes {
--frame-count: 600;
--total-animation-cycle-length: 600s;
--name: zero-to-ten-in-600;
@include digital-clock-frame;
}
.tens-minutes {
--frame-count: 600;
--total-animation-cycle-length: 6000s;
--name: zero-to-ten-in-600;
@include digital-clock-frame;
}
.ones-tenths-of-a-second {
--frame-count: 10;
--total-animation-cycle-length: 1s;
--name: zero-to-ten;
@include digital-clock-frame;
}
.ones-hundredths-of-a-second {
--frame-count: 10;
--total-animation-cycle-length: 0.1s;
--name: zero-to-ten;
@include digital-clock-frame;
}
Credits
Made by Joe Crawford in 2025, originally as a CodePen Pen