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.
npx shadcn@latest add @paragon/telehealth-waiting-room"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>
);
}