A weight-based dose calculator with live kg/lb conversion that computes milligrams, liquid volume, and daily total per dose, raising a warning when the per-dose ceiling is exceeded.
npx shadcn@latest add @paragon/dosage-calculator"use client";
import * as React from "react";
import { Calculator, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
export interface DosageCalculatorProps extends React.ComponentProps<"div"> {
drug?: string;
/** Dose per kg per administration. */
doseMgPerKg?: number;
/** Concentration of the liquid formulation, mg per mL. */
concentrationMgPerMl?: number;
/** Doses per day, for the daily-total note. */
frequencyPerDay?: number;
/** Hard ceiling per dose, in mg. Exceeding it raises a warning. */
maxMgPerDose?: number;
/** Starting patient weight. */
defaultWeight?: number;
defaultUnit?: "kg" | "lb";
}
const round = (n: number, d = 2) => {
const f = 10 ** d;
return Math.round(n * f) / f;
};
/**
* A weight-based dosage calculator. Weight entry converts between kg and lb
* live; the per-dose milligrams, liquid volume, and daily total recompute as
* you type. Exceeding the per-dose ceiling raises an inline warning banner.
* Pure computation — no motion, only short color transitions on the warning.
*/
export function DosageCalculator({
drug = "Amoxicillin suspension",
doseMgPerKg = 25,
concentrationMgPerMl = 50,
frequencyPerDay = 2,
maxMgPerDose = 1000,
defaultWeight = 18,
defaultUnit = "kg",
className,
...props
}: DosageCalculatorProps) {
const [weight, setWeight] = React.useState(defaultWeight);
const [unit, setUnit] = React.useState<"kg" | "lb">(defaultUnit);
const weightKg = unit === "kg" ? weight : weight / 2.2046;
const rawDose = weightKg * doseMgPerKg;
const capped = Math.min(rawDose, maxMgPerDose);
const overMax = rawDose > maxMgPerDose;
const volumeMl = capped / concentrationMgPerMl;
const dailyTotal = capped * frequencyPerDay;
const setUnitConvert = (next: "kg" | "lb") => {
if (next === unit) return;
setWeight((w) =>
round(next === "lb" ? w * 2.2046 : w / 2.2046, 1),
);
setUnit(next);
};
return (
<div
data-slot="dosage-calculator"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-2">
<Calculator aria-hidden className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{drug}</h3>
</div>
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
{doseMgPerKg} mg/kg · {concentrationMgPerMl} mg/mL · {frequencyPerDay}×/day
</p>
<div className="mt-4">
<label
htmlFor="dosage-weight"
className="text-xs font-medium text-muted-foreground"
>
Patient weight
</label>
<div className="mt-1 flex gap-2">
<input
id="dosage-weight"
type="number"
min={0}
step="0.1"
inputMode="decimal"
value={weight}
onChange={(e) => setWeight(Math.max(0, Number(e.target.value) || 0))}
className="h-9 min-w-0 flex-1 rounded-lg border border-input bg-card px-3 text-sm tabular-nums transition-[border-color,box-shadow] duration-150 ease-(--ease-out) focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 outline-none"
/>
<div
role="group"
aria-label="Weight unit"
className="flex shrink-0 rounded-lg bg-secondary p-0.5"
>
{(["kg", "lb"] as const).map((u) => (
<button
key={u}
type="button"
aria-pressed={unit === u}
onClick={() => setUnitConvert(u)}
className={cn(
"rounded-md px-3 text-xs font-medium transition-[background-color,color] duration-150 ease-(--ease-out) focus-visible:ring-2 focus-visible:ring-ring outline-none",
unit === u
? "bg-card text-foreground shadow-border"
: "text-muted-foreground",
)}
>
{u}
</button>
))}
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-lg bg-secondary/60 px-2.5 py-2.5 text-center">
<p className="text-base font-semibold tabular-nums">{round(capped)}</p>
<p className="text-[11px] text-muted-foreground">mg / dose</p>
</div>
<div className="rounded-lg bg-secondary/60 px-2.5 py-2.5 text-center">
<p className="text-base font-semibold tabular-nums">{round(volumeMl, 1)}</p>
<p className="text-[11px] text-muted-foreground">mL / dose</p>
</div>
<div className="rounded-lg bg-secondary/60 px-2.5 py-2.5 text-center">
<p className="text-base font-semibold tabular-nums">{round(dailyTotal)}</p>
<p className="text-[11px] text-muted-foreground">mg / day</p>
</div>
</div>
<div
className={cn(
"mt-3 flex items-start gap-2 rounded-lg px-3 py-2 text-xs transition-colors duration-150",
overMax
? "bg-warning/12 text-foreground"
: "bg-success/[0.08] text-foreground",
)}
>
<AlertTriangle
aria-hidden
className={cn(
"mt-px size-3.5 shrink-0",
overMax ? "text-warning" : "text-success",
)}
/>
{overMax ? (
<span>
Calculated {round(rawDose)} mg exceeds the {maxMgPerDose} mg per-dose
ceiling — capped to max.
</span>
) : (
<span className="tabular-nums">
Within range for {round(weightKg, 1)} kg.
</span>
)}
</div>
</div>
);
}