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