Clinical Risk
HealthcareClinician-review risk summary: the gauge arc sweeps to the score while the number counts up, factor bars grow protective-left/adverse-right, missing data widens stated uncertainty.
10-year cardiovascular risk
Moderate risk
Decision support for clinician review — not a diagnosis.
- LDL trending down158 → 101 mg/dL over 9 months
- HbA1c 6.1%Pre-diabetic range
- Blood pressure 138/88Stage-1 hypertension range
- Non-smoker
- Family historyParent with early CAD
Missing data: Recent lipoprotein(a), Resting ECG — the score has wider uncertainty until these are available.
Risk is moderate and modifiable: blood-pressure control and continued LDL reduction have the largest effect. Re-score after the missing tests.
Install
npx shadcn@latest add https://labs.duku.design/r/clinical-risk.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Clinical Risk component (get_component "clinical-risk", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Mount | Arc sweeps to the score over 1.1s in the risk-band color while the score counts up; factor bars stagger in from the center |
| Read the copy | Explicitly decision-support: missing data, modifiable factors, escalation to clinician review |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title / score | string / number | — | 0–100; bands map to risk-low/medium/high tokens. DEMO_CARDIO_RISK ships. |
| factors | RiskFactor[] | — | label, weight (−1..1, positive = adverse), detail. |
| missingData / recommendation | string[] / string | — | Uncertainty note and next step. |
| onEscalate | () => void | — | Send-to-clinician action. |
Source — registry/default/health/clinical-risk.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import { Button } from "@/registry/default/ui/button";
/* ------------------------------------------------------------------ */
/* Model */
/* ------------------------------------------------------------------ */
export interface RiskFactor {
label: string;
/** Contribution weight, -1..1. Positive raises risk. */
weight: number;
detail?: string;
}
export interface ClinicalRiskProps
extends React.HTMLAttributes<HTMLDivElement> {
title: string;
/** Risk score 0..100. */
score: number;
factors: RiskFactor[];
missingData?: string[];
recommendation?: string;
onEscalate?: () => void;
ref?: React.Ref<HTMLDivElement>;
}
export const DEMO_CARDIO_RISK: Pick<
ClinicalRiskProps,
"title" | "score" | "factors" | "missingData" | "recommendation"
> = {
title: "10-year cardiovascular risk",
score: 34,
factors: [
{ label: "LDL trending down", weight: -0.35, detail: "158 → 101 mg/dL over 9 months" },
{ label: "HbA1c 6.1%", weight: 0.4, detail: "Pre-diabetic range" },
{ label: "Blood pressure 138/88", weight: 0.5, detail: "Stage-1 hypertension range" },
{ label: "Non-smoker", weight: -0.3 },
{ label: "Family history", weight: 0.45, detail: "Parent with early CAD" },
],
missingData: ["Recent lipoprotein(a)", "Resting ECG"],
recommendation:
"Risk is moderate and modifiable: blood-pressure control and continued LDL reduction have the largest effect. Re-score after the missing tests.",
};
const riskBand = (score: number) =>
score < 25
? { label: "Low", cls: "text-risk-low", stroke: "stroke-risk-low" }
: score < 55
? { label: "Moderate", cls: "text-risk-medium", stroke: "stroke-risk-medium" }
: { label: "High", cls: "text-risk-high", stroke: "stroke-risk-high" };
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
/**
* A clinician-review risk summary: the gauge arc sweeps to the score,
* factor bars grow from the center (protective left, adverse right) and
* the copy stays explicitly decision-support, not diagnosis.
*/
export function ClinicalRisk({
title,
score,
factors,
missingData = [],
recommendation,
onEscalate,
className,
ref,
...rest
}: ClinicalRiskProps) {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const arcRef = React.useRef<SVGPathElement>(null);
const scoreRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
const band = riskBand(score);
const maxW = Math.max(...factors.map((f) => Math.abs(f.weight)), 0.01);
useGSAP(
() => {
const root = rootRef.current;
if (!root) return;
const bars = gsap.utils.toArray<HTMLElement>(root.querySelectorAll("[data-factor]"));
if (reduced) {
if (arcRef.current) gsap.set(arcRef.current, { strokeDashoffset: 1 - score / 100 });
if (scoreRef.current) scoreRef.current.textContent = String(score);
gsap.set(bars, { scaleX: 1, opacity: 1 });
return;
}
if (arcRef.current)
gsap.fromTo(
arcRef.current,
{ strokeDashoffset: 1 },
{ strokeDashoffset: 1 - score / 100, duration: 1.1, ease: "power3.inOut" }
);
if (scoreRef.current) {
const counter = { v: 0 };
gsap.to(counter, {
v: score,
duration: 1.1,
ease: "power3.inOut",
onUpdate: () => {
if (scoreRef.current) scoreRef.current.textContent = String(Math.round(counter.v));
},
});
}
gsap.fromTo(
bars,
{ scaleX: 0, opacity: 0 },
{ scaleX: 1, opacity: 1, duration: 0.5, ease: "power3.out", stagger: 0.08, delay: 0.5 }
);
},
{ dependencies: [score, reduced], scope: rootRef }
);
return (
<div
ref={rootRef}
data-slot="clinical-risk"
className={cn(
"w-full max-w-md rounded-2xl border border-border bg-card p-4",
className
)}
{...rest}
>
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{/* Gauge */}
<div className="mt-2 flex items-center gap-4">
<div
className="relative shrink-0"
role="meter"
aria-label={`${title}: ${score} of 100, ${band.label}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={score}
>
<svg viewBox="0 0 100 60" className="w-32">
<path
d="M10,55 A45,45 0 0 1 90,55"
fill="none"
strokeWidth={8}
strokeLinecap="round"
className="stroke-muted"
/>
<path
ref={arcRef}
d="M10,55 A45,45 0 0 1 90,55"
fill="none"
strokeWidth={8}
strokeLinecap="round"
pathLength={1}
strokeDasharray={1}
strokeDashoffset={1}
className={band.stroke}
/>
</svg>
<div className="absolute inset-x-0 bottom-0 text-center">
<span ref={scoreRef} className="text-2xl font-semibold tabular-nums text-foreground">
0
</span>
<span className="text-xs text-muted-foreground">/100</span>
</div>
</div>
<div>
<p className={cn("text-sm font-semibold", band.cls)}>{band.label} risk</p>
<p className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
Decision support for clinician review — not a diagnosis.
</p>
</div>
</div>
{/* Factors: protective ← | → adverse */}
<div className="mt-4">
<div className="flex justify-between text-[10px] uppercase tracking-wide text-muted-foreground">
<span>Protective</span>
<span>Adverse</span>
</div>
<ul className="mt-1.5 flex flex-col gap-2">
{factors.map((f) => {
const adverse = f.weight > 0;
const width = (Math.abs(f.weight) / maxW) * 50;
return (
<li key={f.label} className="text-xs">
<div className="relative h-2.5 overflow-hidden rounded-full bg-muted/70">
<span aria-hidden="true" className="absolute inset-y-0 left-1/2 w-px bg-border" />
<span
data-factor
className={cn(
"absolute inset-y-0 rounded-full",
adverse
? "left-1/2 origin-left bg-risk-high/80"
: "right-1/2 origin-right bg-risk-low/80"
)}
style={{ width: `${width}%` }}
/>
</div>
<div className="mt-0.5 flex justify-between gap-2">
<span className="font-medium text-foreground">{f.label}</span>
{f.detail ? (
<span className="text-right text-muted-foreground">{f.detail}</span>
) : null}
</div>
</li>
);
})}
</ul>
</div>
{missingData.length > 0 ? (
<p className="mt-3 rounded-lg bg-muted/60 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="font-semibold text-foreground">Missing data:</span>{" "}
{missingData.join(", ")} — the score has wider uncertainty until these
are available.
</p>
) : null}
{recommendation ? (
<p className="mt-2 text-[12px] leading-5 text-foreground">{recommendation}</p>
) : null}
<div className="mt-3 flex gap-2">
<Button size="sm" variant="outline" className="flex-1" onClick={onEscalate}>
Send to clinician review
</Button>
</div>
</div>
);
}