CSS + JS Products & branding #loop #product #device #clock #time

Smartwatch in CSS + JavaScript

A watch with thickness, strap stubs and the real time. Once a second JS writes three angles and two integers; CSS turns the hands and prints the digits with counters.

  • 189 lines of CSS
  • 17 lines of HTML
  • 20 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Once a second JS writes --h, --m, --s as angles: not while the frame is paused (<html data-paused>), and not the second hand when the visitor asks for reduced motion. Each hand stands on the centre (bottom: 50%), turns about its foot and reads its own variable.
  2. The angles count from midnight, so they only ever grow: at 59 → 0 seconds the hand keeps going forward instead of sweeping back.
  3. The digits need no DOM writes: JS also sets two integers, counter-reset: hh var(--hh) mm var(--mm) turns them into counters and content: counter(hh, decimal-leading-zero) prints 09:05.
  4. The case is seven rounded slabs 1.5 units apart (they make the rounded sides), with the face and back at ±5. The straps are hinged on the case edge with transform-origin and tilted away with rotateX.
  5. Every length is a multiple of one base unit, --u, tied to the canvas, so the case, the dial and the digits are the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • setInterval → --h / --m / --s angles
  • digits via counter-reset: var(--hh)
  • straps hinged on the case edge
  • rounded slabs for thickness

Copy-paste code

HTML 17 lines

<div class="scene">
  <div class="watch">
    <div class="body">
      <i class="strap top"></i><i class="strap bottom"></i>
      <i class="slab" style="--i:0"></i><i class="slab" style="--i:1"></i><i class="slab" style="--i:2"></i>
      <i class="slab mid" style="--i:3"></i>
      <i class="slab" style="--i:4"></i><i class="slab" style="--i:5"></i><i class="slab" style="--i:6"></i>
      <i class="crown"></i>
      <div class="back"></div>
      <div class="face">
        <div class="dial"><i class="hand h"></i><i class="hand m"></i><i class="hand s"></i></div>
        <div class="time"></div>
        <small>live time</small>
      </div>
    </div>
  </div>
</div>

CSS 189 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the watch is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.34vmin;
  perspective: calc(800 * var(--u));
}

.watch {
  --metal: #5a607f;
  --band: #6550ba;
  transform-style: preserve-3d;
  animation: sway 7s ease-in-out infinite alternate;
}

/* 88 × 104, 10 thick, 24 corners */
.body {
  position: relative;
  width: calc(88 * var(--u));
  height: calc(104 * var(--u));
  transform-style: preserve-3d;
}

/* hinged on the back edge of the case, then tilted away round an invisible wrist */
.strap {
  position: absolute;
  left: calc(16 * var(--u));
  width: calc(56 * var(--u));
  height: calc(44 * var(--u));
  background:
    linear-gradient(90deg, rgb(0 0 0 / 0.35), transparent 22% 78%, rgb(0 0 0 / 0.35)),
    var(--band);
}

.strap.top {
  bottom: 100%;
  border-radius: calc(10 * var(--u)) calc(10 * var(--u)) 0 0;
  transform-origin: 50% 100%;
  transform: translateZ(calc(-4 * var(--u))) rotateX(38deg);
}

.strap.bottom {
  top: 100%;
  border-radius: 0 0 calc(10 * var(--u)) calc(10 * var(--u));
  background:
    radial-gradient(circle, rgb(0 0 0 / 0.45) 0 calc(2 * var(--u)), transparent calc(2.5 * var(--u))) 50% calc(8 * var(--u)) / 100% calc(10 * var(--u)) repeat-y,
    linear-gradient(90deg, rgb(0 0 0 / 0.35), transparent 22% 78%, rgb(0 0 0 / 0.35)),
    var(--band);
  transform-origin: 50% 0;
  transform: translateZ(calc(-4 * var(--u))) rotateX(-38deg);
}

/* rounded slabs fill the case, corners included */
.slab {
  position: absolute;
  inset: calc(0.5 * var(--u));
  border-radius: calc(24 * var(--u));
  background: var(--metal);
  transform: translateZ(calc((var(--i) - 3) * 1.5 * var(--u)));
}

