A searchable directory of provider cards showing specialty, star rating, location, and next availability; matching cards spring in and out as the filter narrows.
npx shadcn@latest add @paragon/provider-directory"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>
);
}