CSS + JS Data & tools #controls #hover #data #chart #json

Activity rings from JSON in CSS + JavaScript

Three neon progress rings, like a fitness watch, drawn from a JSON list of days: each arc is value ÷ goal, and past 100% it runs into a second lap. Switch the day and the arcs sweep to the new numbers; hover or tap a ring for its figures. JS only writes one number per ring.

  • 211 lines of CSS
  • 7 lines of HTML
  • 106 lines of JS
  • No dependencies
  • MIT licensed
Video 0.3 MB GitHub

How it works

  1. The data is plain JSON, shaped like an API response: for each day a [value, goal] pair per ring. JS turns each pair into one number, --p = value ÷ goal, written on the ring. 1 is a closed ring, more is a second lap.
  2. The arc is a conic-gradient that stops at calc(var(--p) * 360deg) and turns transparent. A radial-gradient mask keeps only the outer 16px of the disc, so the pie becomes a band.
  3. A gradient can't be transitioned, and --p as an ordinary custom property would jump. @property --p { syntax: '<number>' } tells the browser it is a number, so transition: --p 1.1s interpolates it and the arc sweeps. That repaints the gradient every frame, which is why it is kept to three rings; everything else moves with transform and opacity.
  4. Depth: each arc is drawn three times, 2px apart in Z and darker each step. Seen tilted, their edges read as a solid band. The dim full track sits 6px behind, and a wider, see-through copy with a soft mask is the glow.
  5. The rounded tip is a dot on a box as big as the ring, turned by rotate(calc(var(--p) * 1turn)): the same number, as a transform. Past 100% it keeps turning, over the start of the ring.
  6. Each ring is a static disc (the inner ones 1px nearer, so they win where they overlap) that never moves. The pointed-at one gets .is-active: its band lifts inside the disc and a full-colour copy of the arc fades in. A finger has no hover, so a tap picks a ring, and its numbers go in the <output>.

Key techniques

  • JSON → --p per ring (value ÷ goal)
  • conic-gradient arc + radial mask = a ring
  • @property <number>: the gradient transitions
  • three layers stepping back: a solid band

Copy-paste code

HTML 7 lines

<div class="activity">
  <div class="scene">
    <div class="rings"></div>
  </div>
  <output></output>
  <div class="seg"></div>
</div>

CSS 211 lines

/* A registered property can be transitioned: the browser knows it is a number and interpolates
   it, so every gradient that uses it is redrawn on the way. An unregistered one would jump. */
@property --p {
  syntax: '<number>';
  inherits: true; /* the ring's one transition drives all of its layers */
  initial-value: 0;
}

.activity {
  display: grid;
  justify-items: center;
  gap: 6px;
  font-family: system-ui, sans-serif;
}

.scene {
  perspective: 800px;
  padding: 20px 40px 26px;
  pointer-events: none; /* the rings are tilted: only the rings and the hub take the pointer */
}

.rings {
  position: relative;
  width: 160px;
  height: 160px;
  transform-style: preserve-3d;
  transform: rotateX(34deg) rotateY(-16deg);
}

/* The hit target: a static disc per ring, each inner one 1px nearer so it wins where they
   overlap. Ring n is inset by n × 19px (a 16px band and a 3px gap). */
.ring {
  --c: #ff4d9d;
  position: absolute;
  inset: calc(var(--r) * 19px);
  border-radius: 50%;
  outline: none;
  pointer-events: auto;
  cursor: pointer;
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--r) * 1px));
  /* past 1: the arc overshoots a little and settles; each ring 90ms after the one around it */
  transition: --p 1.1s cubic-bezier(0.3, 1.15, 0.5, 1) calc(var(--r) * 90ms);
}