.slab.mid {
  background: color-mix(in srgb, #eceefb 45%, var(--metal)); /* a polished band */
}

/* ribbed crown plate, plus its end face turned side-on */
.crown {
  position: absolute;
  top: calc(30 * var(--u));
  left: calc(100% - 3 * var(--u));
  width: calc(7 * var(--u));
  height: calc(20 * var(--u));
  border-radius: calc(2 * var(--u));
  background: repeating-linear-gradient(var(--metal) 0 calc(1.5 * var(--u)), #a3a7bd calc(1.5 * var(--u)) calc(3 * var(--u)));
  transform-style: preserve-3d;
  transform: translateZ(calc(0.75 * var(--u)));
}

.crown::before {
  content: '';
  position: absolute;
  top: 0;
  left: calc(100% - 4 * var(--u));
  width: calc(8 * var(--u));
  height: 100%;
  border-radius: calc(2 * var(--u));
  background: inherit;
  transform: rotateY(90deg);
}

.back,
.face {
  position: absolute;
  inset: 0;
  box-sizing: border-box;
  border-radius: calc(24 * var(--u));
  backface-visibility: hidden;
}

.back {
  background:
    radial-gradient(circle, #05060c 0 calc(10 * var(--u)), #1e8c85 calc(11 * var(--u)) calc(13 * var(--u)), transparent calc(14 * var(--u))),
    radial-gradient(circle, #868ba4 0 calc(30 * var(--u)), var(--metal) calc(31 * var(--u)));
  transform: rotateY(180deg) translateZ(calc(5 * var(--u)));
}

/* black glass, the border is the bezel */
.face {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: calc(9 * var(--u)) calc(6 * var(--u)) calc(6 * var(--u));
  overflow: hidden;
  border: calc(5 * var(--u)) solid #05060c;
  background: radial-gradient(circle at 50% 30%, #231c42, #05060c 70%);
  color: #fff;
  font-family: Inter, system-ui, sans-serif;
  transform: translateZ(calc(5 * var(--u)));
}

.face::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(125deg, rgb(255 255 255 / 0.16), transparent 38%);
}

.face small {
  margin-top: calc(1 * var(--u));
  color: #2ee6d6;
  font-size: calc(7 * var(--u));
  font-weight: 700;
  letter-spacing: calc(1.2 * var(--u));
  text-transform: uppercase;
}

/* 12 ticks from a repeating conic gradient, a dark disc on top, an activity arc behind */
.dial {
  position: relative;
  flex: none;
  width: calc(50 * var(--u));
  height: calc(50 * var(--u));
  border-radius: 50%;
  background:
    radial-gradient(circle, #0b0d18 0 calc(19 * var(--u)), transparent calc(19.5 * var(--u))),
    repeating-conic-gradient(from -1.5deg, rgb(255 255 255 / 0.85) 0 3deg, transparent 3deg 30deg),
    conic-gradient(#ff4d9d 0 68%, rgb(255 77 157 / 0.25) 0);
  box-shadow: inset 0 0 0 calc(1 * var(--u)) rgb(255 255 255 / 0.12);
}

.dial::after {
  content: '';
  position: absolute;
  top: calc(50% - 2.5 * var(--u));
  left: calc(50% - 2.5 * var(--u));
  width: calc(5 * var(--u));
  height: calc(5 * var(--u));
  border-radius: 50%;
  background: #2ee6d6;
}

/* stands on the centre, turns about its foot */
.hand {
  position: absolute;
  bottom: 50%;
  border-radius: calc(2 * var(--u));
  background: #fff;
  transform-origin: 50% 100%;
}

.hand.h { left: calc(50% - 1.5 * var(--u)); width: calc(3 * var(--u)); height: calc(12 * var(--u)); transform: rotate(var(--h, 300deg)); }
.hand.m { left: calc(50% - 1 * var(--u)); width: calc(2 * var(--u)); height: calc(18 * var(--u)); transform: rotate(var(--m, 54deg)); }
.hand.s { left: calc(50% - 0.5 * var(--u)); width: calc(1 * var(--u)); height: calc(21 * var(--u)); background: #2ee6d6; transform: rotate(var(--s, 180deg)); }

/* two integers from JS become counters; decimal-leading-zero pads them */
.time {
  margin-top: calc(5 * var(--u));
  counter-reset: hh var(--hh, 10) mm var(--mm, 9);
  font: 700 calc(15 * var(--u))/1 'JetBrains Mono', ui-monospace, Consolas, monospace;
  letter-spacing: calc(-0.5 * var(--u));
}

.time::before {
  content: counter(hh, decimal-leading-zero) ':' counter(mm, decimal-leading-zero);
}

@keyframes sway {
  from { transform: rotateX(10deg) rotateY(-30deg); }
  to   { transform: rotateX(6deg) rotateY(30deg); }
}

JS 20 lines

const watch = document.querySelector('.watch');
// The site pauses a model by marking the frame's <html data-paused>: then the hands stand still,
// and catch up when it is un-paused. With reduced motion the second hand stays where it is.
const calm = matchMedia('(prefers-reduced-motion: reduce)');

function tick() {
  if (document.documentElement.hasAttribute('data-paused')) return;
  const d = new Date();
  // seconds since midnight: the angles only grow, so no hand sweeps backwards at 59 → 0
  const t = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
  if (!calm.matches || !watch.style.getPropertyValue('--s')) watch.style.setProperty('--s', t * 6 + 'deg');
  watch.style.setProperty('--m', (t / 10).toFixed(1) + 'deg');
  watch.style.setProperty('--h', (t / 120).toFixed(2) + 'deg');
  // integers for the digital readout (CSS counters)
  watch.style.setProperty('--hh', d.getHours());
  watch.style.setProperty('--mm', d.getMinutes());
}

tick();
setInterval(tick, 1000);
Standalone snippet · plain CSS, no build step, no dependencies.