Healthcare

Insurance Eligibility

An eligibility-check result card with plan identity, a coverage status pill, a copay/coinsurance grid, and deductible and out-of-pocket progress bars that fill on first view.

Install

npx shadcn@latest add @paragon/insurance-eligibility

insurance-eligibility.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";

export interface InsuranceEligibilityProps extends React.ComponentProps<"div"> {
  plan?: string;
  memberId?: string;
  status?: "active" | "inactive" | "pending";
  copay?: number;
  coinsurance?: number;
  /** Amount of the deductible already met, in dollars. */
  deductibleMet?: number;
  /** Total deductible for the plan year, in dollars. */
  deductibleTotal?: number;
  /** Out-of-pocket amount met, in dollars. */
  oopMet?: number;
  oopMax?: number;
  static?: boolean;
}

const usd = (n: number) =>
  n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

const statusMap: Record<
  NonNullable<InsuranceEligibilityProps["status"]>,
  { label: string; dot: string; text: string }
> = {
  active: { label: "Active", dot: "bg-success", text: "text-success" },
  pending: { label: "Pending", dot: "bg-warning", text: "text-warning" },
  inactive: { label: "Inactive", dot: "bg-destructive", text: "text-destructive" },
};

function Progress({
  met,
  total,
  animate,
  label,
}: {
  met: number;
  total: number;
  animate: boolean;
  label: string;
}) {
  const pct = total > 0 ? Math.min(100, (met / total) * 100) : 0;
  return (
    <div>
      <div className="flex items-baseline justify-between text-xs">
        <span className="text-muted-foreground">{label}</span>
        <span className="font-medium tabular-nums">
          {usd(met)} <span className="text-muted-foreground">/ {usd(total)}</span>
        </span>
      </div>
      <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-secondary">
        <div
          className="h-full w-full origin-left rounded-full bg-primary"
          style={{
            transform: `scaleX(${pct / 100})`,
            transition: animate ? "transform 700ms var(--ease-out)" : undefined,
          }}
        />
      </div>
    </div>
  );
}

/**
 * An eligibility-check result: plan and member identity, a status pill, a
 * cost-share grid (copay / coinsurance), and deductible + out-of-pocket
 * progress bars that grow on first view. Reduced motion renders the bars at
 * their final width immediately.
 */
export function InsuranceEligibility({
  plan = "Blue Shield PPO — Gold 80",
  memberId = "XZP 884 210 013",
  status = "active",
  copay = 25,
  coinsurance = 20,
  deductibleMet = 640,
  deductibleTotal = 1500,
  oopMet = 1820,
  oopMax = 6000,
  static: isStatic = false,
  className,
  ...props
}: InsuranceEligibilityProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const s = statusMap[status];
  const [grow, setGrow] = React.useState(false);

  React.useEffect(() => {
    if (isStatic || reduced) {
      setGrow(true);
      return;
    }
    if (inView) {
      const id = requestAnimationFrame(() => setGrow(true));
      return () => cancelAnimationFrame(id);
    }
  }, [inView, isStatic, reduced]);

  const animate = !isStatic && !reduced;

  return (
    <div
      ref={ref}
      data-slot="insurance-eligibility"
      className={cn(
        "w-full max-w-sm rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-start gap-2.5">
        <span
          aria-hidden
          className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-secondary-foreground"
        >
          <ShieldCheck className="size-4" />
        </span>
        <div className="min-w-0 flex-1">
          <p className="truncate text-sm font-medium">{plan}</p>
          <p className="truncate text-xs text-muted-foreground tabular-nums">
            Member {memberId}
          </p>
        </div>
        <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-secondary px-2 py-0.5 text-xs font-medium">
          <span aria-hidden className={cn("size-1.5 rounded-full", s.dot)} />
          <span className={s.text}>{s.label}</span>
        </span>
      </div>

      <div className="mt-4 grid grid-cols-2 gap-2">
        <div className="rounded-lg bg-secondary/60 px-3 py-2.5">
          <p className="text-lg font-semibold tabular-nums">{usd(copay)}</p>
          <p className="text-[11px] text-muted-foreground">Office visit copay</p>
        </div>
        <div className="rounded-lg bg-secondary/60 px-3 py-2.5">
          <p className="text-lg font-semibold tabular-nums">{coinsurance}%</p>
          <p className="text-[11px] text-muted-foreground">Coinsurance</p>
        </div>
      </div>

      <div className="mt-4 flex flex-col gap-3.5">
        <Progress
          label="Deductible met"
          met={grow ? deductibleMet : 0}
          total={deductibleTotal}
          animate={animate}
        />
        <Progress
          label="Out-of-pocket met"
          met={grow ? oopMet : 0}
          total={oopMax}
          animate={animate}
        />
      </div>
    </div>
  );
}