Skip to content
Star on GitHub
Charts and tablesHealth score
Style
Accent
Sound

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

Expert design and dev support for startups and enterprises.

Book a call

Health score

A design system score made of a few checks you can open. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add health-score
0of 100
Design system healthGood, 2 checks need work
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add health-score

React

$npx designme add health-score --react

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.

02

About

What it is for and what it does.

A gauge with one score out of 100 for a design system, plus the checks that make it up, such as token usage, contrast and component reuse. Each check opens to show why it scored as it did, and a check marked fixable gets a Fix button that hands the job to the agent. When the fix lands, the check and the overall score update.

  • When an agent audits a design system or codebase and you want one number with the reasons behind it.
  • When some issues can be fixed by the agent with a click.
  • On a dashboard card that tracks quality over time.
03

Usage

Import it and drive it from your own state.

Import

import { HealthScore } from '@/components/designme/health-score/HealthScore';

Example

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

'use client';
// The animated preview from designme.dev, rebuilt from the React component: open a check that needs work and let the agent fix it.
import { useEffect, useRef, useState } from 'react';
import { HealthScore, type HealthScoreCheck } from './HealthScore';

const CHECKS: HealthScoreCheck[] = [
  { label: 'Token usage', score: 96, detail: 'All colours come from the library' },
  { label: 'Contrast', score: 88, detail: '2 text pairs below AA on the pricing page', fixable: true },
  { label: 'Component reuse', score: 74, detail: '11 detached instances in Checkout', fixable: true },
  { label: 'Naming', score: 91, detail: 'Layer names follow the convention' }
];
const FIXED: Record<string, Partial<HealthScoreCheck>> = {
  Contrast: { score: 97, detail: 'Fixed. Both pairs now pass AA', fixable: false },
  'Component reuse': { score: 93, detail: 'Fixed. Reattached 11 instances', fixable: false }
};

export default function HealthScoreDemo() {
  const [checks, setChecks] = useState(CHECKS);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  useEffect(() => () => timers.current.forEach(clearTimeout), []);
  // the agent takes a moment to fix a check, then the score moves
  const fix = (check: HealthScoreCheck) => new Promise<void>(done => {
    timers.current.push(setTimeout(() => { setChecks(cs => cs.map(c => c.label === check.label ? { ...c, ...FIXED[c.label] } : c)); done(); }, 900));
  });
  return <div style={{ width: '100%' }}><HealthScore checks={checks} onFix={fix} /></div>;
}
04

Props

Everything HealthScore takes.

PropTypeWhat it does
checksrequiredHealthScoreCheck[]
titlestringBeside the gauge, like "Design system health"
scorenumberThe score in the gauge. Defaults to the average of the checks
statusstringThe line under the title. Defaults to "Good, 2 checks need work" from the checks
openstring[]The open checks, by key. Leave it out to let each row toggle itself
defaultOpenstring[]
onOpenChange(open: string[]) => void
onFix(check: HealthScoreCheck, index: number) => void | Promise<unknown>Called by a check's Fix button. Return a promise and the button spins until it settles; then update the check's score and detail
classNamestring
05

Code

The full source, as the CLI installs it.

HealthScore.tsxReact · 105 lines
'use client';
import { useEffect, useState } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Fold, Spin, Swap } from '../lib/motion';
import '../designme.css';
import './health-score.css';

export interface HealthScoreCheck {
  /** Identifies the check. Defaults to its label */
  key?: string;
  /** Like "Contrast" */
  label: string;
  /** 0 to 100. From 90 it is green, from 80 the accent, below that red */
  score: number;
  /** One line under the check when it is open, like "2 text pairs below AA on the pricing page" */
  detail: string;
  /** Adds a Fix button to the open check */
  fixable?: boolean;
}

export interface HealthScoreProps {
  checks: HealthScoreCheck[];
  /** Beside the gauge, like "Design system health" */
  title?: string;
  /** The score in the gauge. Defaults to the average of the checks */
  score?: number;
  /** The line under the title. Defaults to "Good, 2 checks need work" from the checks */
  status?: string;
  /** The open checks, by key. Leave it out to let each row toggle itself */
  open?: string[];
  defaultOpen?: string[];
  onOpenChange?: (open: string[]) => void;
  /** Called by a check's Fix button. Return a promise and the button spins until it settles; then update the check's score and detail */
  onFix?: (check: HealthScoreCheck, index: number) => void | Promise<unknown>;
  className?: string;
}

