Charts and tablesHealth score
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callHealth 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add health-scoreReact
npx designme add health-score --reactThe 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.
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.
- A half-circle gauge that fills to the score, which is the average of the checks unless you pass
score. - Colours by score: green from 90, the accent colour from 80, red below 80.
- A status line such as "Good, 2 checks need work" worked out from checks under 90, or your own
status. - Each check is a row with a bar and its score; clicking it opens a line of
detail. - A check with
fixablegets a Fix button whenonFixis set; ifonFixreturns a promise the button spins until it settles. - Score changes count to the new number, and the open rows can be controlled with
openandonOpenChange.
- The gauge has role="meter" with aria-valuemin 0, aria-valuemax 100, aria-valuenow set to the score and the title as its label.
- Each check row is a button with aria-expanded, and its closed detail is inert and aria-hidden.
- The Fix button sets aria-busy while the fix runs.
- Number count-ups finish at once when the user prefers reduced motion.
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>;
}
Props
Everything HealthScore takes.
| Prop | Type | What it does |
|---|---|---|
checksrequired | HealthScoreCheck[] | |
title | string | Beside the gauge, like "Design system health" |
score | number | The score in the gauge. Defaults to the average of the checks |
status | string | The line under the title. Defaults to "Good, 2 checks need work" from the checks |
open | string[] | The open checks, by key. Leave it out to let each row toggle itself |
defaultOpen | string[] | |
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 |
className | string |
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);
});
Questions
What people ask about it.
- How do I let the agent fix a check?
- Set
fixableon the check and passonFix. Return a promise that resolves once the agent is done, then update that check'sscoreanddetailinchecks. You may also want to clearfixableso the button goes away. - How is the overall score worked out?
- By default it is the rounded average of all check scores. Pass
scoreto show your own number instead, for example a weighted score. - Can I open a check from outside the component?
- Yes. Control
openwith an array of check keys. A check's key is itskey, or itslabelwhen no key is set.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.