Healthcare

Dosage Calculator

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.

Install

npx shadcn@latest add @paragon/dosage-calculator

dosage-calculator.tsx

"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>
  );
}