Skip to content
Star on GitHub
Charts and tablesRadial goals
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.

Book a call

Radial goals

Each agent's share of its own target as a ring, with the overall score in the middle. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.

$npx designme add radial-goals
Agent goalsThis week
0%of targets
Agent goals
AgentDoneTargetShare
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add radial-goals

React

$npx designme add radial-goals --react

Astro

$npx designme add radial-goals --astro

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.

shadcn CLI, React

$npx shadcn@latest add https://designme.dev/r/shadcn/radial-goals.json

shadcn CLI, Astro

$npx shadcn@latest add https://designme.dev/r/shadcn/astro/radial-goals.json

In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.

02

About

What it is for and what it does.

Concentric rings, one for each agent, each filled to its share of that agent's own target, so agents with very different targets sit side by side. The middle shows the overall share, or the agent you point at. Every value is printed in the legend as done / target, a ring past its target goes round again with a marker where it ends, and a Week and Month switch changes the period.

  • To show how several agents (or teams, or goals) are doing against targets of different sizes.
  • On a weekly review where the question is who is on target and who is behind.
  • When people need the exact numbers as well as the picture.
03

Usage

Import it in React and drive it from your own state, or place it in an Astro page.

React

import { RadialGoals } from '@/components/designme/radial-goals/RadialGoals';

React example

The demo above as a React component, 30 lines you can copy.

'use client';
// The preview from designme.dev, rebuilt from the React component: the week, then the month.
import { useEffect, useRef, useState } from 'react';
import { RadialGoals, type RadialGoal } from './RadialGoals';
import { useStory } from '../lib/useScript';

const DATA: Record<'w' | 'm', RadialGoal[]> = {
  w: [{ key: 'support', label: 'Support agent', unit: 'tickets', value: 412, target: 375 }, { key: 'qa', label: 'QA agent', unit: 'checks', value: 136, target: 160 },
    { key: 'research', label: 'Research agent', unit: 'reports', value: 18, target: 24, color: 'var(--dm-c4)' }],
  m: [{ key: 'support', label: 'Support agent', unit: 'tickets', value: 1490, target: 1600 }, { key: 'qa', label: 'QA agent', unit: 'checks', value: 702, target: 640 },
    { key: 'research', label: 'Research agent', unit: 'reports', value: 71, target: 96, color: 'var(--dm-c4)' }]
};
const EMPTY = DATA.w.map(g => ({ ...g, value: 0 }));

export default function RadialGoalsDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [range, setRange] = useState<'w' | 'm'>('w');
  // the rings sweep in a moment after the card appears
  const [drawn, setDrawn] = useState(false);
  useEffect(() => { const t = setTimeout(() => setDrawn(true), 260); return () => clearTimeout(t); }, []);
  useStory(ref, async ({ wait }) => { await wait(7000); setRange('m'); await wait(2000); setRange('w'); });
  const on = DATA[range].filter(g => g.value >= g.target).length;
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <RadialGoals goals={drawn ? DATA[range] : EMPTY} subtitle={`${range === 'w' ? 'This week' : 'This month'}, ${on} of ${DATA[range].length} on target`}
        ranges={[{ value: 'w', label: 'Week' }, { value: 'm', label: 'Month' }]} range={range} onRangeChange={setRange} />
    </div>
  );
}

Astro

In a page in src/pages. It plays the demo above; change its radial-goals.js to show your own data. Attributes like class go on its root element.

---
import RadialGoals from '../components/designme/radial-goals/RadialGoals.astro';
---
<RadialGoals />
<RadialGoals data-style="soft" class="my-class" />
04

Props

Everything RadialGoals takes.

PropTypeWhat it does
goalsrequiredRadialGoal[]Two to five goals, outermost first. Three read best: the fewer the rings, the thicker they are
titlestring
subtitlestringThe line under the title. Defaults to "1 of 3 on target"
ranges{ value: R; label: string }[]A switch for the period, like Week and Month
rangeR
onRangeChange(range: R) => void
classNamestring

Also takes the props of string = string>.

05

Code

The full source, as the CLI installs it.

RadialGoals.tsxReact · 93 lines
'use client';
import { useState, type CSSProperties } from 'react';
import { cx } from '../lib/cx';
import { useTween } from '../lib/hooks';
import { Seg, Swap } from '../lib/motion';
import '../designme.css';
import './radial-goals.css';

