Rings
import { useState } from 'react';
const RINGS = [
{ label: 'Move', value: 486, goal: 600, unit: 'kcal', r: 46 },
{ label: 'Exercise', value: 32, goal: 30, unit: 'min', r: 35 },
{ label: 'Stand', value: 9, goal: 12, unit: 'hr', r: 24 },
];
const SHADES = ['light-dark(#373a40, #f1f2f3)', 'light-dark(#50535b, #c6c8cd)', 'light-dark(#6d7179, #7d8087)'];
const css = `
/* Each ring sweeps up from empty to its share of the goal, a beat after the one outside it. */
@keyframes card-rings-a { from { stroke-dashoffset: 1; } }
@keyframes card-rings-b { from { stroke-dashoffset: 1; } }
.card-rings {
display: flex;
align-items: center;
gap: 18px;
width: 256px;
padding: 16px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 24px -14px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
outline: none;
}
.card-rings:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.card-rings svg { flex: none; width: 108px; height: 108px; transform: rotate(-90deg); overflow: visible; }
.card-rings__track { fill: none; stroke: light-dark(#ebedf0, #232529); stroke-width: 9; }
.card-rings__fill { fill: none; stroke: var(--c); stroke-width: 9; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: calc(1 - var(--p)); }
.card-rings[data-flip='a'] .card-rings__fill { animation: card-rings-a 1.1s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--k) * 0.18s) both; }
.card-rings[data-flip='b'] .card-rings__fill { animation: card-rings-b 1.1s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--k) * 0.18s) both; }
.card-rings__list { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.card-rings__list li { display: grid; grid-template-columns: 8px auto; column-gap: 8px; align-items: center; }
.card-rings__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
.card-rings__name { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.card-rings__val { grid-column: 2; color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card-rings__val small { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 400; }
@media (prefers-reduced-motion: reduce) {
.card-rings__fill { animation: none !important; }
}
`;
export function RingsCard() {
const [flip, setFlip] = useState(0);
const replay = () => setFlip((f) => f + 1);
return (
<>
<style>
{css}
</style>
<div
className="card-rings"
data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined}
tabIndex={0}
role="group"
aria-label={`Today: ${RINGS.map((r) => `${r.label} ${r.value} of ${r.goal} ${r.unit}`).join(', ')}`}
onPointerEnter={replay}
onFocus={replay}
>
<svg viewBox="0 0 108 108" aria-hidden="true">
{RINGS.map((r, k) => (
<g key={r.label}>
<circle className="card-rings__track" cx="54" cy="54" r={r.r} />
<circle
className="card-rings__fill"
cx="54"
cy="54"
r={r.r}
pathLength={1}
style={{ '--c': SHADES[k], '--p': Math.min(1, r.value / r.goal), '--k': k } as React.CSSProperties}
/>
</g>
))}
</svg>
<ul className="card-rings__list" aria-hidden="true">
{RINGS.map((r, k) => (
<li key={r.label} style={{ '--c': SHADES[k] } as React.CSSProperties}>
<span className="card-rings__dot" />
<span className="card-rings__name">{r.label}</span>
<span className="card-rings__val">
{r.value}
<small>
/{r.goal} {r.unit}
</small>
</span>
</li>
))}
</ul>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardRings(el) on the mounted element instead.
<div class="card-rings" tabindex="0" role="group" aria-label="Today: Move 486 of 600 kcal, Exercise 32 of 30 min, Stand 9 of 12 hr">
<svg viewBox="0 0 108 108" aria-hidden="true">
<g>
<circle class="card-rings__track" cx="54" cy="54" r="46"></circle>
<circle class="card-rings__fill" cx="54" cy="54" r="46" pathLength="1" style="--c:light-dark(#373a40, #f1f2f3);--p:0.81;--k:0"></circle>
</g>
<g>
<circle class="card-rings__track" cx="54" cy="54" r="35"></circle>
<circle class="card-rings__fill" cx="54" cy="54" r="35" pathLength="1" style="--c:light-dark(#50535b, #c6c8cd);--p:1;--k:1"></circle>
</g>
<g>
<circle class="card-rings__track" cx="54" cy="54" r="24"></circle>
<circle class="card-rings__fill" cx="54" cy="54" r="24" pathLength="1" style="--c:light-dark(#6d7179, #7d8087);--p:0.75;--k:2"></circle>
</g>
</svg>
<ul class="card-rings__list" aria-hidden="true">
<li style="--c:light-dark(#373a40, #f1f2f3)">
<span class="card-rings__dot"></span>
<span class="card-rings__name">Move</span>
<span class="card-rings__val">486<small>/600 kcal</small></span>
</li>
<li style="--c:light-dark(#50535b, #c6c8cd)">
<span class="card-rings__dot"></span>
<span class="card-rings__name">Exercise</span>
<span class="card-rings__val">32<small>/30 min</small></span>
</li>
<li style="--c:light-dark(#6d7179, #7d8087)">
<span class="card-rings__dot"></span>
<span class="card-rings__name">Stand</span>
<span class="card-rings__val">9<small>/12 hr</small></span>
</li>
</ul>
</div>/* Each ring sweeps up from empty to its share of the goal, a beat after the one outside it. */
@keyframes card-rings-a { from { stroke-dashoffset: 1; } }
@keyframes card-rings-b { from { stroke-dashoffset: 1; } }
.card-rings {
display: flex;
align-items: center;
gap: 18px;
width: 256px;
padding: 16px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 24px -14px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7));
outline: none;
}
.card-rings:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.card-rings svg { flex: none; width: 108px; height: 108px; transform: rotate(-90deg); overflow: visible; }
.card-rings__track { fill: none; stroke: light-dark(#ebedf0, #232529); stroke-width: 9; }
.card-rings__fill { fill: none; stroke: var(--c); stroke-width: 9; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: calc(1 - var(--p)); }
.card-rings[data-flip='a'] .card-rings__fill { animation: card-rings-a 1.1s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--k) * 0.18s) both; }
.card-rings[data-flip='b'] .card-rings__fill { animation: card-rings-b 1.1s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--k) * 0.18s) both; }
.card-rings__list { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.card-rings__list li { display: grid; grid-template-columns: 8px auto; column-gap: 8px; align-items: center; }
.card-rings__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
.card-rings__name { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.card-rings__val { grid-column: 2; color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card-rings__val small { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 400; }
@media (prefers-reduced-motion: reduce) {
.card-rings__fill { animation: none !important; }
}function initCardRings(root) {
var flip = 0;
// Each visit flips data-flip between a and b, so the rings close again from empty.
function replay() {
flip += 1;
root.setAttribute('data-flip', flip % 2 ? 'a' : 'b');
}
root.addEventListener('pointerenter', replay);
root.addEventListener('focus', replay);
return function () {
root.removeEventListener('pointerenter', replay);
root.removeEventListener('focus', replay);
};
}
document.querySelectorAll('.card-rings').forEach(initCardRings);Goal: Create a "Rings" card.
Visual behavior: An activity rings card: each ring sweeps round from empty to its share of the goal, a beat after the one outside it, with the totals listed beside them.
Technique:
- Each ring is an SVG circle with pathLength="1"; its dash shows --p, its value over its goal, and a keyframe sweeps it from empty, delayed by its index (--k).
- Hover and focus flip a data-flip attribute between a and b, picking one of two identically written keyframes, so the rings close again on every visit.
Accessibility: The card is focusable (tabIndex 0, role="group") with an aria-label that carries its content, so keyboard focus reveals the same state as hover and screen readers get the information in one go; the drawing is aria-hidden. Under prefers-reduced-motion the rings show their values without sweeping.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Rings" card.
Visual behavior: An activity rings card: each ring sweeps round from empty to its share of the goal, a beat after the one outside it, with the totals listed beside them.
Technique:
- Each ring is an SVG circle with pathLength="1"; its dash shows --p, its value over its goal, and a keyframe sweeps it from empty, delayed by its index (--k).
- Hover and focus flip a data-flip attribute between a and b, picking one of two identically written keyframes, so the rings close again on every visit.
Accessibility: The card is focusable (tabIndex 0, role="group") with an aria-label that carries its content, so keyboard focus reveals the same state as hover and screen readers get the information in one go; the drawing is aria-hidden. Under prefers-reduced-motion the rings show their values without sweeping.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
An activity rings card: each ring sweeps round from empty to its share of the goal, a beat after the one outside it, with the totals listed beside them.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- hover, card, draw, delay stagger
- Techniques
- svg, stroke dashoffset, keyframes, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The card is focusable (tabIndex 0, role="group") with an aria-label that carries its content, so keyboard focus reveals the same state as hover and screen readers get the information in one go; the drawing is aria-hidden. Under prefers-reduced-motion the rings show their values without sweeping.