Healthcare

Telehealth Waiting Room

A video-visit waiting card with a pulsing presence ring, a live countdown, a queue position that steps down, and a join button that unlocks when you reach the front.

Install

npx shadcn@latest add @paragon/telehealth-waiting-room

telehealth-waiting-room.tsx

"use client";

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

export interface TelehealthWaitingRoomProps
  extends Omit<React.ComponentProps<"div">, "onJoin"> {
  provider?: string;
  specialty?: string;
  /** Starting position in the queue; ticks down to 0 over time. */
  queuePosition?: number;
  /** Estimated seconds until the provider joins. */
  estimatedSeconds?: number;
  onJoin?: () => void;
  /** Freezes the countdown and pulse. */
  static?: boolean;
}

function fmt(total: number) {
  const m = Math.floor(total / 60);
  const s = total % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
}

/**
 * A telehealth waiting card: a pulsing presence ring, a live countdown, and a
 * queue position that steps down as the wait elapses. The timer runs in an
 * effect (never Date.now in render) and self-clears; reduced motion freezes
 * the pulse. Join enables when the queue reaches the front.
 */
export function TelehealthWaitingRoom({
  provider = "Dr. Samuel Okafor, MD",
  specialty = "Primary Care Video Visit",
  queuePosition = 3,
  estimatedSeconds = 180,
  onJoin,
  static: isStatic = false,
  className,
  ...props
}: TelehealthWaitingRoomProps) {
  const reduced = useReducedMotion();
  const [remaining, setRemaining] = React.useState(estimatedSeconds);

  React.useEffect(() => {
    if (isStatic) return;
    const id = setInterval(() => {
      setRemaining((r) => (r > 0 ? r - 1 : 0));
    }, 1000);
    return () => clearInterval(id);
  }, [isStatic]);

  const startPos = Math.max(1, queuePosition);
  const progressed = 1 - remaining / Math.max(1, estimatedSeconds);
  const position = Math.max(0, Math.ceil(startPos * (1 - progressed)));
  const ready = position <= 0 || remaining <= 0;
  const pulse = !isStatic && !reduced;

  return (
    <div
      data-slot="telehealth-waiting-room"
      className={cn(
        "w-full max-w-sm overflow-hidden rounded-xl bg-card p-5 text-center text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-telehealth-waiting-room" precedence="paragon">{`
        @keyframes paragon-tele-pulse {
          0% { transform: scale(0.85); opacity: 0.5; }
          70%, 100% { transform: scale(1.5); opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-paragon-tele-pulse] { animation: none !important; }
        }
      `}</style>

      <div className="relative mx-auto flex size-16 items-center justify-center">
        {pulse &&
          [0, 1].map((i) => (
            <span
              key={i}
              aria-hidden
              data-paragon-tele-pulse
              className="absolute inset-0 rounded-full bg-primary/25"
              style={{
                animation: "paragon-tele-pulse 2.4s var(--ease-out) infinite",
                animationDelay: `${i * 1.2}s`,
              }}
            />
          ))}
        <span
          aria-hidden
          className="relative flex size-16 items-center justify-center rounded-full bg-secondary text-secondary-foreground"
        >
          <Video className="size-6" />
        </span>
      </div>

      <h3 className="mt-4 text-base font-semibold">{provider}</h3>
      <p className="mt-0.5 text-xs text-muted-foreground">{specialty}</p>

      <div className="mt-4 flex items-stretch justify-center gap-2">
        <div className="flex-1 rounded-lg bg-secondary/60 py-2.5">
          <p className="text-lg font-semibold tabular-nums">
            {ready ? "0" : position}
          </p>
          <p className="text-[11px] text-muted-foreground">
            {ready ? "You're next" : "ahead of you"}
          </p>
        </div>
        <div className="flex-1 rounded-lg bg-secondary/60 py-2.5">
          <p className="text-lg font-semibold tabular-nums">{fmt(remaining)}</p>
          <p className="text-[11px] text-muted-foreground">est. wait</p>
        </div>
      </div>

      <button
        type="button"
        disabled={!ready}
        onClick={onJoin}
        className={cn(
          "mt-4 inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg text-sm font-medium transition-[background-color,color,scale] duration-150 ease-(--ease-out) focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card outline-none disabled:cursor-not-allowed disabled:opacity-50",
          ready
            ? "bg-success text-success-foreground active:scale-[0.98]"
            : "bg-secondary text-secondary-foreground",
        )}
      >
        <Video className="size-4" />
        {ready ? "Join visit" : "Waiting for provider…"}
      </button>

      <div className="mt-3 flex items-center justify-center gap-4 text-[11px] text-muted-foreground">
        <span className="inline-flex items-center gap-1">
          <Mic className="size-3" /> Mic ready
        </span>
        <span className="inline-flex items-center gap-1">
          <Video className="size-3" /> Camera ready
        </span>
        <span className="inline-flex items-center gap-1 text-success">
          <Wifi className="size-3" /> Strong
        </span>
      </div>
    </div>
  );
}