export interface RadialGoal {
  key: string;
  /** Like "Support agent" */
  label: string;
  /** What it counts, like "tickets" */
  unit: string;
  value: number;
  target: number;
  /** Defaults to the chart palette in order */
  color?: string;
}

export interface RadialGoalsProps<R extends string = string> {
  /** Two to five goals, outermost first. Three read best: the fewer the rings, the thicker they are */
  goals: RadialGoal[];
  title?: string;
  /** The line under the title. Defaults to "1 of 3 on target" */
  subtitle?: string;
  /** A switch for the period, like Week and Month */
  ranges?: { value: R; label: string }[];
  range?: R;
  onRangeChange?: (range: R) => void;
  className?: string;
}

const PAL = ['var(--dm-c1)', 'var(--dm-c3)', 'var(--dm-c4)', 'var(--dm-c2)', 'var(--dm-c5)'];
const pc = (x: number) => Math.round(x * 100) + '%';

/** Concentric rings, one per agent, each its share of its own target; past the target a ring goes round again with a marker. */
export function RadialGoals<R extends string = string>({ goals, title = 'Agent goals', subtitle, ranges, range, onRangeChange, className }: RadialGoalsProps<R>) {
  const [focus, setFocus] = useState(-1);
  // the rings share the band from the outer edge (85) to the readout in the middle (34) with a 4 unit gap between them, so
  // three goals get rings 14 units wide; never wider than 16
  const W = Math.min(16, (51 - 4 * (goals.length - 1)) / Math.max(1, goals.length));
  const RAD = goals.map((_, i) => 85 - W / 2 - i * (W + 4));
  const share = (g: RadialGoal) => g.target ? g.value / g.target : 0;
  const overall = goals.reduce((t, g) => t + share(g), 0) / Math.max(1, goals.length);
  const mid = useTween(focus < 0 ? overall : share(goals[focus]), 600, 0);
  const on = goals.filter(g => share(g) >= 1).length;
  return (
    <div data-dm="" className={cx('dm-rgx dm-panel', className)}>
      <div className="dm-rgx-h">
        <div className="dm-rgx-t"><b>{title}</b><Swap className="dm-muted dm-rgx-sub">{subtitle ?? `${on} of ${goals.length} on target`}</Swap></div>
        {ranges && range !== undefined && onRangeChange && <Seg className="dm-rgx-sg" label="Range" options={ranges} value={range} onChange={onRangeChange} />}
      </div>
      <div className="dm-rgx-b">
        <div className={cx('dm-rgx-ring', focus >= 0 && 'dm-hov')}>
          <svg viewBox="0 0 180 180" aria-hidden="true" style={{ ['--dm-rw' as string]: W } as CSSProperties}>
            {goals.map((g, i) => { const s = share(g), c = g.color ?? PAL[i % PAL.length], an = (s > 1 ? s - 1 : s) * 2 * Math.PI;
              return (
                <g key={g.key} className={cx(i === focus && 'dm-on', s > 1 && 'dm-lap')} style={{ stroke: c }} onPointerEnter={e => { if (e.pointerType !== 'touch') setFocus(i); }} onPointerLeave={() => setFocus(-1)}>
                  <circle className="dm-tr" cx={90} cy={90} r={RAD[i]} />
                  <circle className="dm-fl" cx={90} cy={90} r={RAD[i]} pathLength={100} strokeDasharray={`${Math.max(0, Math.min(1, s) * 100 - .01)} 100`} />
                  {/* past the target the ring goes round again from the top, and a marker sits where it ends */}
                  {/* under the lap's end only, a band of the surface a little wider than the ring, so the end cuts into the ring under it */}
                  <circle className="dm-ovb" cx={90} cy={90} r={RAD[i]} pathLength={100} strokeDasharray={`${Math.min(3, Math.max(0, s - 1) * 100)} 100`} strokeDashoffset={-Math.max(0, (s - 1) * 100 - 3)} />
                  <circle className="dm-ov" cx={90} cy={90} r={RAD[i]} pathLength={100} strokeDasharray={`${Math.max(0, s - 1) * 100} 100`} />
                  <circle className="dm-mk" r={Math.max(2.5, W * .29)} cx={90 + Math.cos(an) * RAD[i]} cy={90 + Math.sin(an) * RAD[i]} style={{ stroke: c }} />
                </g>
              ); })}
          </svg>
          <div className="dm-rgx-c"><b className="dm-num">{pc(mid)}</b><Swap className="dm-muted">{focus < 0 ? 'of targets' : goals[focus].label.replace(/ agent$/, '')}</Swap></div>
        </div>
        <div className="dm-rgx-lg">
          {goals.map((g, i) => { const s = share(g);
            return (
              <div key={g.key} className={cx('dm-rgx-r', i === focus && 'dm-on')} tabIndex={0} aria-label={`${g.label}: ${g.value} of ${g.target} ${g.unit}, ${pc(s)} of target`}
                onPointerEnter={e => { if (e.pointerType !== 'touch') setFocus(i); }} onPointerLeave={e => { if (!e.currentTarget.parentElement?.contains(document.activeElement)) setFocus(-1); }}
                onFocus={() => setFocus(i)} onBlur={() => setFocus(-1)}>
                <i style={{ background: g.color ?? PAL[i % PAL.length] }} /><span className="dm-n">{g.label}</span>
                <span className="dm-p dm-num">{s >= 1 ? <span className="dm-tag dm-ok">{pc(s)}</span> : pc(s)}</span>
                <span className="dm-v dm-num">{g.value.toLocaleString('en-US')} / {g.target.toLocaleString('en-US')} {g.unit}</span>
              </div>
            ); })}
        </div>
      </div>
      <div className="dm-rgx-sr"><table>
        <caption>{title}</caption>
        <thead><tr><th>Agent</th><th>Done</th><th>Target</th><th>Share</th></tr></thead>
        <tbody>{goals.map(g => <tr key={g.key}><th>{g.label}</th><td>{g.value} {g.unit}</td><td>{g.target}</td><td>{pc(share(g))}</td></tr>)}</tbody>
      </table></div>
    </div>
  );
}
radial-goals.cssReact · 38 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Radial goals ---------- */
/* one ring per agent, each its share of its own target; past the target the ring goes round again, with a marker where it ends */
.dm-rgx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-4)}
.dm-rgx-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
.dm-rgx-t{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.dm-rgx-t b{font-weight:500}
.dm-rgx-t .dm-muted{font-size:12px}
.dm-rgx-sg button{font-size:12px;height:26px;padding:0 10px}
.dm-rgx-b{display:flex;align-items:center;gap:var(--dm-s-5);flex-wrap:wrap;justify-content:center}
.dm-rgx-ring{position:relative;width:172px;height:172px;flex:none}
.dm-rgx-ring svg{width:100%;height:100%;transform:rotate(-90deg);overflow:visible}
.dm-rgx-ring circle{fill:none;stroke-width:var(--dm-rw,14);transition:opacity .3s}
.dm-rgx-ring .dm-tr{stroke:var(--dm-chip)}
.dm-rgx-ring .dm-fl,.dm-rgx-ring .dm-ov,.dm-rgx-ring .dm-ovb{stroke-linecap:round;transition:stroke-dasharray .9s var(--dm-ease),stroke-dashoffset .9s var(--dm-ease),opacity .3s}
/* the end of a lap past the target sits on a slightly wider band of the surface, so it cuts into the ring under it and reads as a second lap */
.dm-rgx-ring .dm-ovb{stroke:var(--dm-surface);stroke-width:calc(var(--dm-rw,14) * 1px + 3px)}
.dm-rgx-ring .dm-mk{fill:var(--dm-surface);stroke-width:2.5;transition:opacity .3s}
.dm-rgx-ring.dm-hov g:not(.dm-on) :is(.dm-fl,.dm-ov,.dm-mk){opacity:.25}
/* a ring short of its target has no lap and no marker */
.dm-rgx-ring g:not(.dm-lap) :is(.dm-ov,.dm-ovb,.dm-mk){opacity:0!important}
.dm-rgx-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;line-height:1.3;text-align:center}
.dm-rgx-c b{font-size:14px;font-weight:500}
.dm-rgx-c .dm-muted{font-size:11px;max-width:72px}
.dm-rgx-lg{flex:1 1 180px;min-width:0;display:flex;flex-direction:column;gap:2px}
.dm-rgx-r{display:grid;grid-template-columns:auto 1fr auto;column-gap:9px;row-gap:1px;align-items:center;width:100%;padding:6px var(--dm-s-2);border-radius:calc(9px * var(--dm-rk));font-size:13px;text-align:left;transition:background .2s;cursor:default}
.dm-rgx-r.dm-on{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-rgx-r:hover{background:var(--dm-row)}}
.dm-rgx-r i{width:8px;height:8px;border-radius:50%}
.dm-rgx-r .dm-n{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-rgx-r .dm-p{font-size:12.5px;color:var(--dm-fg2);text-align:right}
.dm-rgx-r .dm-v{grid-column:2/4;font-size:12px;color:var(--dm-muted)}
.dm-rgx-r .dm-tag{height:18px;font-size:11px}
.dm-rgx-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
.dm-rgx{container-type:inline-size}
@container (max-width:300px){.dm-rgx-lg{min-width:0}.dm-rgx-h{flex-wrap:wrap}}
.dm-rgx-sr table{table-layout:fixed;width:1px;border-collapse:collapse}
RadialGoals.astroAstro · 34 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Radial goals: Each agent's share of its own target as a ring, with the overall score in the middle.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change radial-goals.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './radial-goals.css';

export interface Props extends HTMLAttributes<'div'> {
  /** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
  'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="radial-goals" {...rest}>
  <div class="stage-in"><Fragment is:raw>
<div class="rgx panel">
  <div class="rgx-h">
    <div class="rgx-t"><b>Agent goals</b><span class="muted rgx-sub">This week</span></div>
    <div class="seg2 rgx-sg" role="group" aria-label="Range"><button aria-pressed="true" data-v="w">Week</button><button aria-pressed="false" data-v="m">Month</button></div>
  </div>
  <div class="rgx-b">
    <div class="rgx-ring"><svg viewBox="0 0 180 180" aria-hidden="true"></svg><div class="rgx-c"><b class="num">0%</b><span class="muted">of targets</span></div></div>
    <div class="rgx-lg"></div>
  </div>
  <div class="rgx-sr"><table><caption>Agent goals</caption><thead><tr><th>Agent</th><th>Done</th><th>Target</th><th>Share</th></tr></thead><tbody></tbody></table></div>
</div>
  </Fragment></div>
</div>
<script>
  import './radial-goals.js';
</script>
radial-goals.htmlHTML · 16 lines
<div class="card" data-c="radial-goals">
  <div class="stage-in">
    <div class="rgx panel">
      <div class="rgx-h">
        <div class="rgx-t"><b>Agent goals</b><span class="muted rgx-sub">This week</span></div>
        <div class="seg2 rgx-sg" role="group" aria-label="Range"><button aria-pressed="true" data-v="w">Week</button><button aria-pressed="false" data-v="m">Month</button></div>
      </div>
      <div class="rgx-b">
        <div class="rgx-ring"><svg viewBox="0 0 180 180" aria-hidden="true"></svg><div class="rgx-c"><b class="num">0%</b><span class="muted">of targets</span></div></div>
        <div class="rgx-lg"></div>
      </div>
      <div class="rgx-sr"><table><caption>Agent goals</caption><thead><tr><th>Agent</th><th>Done</th><th>Target</th><th>Share</th></tr></thead><tbody></tbody></table></div>
    </div>
  </div>
</div>
radial-goals.cssHTML · 37 lines
/* ---------- Radial goals ---------- */
/* one ring per agent, each its share of its own target; past the target the ring goes round again, with a marker where it ends */
.rgx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-4)}
.rgx-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
.rgx-t{display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.rgx-t b{font-weight:500}
.rgx-t .muted{font-size:12px}
.rgx-sg button{font-size:12px;height:26px;padding:0 10px}
.rgx-b{display:flex;align-items:center;gap:var(--dm-s-5);flex-wrap:wrap;justify-content:center}
.rgx-ring{position:relative;width:172px;height:172px;flex:none}
.rgx-ring svg{width:100%;height:100%;transform:rotate(-90deg);overflow:visible}
.rgx-ring circle{fill:none;stroke-width:var(--dm-rw,14);transition:opacity .3s}
.rgx-ring .tr{stroke:var(--dm-chip)}
.rgx-ring .fl,.rgx-ring .ov,.rgx-ring .ovb{stroke-linecap:round;transition:stroke-dasharray .9s var(--dm-ease),stroke-dashoffset .9s var(--dm-ease),opacity .3s}
/* the end of a lap past the target sits on a slightly wider band of the surface, so it cuts into the ring under it and reads as a second lap */
.rgx-ring .ovb{stroke:var(--dm-surface);stroke-width:calc(var(--dm-rw,14) * 1px + 3px)}
.rgx-ring .mk{fill:var(--dm-surface);stroke-width:2.5;transition:opacity .3s}
.rgx-ring.hov g:not(.on) :is(.fl,.ov,.mk){opacity:.25}
/* a ring short of its target has no lap and no marker */
.rgx-ring g:not(.lap) :is(.ov,.ovb,.mk){opacity:0!important}
.rgx-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;line-height:1.3;text-align:center}
.rgx-c b{font-size:14px;font-weight:500}
.rgx-c .muted{font-size:11px;max-width:72px}
.rgx-lg{flex:1 1 180px;min-width:0;display:flex;flex-direction:column;gap:2px}
.rgx-r{display:grid;grid-template-columns:auto 1fr auto;column-gap:9px;row-gap:1px;align-items:center;width:100%;padding:6px var(--dm-s-2);border-radius:calc(9px * var(--dm-rk));font-size:13px;text-align:left;transition:background .2s;cursor:default}
.rgx-r.on{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.rgx-r:hover{background:var(--dm-row)}}
.rgx-r i{width:8px;height:8px;border-radius:50%}
.rgx-r .n{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rgx-r .p{font-size:12.5px;color:var(--dm-fg2);text-align:right}
.rgx-r .v{grid-column:2/4;font-size:12px;color:var(--dm-muted)}
.rgx-r .tag{height:18px;font-size:11px}
.rgx-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
.rgx{container-type:inline-size}
@container (max-width:300px){.rgx-lg{min-width:0}.rgx-h{flex-wrap:wrap}}
.rgx-sr table{table-layout:fixed;width:1px;border-collapse:collapse}
radial-goals.jsHTML · 50 lines
DM.def('radial-goals', (r, c) => {
  // each agent, what it counts, and what it did against its target this week and this month
  const A = [
    { n:'Support agent', u:'tickets', c:'var(--dm-c1)', w:[412, 375], m:[1490, 1600] },
    { n:'QA agent', u:'checks', c:'var(--dm-c3)', w:[136, 160], m:[702, 640] },
    { n:'Research agent', u:'reports', c:'var(--dm-c4)', w:[18, 24], m:[71, 96] }
  ];
  const svg = $('.rgx-ring svg', r), ring = $('.rgx-ring', r), lg = $('.rgx-lg', r), cb = $('.rgx-c b', r), cl = $('.rgx-c .muted', r), sub = $('.rgx-sub', r), tb = $('.rgx-sr tbody', r);
  // three thick rings, 14 units wide with a 4 unit gap between them, leaving room in the middle for the readout
  const RAD = [78, 60, 42];
  svg.innerHTML = A.map((a, i) => `<g data-i="${i}" style="stroke:${a.c}"><circle class="tr" cx="90" cy="90" r="${RAD[i]}"/><circle class="fl" cx="90" cy="90" r="${RAD[i]}" pathLength="100" stroke-dasharray="0 100"/><circle class="ovb" cx="90" cy="90" r="${RAD[i]}" pathLength="100" stroke-dasharray="0 100"/><circle class="ov" cx="90" cy="90" r="${RAD[i]}" pathLength="100" stroke-dasharray="0 100"/><circle class="mk" r="4" cx="${90 + RAD[i]}" cy="90"/></g>`).join('');
  lg.innerHTML = A.map((a, i) => `<div class="rgx-r" data-i="${i}" tabindex="0"><i style="background:${a.c}"></i><span class="n">${a.n}</span><span class="p num"></span><span class="v num"></span></div>`).join('');
  const G = $$('g', svg), rows = $$('.rgx-r', lg);
  let k = 'w', focus = -1, shown = 0;
  const share = a => a[k][0] / a[k][1], pc = x => Math.round(x * 100) + '%';
  const overall = () => A.reduce((t, a) => t + share(a), 0) / A.length;
  function paint(){
    A.forEach((a, i) => { const s = share(a), g = G[i];
      // (a hair short of the share: a dash that ends exactly on a quarter of the circle draws a stray notch in Chrome)
      $('.fl', g).setAttribute('stroke-dasharray', `${Math.max(0, Math.min(1, s) * 100 - .01)} 100`);
      // past the target the ring goes round again from the top, and a marker sits where it ends
      const lap = Math.max(0, s - 1) * 100, cut = Math.min(3, lap), ovb = $('.ovb', g);
      $('.ov', g).setAttribute('stroke-dasharray', `${lap} 100`);
      // under the lap's end only, a band of the surface a little wider than the ring, so the end cuts into the ring under it
      ovb.setAttribute('stroke-dasharray', `${cut} 100`); ovb.setAttribute('stroke-dashoffset', -(lap - cut)); g.classList.toggle('lap', s > 1);
      const mk = $('.mk', g), an = (s > 1 ? s - 1 : s) * 2 * Math.PI; mk.setAttribute('cx', 90 + Math.cos(an) * RAD[i]); mk.setAttribute('cy', 90 + Math.sin(an) * RAD[i]); mk.style.stroke = a.c;
      const row = rows[i]; $('.p', row).innerHTML = s >= 1 ? `<span class="tag ok">${pc(s)}</span>` : pc(s); $('.v', row).textContent = `${a[k][0].toLocaleString('en-US')} / ${a[k][1].toLocaleString('en-US')} ${a.u}`;
      row.setAttribute('aria-label', `${a.n}: ${a[k][0]} of ${a[k][1]} ${a.u}, ${pc(s)} of target`); });
    tb.innerHTML = A.map(a => `<tr><th>${a.n}</th><td>${a[k][0]} ${a.u}</td><td>${a[k][1]}</td><td>${pc(share(a))}</td></tr>`).join('');
    centre();
    const on = A.filter(a => share(a) >= 1).length; swap(sub, `${k === 'w' ? 'This week' : 'This month'}, ${on} of ${A.length} on target`);
  }
  // the middle shows the overall share, or the agent in focus
  function centre(){ const t = focus < 0 ? overall() : share(A[focus]), from = shown; shown = t; tween(from, t, 600, x => cb.textContent = pc(x)); swap(cl, focus < 0 ? 'of targets' : A[focus].n.replace(' agent', '')); }
  const hl = i => { if (focus === i) return; focus = i; ring.classList.toggle('hov', i >= 0); G.forEach((g, j) => g.classList.toggle('on', j === i)); rows.forEach((x, j) => x.classList.toggle('on', j === i)); centre(); };
  [...G, ...rows].forEach(el => { const i = +el.dataset.i; c.on(el, 'pointerenter', e => { if (e.pointerType !== 'touch') hl(i); }); c.on(el, 'pointerleave', () => { if (!lg.contains(document.activeElement)) hl(-1); }); });
  rows.forEach((x, i) => { c.on(x, 'focus', () => hl(i)); c.on(x, 'blur', () => hl(-1)); });
  seg($('.rgx-sg', r), v => { k = v; paint(); });
  cb.textContent = '0%';
  c.later(260, paint);
  // the preview's story: read the agent furthest behind, then the one past its target, see the month, then back to the week
  if (r === c.st) c.story(async s => {
    await s.beat(5);
    await s.point(rows[2]); hl(2); await s.beat(3);
    await s.point(rows[0]); hl(0); await s.beat(3);
    hl(-1); await s.tap($('.rgx-sg [data-v="m"]', r)); await s.beat(4);
    await s.tap($('.rgx-sg [data-v="w"]', r)); s.away(); await s.beat(3);
  });
});
06

Questions

What people ask about it.

How many rings fit?
Two to five, and three read best. The rings share one band between the edge and the readout in the middle, so three are 14 units thick, four are about 10 and five are 7; more than five would be too thin to read.
How is the overall share worked out?
It is the average of each goal's share, so a goal past its target can lift the average above another's shortfall.
Can I leave out the range switch?
Yes. Leave out ranges, or onRangeChange, and the header shows the title and its line only.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Bar chartWeekly agent runs, split by outcome, with a range switch.
Donut chartWhere the agent's time went, by kind of task.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Score breakdownA score out of 100 whose gauge is split by what goes into it, with the weakest part flagged and a fix to try.
Eval radarThis run against the baseline on six eval scores, with the weak ones flagged.
All 13 in Charts and tables →