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;
}
}
}
}