const ARC = 157;
const tone = (v: number) => v >= 90 ? 'var(--dm-hs-ok)' : v >= 80 ? 'var(--dm-accent)' : 'var(--dm-hs-bad)';
const keyOf = (c: HealthScoreCheck) => c.key ?? c.label;

function Check({ check, index, filled, open, onToggle, onFix }: { check: HealthScoreCheck; index: number; filled: boolean; open: boolean; onToggle: () => void; onFix?: HealthScoreProps['onFix'] }) {
  const shown = useTween(check.score, 700);
  const [busy, setBusy] = useState(false);
  const fix = async () => {
    if (busy) return;
    setBusy(true);
    try { await onFix?.(check, index); } finally { setBusy(false); }
  };
  return (
    <div className={cx('dm-hsx-c', open && 'dm-open')}>
      <button type="button" className="dm-hsx-ch" aria-expanded={open} onClick={onToggle}>
        <span className="dm-t">{check.label}</span>
        <span className="dm-bar"><i style={filled ? { width: check.score + '%', background: tone(check.score) } : { width: 0 }} /></span>
        <span className="dm-v dm-num">{Math.round(shown)}</span>
        <Icon name="chev-r" size="xs" />
      </button>
      <Fold open={open}>
        <div className="dm-hsx-d">
          <Swap>{check.detail}</Swap>
          {check.fixable && onFix && <button type="button" className="dm-pill dm-soft dm-sm" onClick={fix} aria-busy={busy}>{busy ? <Spin /> : <><Icon name="wand" size="xs" />Fix</>}</button>}
        </div>
      </Fold>
    </div>
  );
}