.ring:nth-child(2) { --c: #2ee6d6; }
.ring:nth-child(3) { --c: #ffb547; }

/* what you see: it lifts while its ring is pointed at (the disc around it stays put) */
.band {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transform-style: preserve-3d;
  transition: transform 0.35s ease;
}

/* every layer is the disc cut to its outer 16px by a radial mask */
.band i {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  mask: radial-gradient(closest-side, transparent calc(100% - 16px), #000 calc(100% - 15.5px));
  transition: opacity 0.25s;
}

/* the track: the whole ring, dim, recessed behind the arc */
.band i:nth-child(1) {
  background: color-mix(in srgb, var(--c) 16%, #141830);
  transform: translateZ(-6px);
}

/* the glow: the same arc, wider and see-through, its edges faded by a soft mask */
.band i:nth-child(2) {
  inset: -6px;
  background: conic-gradient(color-mix(in srgb, var(--c) 42%, transparent) calc(var(--p) * 360deg), transparent calc(var(--p) * 360deg + 10deg));
  mask: radial-gradient(closest-side, transparent calc(100% - 30px), #000 calc(100% - 22px) calc(100% - 6px), transparent);
  opacity: 0.55;
  transform: translateZ(-5.5px);
}

/* the arc three times, stepping back and darker: tilted, the edges read as a solid band.
   "transparent 0" ends the colour exactly at the arc's angle */
.band i:nth-child(3) {
  background: conic-gradient(color-mix(in srgb, var(--c) 32%, #05060c) calc(var(--p) * 360deg), transparent 0);
  transform: translateZ(-4px);
}

.band i:nth-child(4) {
  background: conic-gradient(color-mix(in srgb, var(--c) 55%, #05060c) calc(var(--p) * 360deg), transparent 0);
  transform: translateZ(-2px);
}

/* the front: from a softer start to the full colour at the tip */
.band i:nth-child(5) {
  background: conic-gradient(color-mix(in srgb, var(--c) 60%, #141830), var(--c) calc(var(--p) * 360deg), transparent 0);
}

/* the pointed-at ring fills in: the whole arc at full colour, faded in over the front */
.band i:nth-child(6) {
  background: conic-gradient(var(--c) calc(var(--p) * 360deg), transparent 0);
  opacity: 0;
  transform: translateZ(0.25px);
}

/* the round start (on the band) and the round tip (on a box turned by the same number) */
.band em {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: rotate(calc(var(--p) * 1turn));
}

.band::before,
.band::after,
.band em::before,
.band em::after {
  content: '';
  position: absolute;
  top: 0;
  left: calc(50% - 8px);
  width: 16px;
  height: 16px;
  border-radius: 50%;
}

.band::before {
  background: color-mix(in srgb, var(--c) 60%, #141830);
  transform: translateZ(0.5px);
}

.band::after,
.band em::after {
  background: color-mix(in srgb, var(--c) 32%, #05060c);
  transform: translateZ(-3.5px);
}

.band em::before {
  background: var(--c);
  box-shadow: 0 0 10px color-mix(in srgb, var(--c) 60%, transparent);
  transform: translateZ(1px);
}

/* a second lap: a dark rim sets the tip apart from the arc it runs over */
.ring.is-lap em::before {
  box-shadow:
    0 0 0 1.5px color-mix(in srgb, var(--c) 40%, #05060c),
    0 0 10px color-mix(in srgb, var(--c) 60%, transparent);
}

/* The pointed-at ring lifts, fills in and glows more: transform and opacity only, and on the
   flat layers (opacity on a 3D group would flatten it). The others stay as they are. */
.ring.is-active > .band {
  transform: translateZ(8px);
}

.ring.is-active > .band::before {
  background: var(--c);
}

.ring.is-active i:nth-child(2),
.ring.is-active i:nth-child(6) {
  opacity: 1;
}

/* the day, in the hole: it also catches the pointer there, so the middle picks no ring */
.hub {
  position: absolute;
  top: calc(50% - 24px);
  left: calc(50% - 24px);
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  color: #eceefb;
  font: 800 12px/1 system-ui, sans-serif;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  pointer-events: auto;
  transform: translateZ(4px);
}

output {
  color: #949bc0;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

.seg {
  display: flex;
  gap: 2px;
  padding: 3px;
  border: 1px solid rgb(255 255 255 / 0.14);
  border-radius: 999px;
}

.seg button {
  padding: 4px 12px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: #949bc0;
  font: 700 12px system-ui, sans-serif;
  cursor: pointer;
}

.seg button[aria-pressed='true'] {
  background: linear-gradient(135deg, #8b6cff, #ff4d9d);
  color: #fff;
}

JS 106 lines

// The data, as an API would send it back: per day, [value, goal] for each ring
const ACTIVITY = {
  "rings": [
    { "key": "move", "label": "Move", "unit": "kcal" },
    { "key": "exercise", "label": "Exercise", "unit": "min" },
    { "key": "stand", "label": "Stand", "unit": "hrs" }
  ],
  "days": [
    { "day": "Mon", "move": [420, 600], "exercise": [22, 30], "stand": [9, 12] },
    { "day": "Tue", "move": [552, 600], "exercise": [21, 30], "stand": [9, 12] },
    { "day": "Wed", "move": [660, 600], "exercise": [34, 30], "stand": [12, 12] },
    { "day": "Thu", "move": [240, 600], "exercise": [27, 30], "stand": [5, 12] }
  ]
};

const box = document.querySelector('.rings');
const out = document.querySelector('.activity output');
const seg = document.querySelector('.activity .seg');
const { rings: RINGS, days: DAYS } = ACTIVITY;

// Build it once from the data: per ring a static disc holding a band of six layers and a tip,
// the day in the middle, and a button per day. Text goes in with textContent, never as HTML:
// data from an API is not trusted markup.
box.innerHTML =
  RINGS.map((_, i) => `<div class="ring" style="--r:${i}" tabindex="0"><div class="band">${'<i></i>'.repeat(6)}<em></em></div></div>`).join('') +
  '<div class="hub"></div>';
DAYS.forEach((d, i) => {
  const b = document.createElement('button');
  b.type = 'button';
  b.dataset.day = i;
  b.textContent = d.day;
  seg.append(b);
});
const rings = [...box.querySelectorAll('.ring')];
const hub = box.querySelector('.hub');
const buttons = seg.querySelectorAll('button');

const pct = (pair) => Math.round((pair[0] / pair[1]) * 100);
const summary = (d) => d.day + ' · ' + RINGS.map((r) => `${r.label} ${pct(d[r.key])}%`).join(' · ');
const detail = (d, i) => {
  const r = RINGS[i];
  return `${d.day} · ${r.label} ${d[r.key][0]}/${d[r.key][1]} ${r.unit} · ${pct(d[r.key])}%`;
};

let day = 0;
let active = -1;
let hideTimer = 0;
const ringOf = (el) => (el instanceof Element ? rings.indexOf(el.closest('.ring')) : -1);

// the pointed-at ring lifts and fills in (CSS), and the dock shows its numbers
function pointAt(i) {
  clearTimeout(hideTimer);
  active = i;
  rings.forEach((r, k) => r.classList.toggle('is-active', k === i));
  out.textContent = i >= 0 ? detail(DAYS[day], i) : summary(DAYS[day]);
}
// a short grace period, so crossing from one ring to the next does not flicker the dock
function letGo() {
  clearTimeout(hideTimer);
  hideTimer = setTimeout(() => pointAt(-1), 150);
}

// Show one day: each ring gets --p = value ÷ goal. The sweep, the stagger and the lap are CSS.
function show(i) {
  day = i;
  const d = DAYS[i];
  RINGS.forEach((r, k) => {
    const p = d[r.key][0] / d[r.key][1];
    rings[k].style.setProperty('--p', p.toFixed(3));
    rings[k].classList.toggle('is-lap', p > 1);
    rings[k].setAttribute('aria-label', detail(d, k));
  });
  hub.textContent = d.day;
  buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.day == i));
  pointAt(active);
}

seg.addEventListener('click', (e) => {
  const b = e.target.closest('button');
  if (b) show(Number(b.dataset.day));
});

// a mouse picks a ring by hovering it, the keyboard by focusing it (a tap focuses it too, but
// taps are handled on pointerup below)
const mine = (e) => (e.pointerType ? e.pointerType === 'mouse' : e.target.matches(':focus-visible'));
const over = (e) => {
  const i = ringOf(e.target);
  if (i >= 0 && mine(e)) pointAt(i);
};
const leave = (e) => {
  if (e.pointerType && e.pointerType !== 'mouse') return;
  if (ringOf(e.relatedTarget) < 0) letGo();
};
box.addEventListener('pointerover', over);
box.addEventListener('focusin', over);
box.addEventListener('pointerout', leave);
box.addEventListener('focusout', leave);
// a finger has no hover: a tap picks a ring, a second tap or a tap on the middle lets go
box.addEventListener('pointerup', (e) => {
  if (e.pointerType === 'mouse') return;
  const i = ringOf(e.target);
  if (i >= 0 && i !== active) pointAt(i);
  else letGo();
});

show(0);

Sass source 271 lines

@use 'sass:list';

// Three activity rings drawn from JSON. JS writes one number per ring, --d-activity-p: value ÷
// goal (1 = a closed ring, more = into a second lap). A conic-gradient paints the arc up to that
// angle and a radial mask cuts it to a band.
// A gradient cannot be transitioned, but a REGISTERED custom property can: @property tells the
// browser --d-activity-p is a <number>, so it interpolates it and redraws every gradient that uses
// it on the way. That is this model's one exception to "animate transform and opacity only", and
// it is kept to these three rings. The property inherits, so the ring's one transition drives all
// of its layers and the rotating end cap.
@property --d-activity-p {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

$size: 160px;
$band: 16px;
$pitch: 19px; // a band and a 3px gap: ring n is inset by n × $pitch
$hub: 48px;
$colors: (var(--hot), var(--accent-2), var(--warm)); // outer → inner
$ease: cubic-bezier(0.3, 1.15, 0.5, 1); // a touch past 1: the arc overshoots a little and settles
// the band: a radial mask keeps only the outer $band px of the disc
$ring: radial-gradient(closest-side, transparent calc(100% - #{$band}), #000 calc(100% - #{$band - 0.5px}));
$arc: calc(var(--d-activity-p) * 360deg);
// the arc runs from a softer start to the full colour at its tip
$start: color-mix(in srgb, var(--c) 60%, var(--surface-solid));

.d-activity {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  width: 100%;
  height: 100%;
  padding: 10px 14px 14px; // the control dock: the same place in every model with controls
  // The rings are tilted, so their top half sits behind the plane of the flat boxes around them
  // (this one and the view), which would catch the pointer first: only the rings, the hub and
  // the dock take it.
  pointer-events: none;

  &__view {
    display: grid;
    place-items: center;
    min-height: 0;
    pointer-events: none;
  }

  &__rings {
    position: relative;
    width: $size;
    height: $size;
    pointer-events: none;
    transform-style: preserve-3d;
    transform: rotateX(34deg) rotateY(-16deg);
  }

  // A static disc per ring is the hit target: it never moves (what moves is the band inside it).
  // Each inner ring is 1px nearer than the one around it, so where the discs overlap the inner
  // one is hit first and every band catches its own pointer.
  &__ring {
    --c: var(--hot);
    position: absolute;
    inset: calc(var(--r) * #{$pitch});
    border-radius: 50%;
    outline: none;
    pointer-events: auto;
    cursor: pointer;
    transform-style: preserve-3d;
    transform: translateZ(calc(var(--r) * 1px));
    // the one animated number; each ring starts 90ms after the one around it
    transition: --d-activity-p 1.1s $ease calc(var(--r) * 90ms);
  }

  @each $c in $colors {
    $n: list.index($colors, $c);

    &__ring:nth-child(#{$n}) {
      --c: #{$c};
    }
  }

  // the visible ring: lifted towards you while its ring is picked
  &__band {
    position: absolute;
    inset: 0;
    pointer-events: none;
    transform-style: preserve-3d;
    transition: transform 0.35s ease;

    // the rounded start of the arc, at 12 o'clock: a dot in the front layer and one in the back
    &::before,
    &::after {
      content: '';
      position: absolute;
      top: 0;
      left: calc(50% - #{$band * 0.5});
      width: $band;
      height: $band;
      border-radius: 50%;
    }

    &::before {
      background: $start;
      transform: translateZ(0.5px);
    }

    &::after {
      background: color-mix(in srgb, var(--c) 32%, #05060c);
      transform: translateZ(-3.5px);
    }

    i {
      position: absolute;
      inset: 0;
      border-radius: 50%;
      mask: $ring;
      transition: opacity 0.25s;
    }

    // the track: the whole ring, dim, recessed behind the lit arc
    i:nth-child(1) {
      background: color-mix(in srgb, var(--c) 16%, var(--surface-solid));
      transform: translateZ(-6px);
    }

    // the glow: the same arc, wider and see-through, its edges faded by a soft mask
    i:nth-child(2) {
      inset: -6px;
      background: conic-gradient(color-mix(in srgb, var(--c) 42%, transparent) $arc, transparent calc(#{$arc} + 10deg));
      mask: radial-gradient(closest-side, transparent calc(100% - #{$band + 14px}), #000 calc(100% - #{$band + 6px}) calc(100% - 6px), transparent);
      opacity: 0.55;
      transform: translateZ(-5.5px);
    }

    // The arc, three times, stepping back and darker: seen tilted, their edges read as a solid
    // band. `transparent 0` ends the colour exactly at the arc's angle.
    i:nth-child(3) {
      background: conic-gradient(color-mix(in srgb, var(--c) 32%, #05060c) $arc, transparent 0);
      transform: translateZ(-4px);
    }

    i:nth-child(4) {
      background: conic-gradient(color-mix(in srgb, var(--c) 55%, #05060c) $arc, transparent 0);
      transform: translateZ(-2px);
    }

    // the front: from a softer start to the full colour at the tip
    i:nth-child(5) {
      background: conic-gradient($start, var(--c) $arc, transparent 0);
    }

    // the pointed-at ring fills in: the whole arc at full colour, faded in over the front
    i:nth-child(6) {
      background: conic-gradient(var(--c) $arc, transparent 0);
      opacity: 0;
      transform: translateZ(0.25px);
    }

    // The end cap: a box as big as the ring, turned by the same number (a transform, so it
    // follows the arc), with the dot at its top. Past 100% it keeps going round: a second lap.
    em {
      position: absolute;
      inset: 0;
      transform-style: preserve-3d;
      transform: rotate(calc(var(--d-activity-p) * 1turn));

      &::before,
      &::after {
        content: '';
        position: absolute;
        top: 0;
        left: calc(50% - #{$band * 0.5});
        width: $band;
        height: $band;
        border-radius: 50%;
      }

      &::before {
        background: var(--c);
        box-shadow: 0 0 10px color-mix(in srgb, var(--c) 60%, transparent);
        transform: translateZ(1px);
      }

      &::after {
        background: color-mix(in srgb, var(--c) 32%, #05060c);
        transform: translateZ(-3.5px);
      }
    }
  }

  // a second lap: a dark rim sets the cap apart from the arc it runs over
  &__ring.is-lap em::before {
    box-shadow:
      0 0 0 1.5px color-mix(in srgb, var(--c) 40%, #05060c),
      0 0 10px color-mix(in srgb, var(--c) 60%, transparent);
  }

  // The pointed-at ring (.is-active): its band lifts towards you, the arc fills in and the glow
  // brightens, all by transform and opacity (on the flat layers: opacity on a 3D group would
  // flatten it). The other rings stay as they are. Its round start takes the full colour too.
  &__ring.is-active {
    > .d-activity__band {
      transform: translateZ(8px);
    }

    > .d-activity__band::before {
      background: var(--c);
    }

    i:nth-child(2),
    i:nth-child(6) {
      opacity: 1;
    }
  }

  // the day, in the hole: it also catches the pointer there, so the middle picks no ring
  &__hub {
    position: absolute;
    top: calc(50% - #{$hub * 0.5});
    left: calc(50% - #{$hub * 0.5});
    display: grid;
    place-items: center;
    width: $hub;
    height: $hub;
    border-radius: 50%;
    color: var(--text);
    font: 800 12px/1 var(--font);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    pointer-events: auto;
    transform: translateZ(4px);
  }

  &__dock {
    display: grid;
    justify-items: center;
    gap: 6px;
    font-size: 12px;
    pointer-events: auto;

    output {
      color: var(--muted);
      font-variant-numeric: tabular-nums;
      white-space: nowrap;
    }
  }

  &__seg {
    display: flex;
    gap: 2px;
    padding: 3px;
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    background: color-mix(in srgb, var(--bg) 70%, transparent);

    button {
      padding: 4px 12px;
      border: 0;
      border-radius: 999px;
      background: transparent;
      color: var(--muted);
      font: inherit;
      font-weight: 700;
      cursor: pointer;

      &[aria-pressed='true'] {
        background: linear-gradient(135deg, var(--accent), var(--hot));
        color: #fff;
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.