Healthcare

Provider Directory

A searchable directory of provider cards showing specialty, star rating, location, and next availability; matching cards spring in and out as the filter narrows.

Install

npx shadcn@latest add @paragon/provider-directory

provider-directory.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Search, Star, MapPin } from "lucide-react";
import { cn } from "@/lib/utils";

export interface Provider {
  id: string;
  name: string;
  specialty: string;
  rating: number;
  reviews: number;
  location: string;
  nextAvailable: string;
  /** True when accepting new patients. */
  acceptingNew?: boolean;
}

export interface ProviderDirectoryProps extends React.ComponentProps<"div"> {
  providers?: Provider[];
  static?: boolean;
}

const DEFAULT_PROVIDERS: Provider[] = [
  { id: "1", name: "Dr. Priya Nair", specialty: "Family Medicine", rating: 4.9, reviews: 218, location: "Downtown Clinic", nextAvailable: "Tomorrow", acceptingNew: true },
  { id: "2", name: "Dr. Samuel Okafor", specialty: "Cardiology", rating: 4.7, reviews: 164, location: "Heart & Vascular Center", nextAvailable: "Thu, Jun 24", acceptingNew: true },
  { id: "3", name: "Dr. Elena Vásquez", specialty: "Endocrinology", rating: 4.8, reviews: 97, location: "Metro Health Pavilion", nextAvailable: "Mon, Jun 28" },
  { id: "4", name: "Dr. Aisha Rahman", specialty: "Dermatology", rating: 4.6, reviews: 302, location: "Skin & Laser Institute", nextAvailable: "Fri, Jun 25", acceptingNew: true },
  { id: "5", name: "Dr. Marcus Feld", specialty: "Family Medicine", rating: 4.5, reviews: 141, location: "Riverside Family Care", nextAvailable: "Wed, Jun 30" },
];

/**
 * A searchable provider directory. A live filter matches name or specialty;
 * matching cards animate in and out with a spring layout so the list reflows
 * smoothly. Star rating and next-availability read in tabular figures.
 * Reduced motion drops the reflow animation.
 */
export function ProviderDirectory({
  providers = DEFAULT_PROVIDERS,
  static: isStatic = false,
  className,
  ...props
}: ProviderDirectoryProps) {
  const reduced = useReducedMotion();
  const [query, setQuery] = React.useState("");
  const animate = !isStatic && !reduced;

  const q = query.trim().toLowerCase();
  const filtered = q
    ? providers.filter(
        (p) =>
          p.name.toLowerCase().includes(q) ||
          p.specialty.toLowerCase().includes(q),
      )
    : providers;

  return (
    <div
      data-slot="provider-directory"
      className={cn("w-full max-w-md", className)}
      {...props}
    >
      <div className="relative">
        <Search
          aria-hidden
          className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
        />
        <input
          type="search"
          placeholder="Search by name or specialty…"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          className="h-10 w-full rounded-lg border border-input bg-card pr-3 pl-9 text-sm text-card-foreground 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>

      <div className="mt-3 flex flex-col gap-2">
        <AnimatePresence mode="popLayout" initial={false}>
          {filtered.map((p) => (
            <motion.article
              key={p.id}
              layout={animate}
              initial={animate ? { opacity: 0, y: 8 } : false}
              animate={{ opacity: 1, y: 0 }}
              exit={animate ? { opacity: 0, scale: 0.97 } : undefined}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
              className="flex items-start gap-3 rounded-xl bg-card p-3 text-card-foreground shadow-border"
            >
              <span
                aria-hidden
                className="flex size-11 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-secondary-foreground"
              >
                {p.name
                  .replace(/^Dr\.\s*/, "")
                  .split(" ")
                  .map((s) => s[0])
                  .slice(0, 2)
                  .join("")}
              </span>
              <div className="min-w-0 flex-1">
                <div className="flex items-baseline justify-between gap-2">
                  <p className="truncate text-sm font-medium">{p.name}</p>
                  <span className="flex shrink-0 items-center gap-0.5 text-xs">
                    <Star
                      aria-hidden
                      className="size-3 fill-warning text-warning"
                    />
                    <span className="font-medium tabular-nums">
                      {p.rating.toFixed(1)}
                    </span>
                    <span className="text-muted-foreground tabular-nums">
                      ({p.reviews})
                    </span>
                  </span>
                </div>
                <p className="text-xs text-muted-foreground">{p.specialty}</p>
                <p className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground">
                  <MapPin aria-hidden className="size-3" />
                  {p.location}
                </p>
                <div className="mt-2 flex items-center gap-2">
                  {p.acceptingNew && (
                    <span className="rounded bg-success/12 px-1.5 py-0.5 text-[10px] font-medium text-success">
                      Accepting new
                    </span>
                  )}
                  <span className="ml-auto text-[11px] text-muted-foreground tabular-nums">
                    Next: <span className="font-medium text-foreground">{p.nextAvailable}</span>
                  </span>
                </div>
              </div>
            </motion.article>
          ))}
        </AnimatePresence>
        {filtered.length === 0 && (
          <p className="rounded-xl bg-secondary/40 px-3 py-6 text-center text-sm text-muted-foreground">
            No providers match “{query}”.
          </p>
        )}
      </div>
    </div>
  );
}