/** One score for a design system, with the checks behind it. Open a check to see why, and fix it from there. */
export function HealthScore({ checks, title = 'Design system health', score, status, open, defaultOpen = [], onOpenChange, onFix, className }: HealthScoreProps) {
  const [opened, setOpened] = useControllable(open, defaultOpen, onOpenChange);
  // the gauge and the bars fill in a moment after the card appears, the bars one after another
  const [filled, setFilled] = useState(0);
  useEffect(() => {
    const ts = [setTimeout(() => setFilled(n => Math.max(n, 1)), 200), ...checks.map((_, i) => setTimeout(() => setFilled(n => Math.max(n, i + 2)), 200 + i * 80))];
    return () => ts.forEach(clearTimeout);
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const total = Math.round(score ?? (checks.length ? checks.reduce((a, c) => a + c.score, 0) / checks.length : 0));
  const drawn = filled > 0;
  const shown = useTween(drawn ? total : 0, 1000, 0);
  const weak = checks.filter(c => c.score < 90).length;
  const line = status ?? (weak ? `${total >= 85 ? 'Good' : 'Fair'}, ${weak} ${weak > 1 ? 'checks need' : 'check needs'} work` : 'Excellent, all checks pass');
  const toggle = (k: string) => setOpened(opened.includes(k) ? opened.filter(x => x !== k) : [...opened, k]);

  return (
    <div data-dm="" className={cx('dm-hsx dm-panel', className)}>
      <div className="dm-hsx-top">
        <div className="dm-hsx-g" role="meter" aria-label={title} aria-valuemin={0} aria-valuemax={100} aria-valuenow={total}>
          <svg viewBox="0 0 120 70" aria-hidden="true">
            <path d="M10 64a50 50 0 0 1 100 0" className="dm-tr" />
            <path d="M10 64a50 50 0 0 1 100 0" className="dm-fl" style={drawn ? { strokeDashoffset: ARC * (1 - total / 100), stroke: tone(total) } : undefined} />
          </svg>
          <div className="dm-hsx-v"><b className="dm-num">{Math.round(shown)}</b><span className="dm-muted">of 100</span></div>
        </div>
        <div className="dm-hsx-s"><b>{title}</b><Swap className="dm-muted dm-hsx-st">{line}</Swap></div>
      </div>
      <div className="dm-hsx-list">
        {checks.map((c, i) => <Check key={keyOf(c)} check={c} index={i} filled={filled > i + 1} open={opened.includes(keyOf(c))} onToggle={() => toggle(keyOf(c))} onFix={onFix} />)}
      </div>
    </div>
  );
}
health-score.cssReact · 31 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Health score ---------- */
.dm-hsx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);--dm-hs-ok:#16b558;--dm-hs-bad:#e5383b}
:root[data-theme="dark"] .dm-hsx{--dm-hs-ok:#34d27a;--dm-hs-bad:#ff5c5f}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-hsx{--dm-hs-ok:#34d27a;--dm-hs-bad:#ff5c5f}}
.dm-hsx-top{display:flex;align-items:center;gap:14px}
.dm-hsx-g{position:relative;width:120px;height:72px;flex:none}
.dm-hsx-g svg{width:100%;height:100%;overflow:visible}
.dm-hsx-g path{fill:none;stroke-width:9;stroke-linecap:round}
.dm-hsx-g .dm-tr{stroke:var(--dm-line)}
.dm-hsx-g .dm-fl{stroke:var(--dm-accent-mark,var(--dm-accent));stroke-dasharray:157;stroke-dashoffset:157;transition:stroke-dashoffset 1.2s var(--dm-ease),stroke .4s}
.dm-hsx-v{position:absolute;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;line-height:1.1}
.dm-hsx-v b{font-size:14px;font-weight:500}
.dm-hsx-v span{font-size:11px}
.dm-hsx-s{display:flex;flex-direction:column;font-size:13.5px}
.dm-hsx-s b{font-weight:500}
.dm-hsx-st{font-size:12.5px}
.dm-hsx-list{display:flex;flex-direction:column;gap:2px}
.dm-hsx-c{border-radius:10px;transition:background .2s}
.dm-hsx-c.dm-open{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-hsx-c:hover{background:var(--dm-row)}}
.dm-hsx-ch{display:flex;align-items:center;gap:10px;width:100%;padding:var(--dm-s-2);font-size:13px;text-align:left}
.dm-hsx-ch .dm-t{flex:1}
.dm-hsx-ch .dm-bar{width:70px;height:5px;border-radius:3px;background:var(--dm-line);overflow:hidden}
.dm-hsx-ch .dm-bar i{display:block;height:100%;border-radius:3px;transition:width .8s var(--dm-ease),background .4s}
.dm-hsx-ch .dm-v{width:26px;text-align:right;font-size:12px;color:var(--dm-fg2)}
.dm-hsx-ch>svg{color:var(--dm-muted);transition:transform .35s var(--dm-ease)}
.dm-hsx-c.dm-open .dm-hsx-ch>svg{transform:rotate(90deg)}
.dm-hsx-d{padding:0 var(--dm-s-2) 10px var(--dm-s-2);font-size:12.5px;color:var(--dm-fg2);display:flex;align-items:center;gap:10px;line-height:1.45}
.dm-hsx-d span{flex:1}
health-score.htmlHTML · 12 lines
<div class="card" data-c="health-score">
  <div class="stage-in">
    <div class="hsx panel">
      <div class="hsx-top">
        <div class="hsx-g"><svg viewBox="0 0 120 70"><path d="M10 64a50 50 0 0 1 100 0" class="tr"/><path d="M10 64a50 50 0 0 1 100 0" class="fl"/></svg><div class="hsx-v"><b class="num">0</b><span class="muted">of 100</span></div></div>
        <div class="hsx-s"><b>Design system health</b><span class="muted hsx-st">Good, 2 checks need work</span></div>
      </div>
      <div class="hsx-list"></div>
    </div>
  </div>
</div>
health-score.cssHTML · 30 lines
/* ---------- Health score ---------- */
.hsx{padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3);--hs-ok:#16b558;--hs-bad:#e5383b}
:root[data-theme="dark"] .hsx{--hs-ok:#34d27a;--hs-bad:#ff5c5f}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .hsx{--hs-ok:#34d27a;--hs-bad:#ff5c5f}}
.hsx-top{display:flex;align-items:center;gap:14px}
.hsx-g{position:relative;width:120px;height:72px;flex:none}
.hsx-g svg{width:100%;height:100%;overflow:visible}
.hsx-g path{fill:none;stroke-width:9;stroke-linecap:round}
.hsx-g .tr{stroke:var(--line)}
.hsx-g .fl{stroke:var(--accent-mark,var(--accent));stroke-dasharray:157;stroke-dashoffset:157;transition:stroke-dashoffset 1.2s var(--ease),stroke .4s}
.hsx-v{position:absolute;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;line-height:1.1}
.hsx-v b{font-size:14px;font-weight:500}
.hsx-v span{font-size:11px}
.hsx-s{display:flex;flex-direction:column;font-size:13.5px}
.hsx-s b{font-weight:500}
.hsx-st{font-size:12.5px}
.hsx-list{display:flex;flex-direction:column;gap:2px}
.hsx-c{border-radius:10px;transition:background .2s}
.hsx-c.open{background:var(--row)}
@media (hover:hover) and (pointer:fine){.hsx-c:hover{background:var(--row)}}
.hsx-ch{display:flex;align-items:center;gap:10px;width:100%;padding:var(--s-2);font-size:13px;text-align:left}
.hsx-ch .t{flex:1}
.hsx-ch .bar{width:70px;height:5px;border-radius:3px;background:var(--line);overflow:hidden}
.hsx-ch .bar i{display:block;height:100%;border-radius:3px;transition:width .8s var(--ease),background .4s}
.hsx-ch .v{width:26px;text-align:right;font-size:12px;color:var(--fg2)}
.hsx-ch>svg{color:var(--muted);transition:transform .35s var(--ease)}
.hsx-c.open .hsx-ch>svg{transform:rotate(90deg)}
.hsx-d{padding:0 var(--s-2) 10px var(--s-2);font-size:12.5px;color:var(--fg2);display:flex;align-items:center;gap:10px;line-height:1.45}
.hsx-d span{flex:1}
health-score.jsHTML · 17 lines
DM.def('health-score', (r, c) => {
  const K = [['Token usage',96,'All colours come from the library'],['Contrast',88,'2 text pairs below AA on the pricing page',1],['Component reuse',74,'11 detached instances in Checkout',1],['Naming',91,'Layer names follow the convention']];
  const list = $('.hsx-list', r), fl = $('.hsx-g .fl', r), vb = $('.hsx-v b', r), st = $('.hsx-st', r);
  let score = 0;
  const col = v => v >= 90 ? 'var(--hs-ok)' : v >= 80 ? 'var(--accent-mark,var(--accent))' : 'var(--hs-bad)';
  function total(){ const t = Math.round(K.reduce((a, k) => a + k[1], 0) / K.length); fl.style.strokeDashoffset = 157 * (1 - t / 100); fl.style.stroke = col(t); tween(score, t, 1000, n => { score = n; vb.textContent = Math.round(n); }); const n = K.filter(k => k[1] < 90).length; swap(st, n ? (t >= 85 ? 'Good' : 'Fair') + ', ' + n + (n > 1 ? ' checks need' : ' check needs') + ' work' : 'Excellent, all checks pass'); }
  list.innerHTML = K.map(k => `<div class="hsx-c"><button class="hsx-ch"><span class="t">${k[0]}</span><span class="bar"><i style="width:0"></i></span><span class="v num">${k[1]}</span>${I('chev-r','xs')}</button><div class="fold shut"><div><div class="hsx-d"><span>${k[2]}</span>${k[3] ? `<button class="pill soft sm">${I('wand','xs')}Fix</button>` : ''}</div></div></div></div>`).join('');
  $$('.hsx-c', list).forEach((el, i) => {
    $('.hsx-ch', el).onclick = () => { const o = !el.classList.contains('open'); el.classList.toggle('open', o); $('.fold', el).classList.toggle('shut', !o); };
    const bar = () => { const b = $('.bar i', el); b.style.width = K[i][1] + '%'; b.style.background = col(K[i][1]); };
    c.later(300 + i * 80, bar);
    const fx = $('.hsx-d .pill', el);
    if (fx) fx.onclick = () => c.run(async () => { fx.innerHTML = '<span class="spin"></span>'; await c.wait(900); K[i][1] = i === 1 ? 97 : 93; K[i][3] = 0; bar(); tween(+$('.v', el).textContent, K[i][1], 700, n => $('.v', el).textContent = Math.round(n)); swap($('.hsx-d span', el), i === 1 ? 'Fixed. Both pairs now pass AA' : 'Fixed. Reattached 11 instances'); fx.remove(); total(); });
  });
  c.later(280, total);
});
06

Questions

What people ask about it.

How do I let the agent fix a check?
Set fixable on the check and pass onFix. Return a promise that resolves once the agent is done, then update that check's score and detail in checks. You may also want to clear fixable so the button goes away.
How is the overall score worked out?
By default it is the rounded average of all check scores. Pass score to show your own number instead, for example a weighted score.
Can I open a check from outside the component?
Yes. Control open with an array of check keys. A check's key is its key, or its label when no key is set.
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.
Activity heatmapWhen the agent and the team were busy, by day and hour.
Stat cardsFour headline numbers with sparklines and a period switch.
Account tableCustomer accounts with health, plan and an agent note per row.