Browse the library
Components / Cards
Agent card
FreeRealtor contact card with initials avatar, rating, license, languages and call, email and tour actions.
components/facade/agent-card.tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { Calendar, Mail, Phone, Star } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AgentCardProps {
name?: string;
title?: string;
brokerage?: string;
license?: string;
/** Optional headshot URL. Falls back to an initials avatar. */
photoSrc?: string;
rating?: number;
reviewCount?: number;
languages?: string[];
phone?: string;
email?: string;
/** Link or handler for the schedule tour action. */
scheduleHref?: string;
onSchedule?: () => void;
/** Optional stats shown under the header, e.g. homes sold. */
stats?: Array<{ label: string; value: string }>;
/** Show the online status dot on the avatar. */
available?: boolean;
className?: string;
}
function initialsOf(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
function Rating({ value, count }: { value: number; count?: number }) {
const rounded = Math.round(value * 2) / 2;
return (
<div className="flex items-center gap-1.5" role="img" aria-label={`Rated ${value.toFixed(1)} out of 5${count ? ` from ${count} reviews` : ""}`}>
<div className="flex" aria-hidden="true">
{Array.from({ length: 5 }).map((_, i) => {
const fill = Math.max(0, Math.min(1, rounded - i));
return (
<span key={i} className="relative size-3.5">
<Star className="absolute inset-0 size-3.5 text-muted-foreground/30" fill="currentColor" strokeWidth={0} />
<span className="absolute inset-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
<Star className="size-3.5 text-warning" fill="currentColor" strokeWidth={0} />
</span>
</span>
);
})}
</div>
<span className="text-sm font-medium tabular-nums text-foreground">{value.toFixed(1)}</span>
{count !== undefined && <span className="text-sm text-muted-foreground">({count})</span>}
</div>
);
}
export function AgentCard({
name = "Danielle Ortiz",
title = "Realtor",
brokerage = "Summit Peak Realty",
license = "CO FA.100082417",
photoSrc,
rating = 4.9,
reviewCount = 128,
languages = ["English", "Spanish"],
phone = "(303) 555-0148",
email = "danielle@summitpeakrealty.com",
scheduleHref = "#schedule",
onSchedule,
stats,
available = true,
className,
}: AgentCardProps) {
const reduce = useReducedMotion();
const tel = `tel:${phone.replace(/[^0-9+]/g, "")}`;
const btnBase =
"inline-flex h-10 items-center justify-center gap-2 rounded-xl px-3 text-sm font-medium transition-[background-color,border-color,color,opacity] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card";
return (
<motion.article
whileHover={reduce ? undefined : { y: -2 }}
transition={{ type: "spring", stiffness: 400, damping: 34 }}
className={cn(
"group w-full max-w-sm rounded-2xl border border-border bg-card p-5 text-card-foreground shadow-sm transition-[box-shadow,border-color] duration-300 hover:border-foreground/15 hover:shadow-lg hover:shadow-foreground/5",
className,
)}
>
<header className="flex items-start gap-4">
<div className="relative shrink-0">
<div className="rounded-full bg-gradient-to-br from-brand to-brand/30 p-[2px] transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:rotate-6">
<div className="rounded-full bg-card p-[3px]">
{photoSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photoSrc} alt={name} className="size-16 rounded-full object-cover" />
) : (
<div
className="grid size-16 place-items-center rounded-full bg-accent font-display text-xl font-semibold tracking-tight text-accent-foreground"
aria-hidden="true"
>
{initialsOf(name)}
</div>
)}
</div>
</div>
{available && (
<span className="absolute bottom-0.5 right-0.5 size-3.5 rounded-full border-2 border-card bg-success" aria-label="Available now" role="img" />
)}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h3 className="truncate font-display text-lg font-semibold leading-tight tracking-tight">{name}</h3>
<p className="truncate text-sm text-muted-foreground">
{title} ยท {brokerage}
</p>
<div className="mt-1.5">
<Rating value={rating} count={reviewCount} />
</div>
</div>
</header>
{stats && stats.length > 0 && (
<dl className="mt-4 grid grid-cols-3 divide-x divide-border rounded-xl border border-border bg-muted/40">
{stats.slice(0, 3).map((s) => (
<div key={s.label} className="flex flex-col-reverse px-2 py-2.5 text-center">
<dt className="text-[11px] text-muted-foreground">{s.label}</dt>
<dd className="font-display text-base font-semibold tracking-tight tabular-nums">{s.value}</dd>
</div>
))}
</dl>
)}
<div className="mt-4 flex flex-wrap items-center gap-1.5">
{languages.map((l) => (
<span key={l} className="rounded-full border border-border bg-background px-2.5 py-0.5 text-xs text-muted-foreground">
{l}
</span>
))}
<span className="ml-auto font-mono text-[11px] text-muted-foreground">Lic. {license}</span>
</div>
<div className="mt-5 grid grid-cols-2 gap-2">
<a href={tel} className={cn(btnBase, "border border-border bg-background text-foreground hover:bg-muted")}>
<Phone className="size-4" aria-hidden="true" />
Call
</a>
<a href={`mailto:${email}`} className={cn(btnBase, "border border-border bg-background text-foreground hover:bg-muted")}>
<Mail className="size-4" aria-hidden="true" />
Email
</a>
{onSchedule ? (
<button type="button" onClick={onSchedule} className={cn(btnBase, "col-span-2 bg-primary text-primary-foreground hover:opacity-90")}>
<Calendar className="size-4" aria-hidden="true" />
Schedule a tour
</button>
) : (
<a href={scheduleHref} className={cn(btnBase, "col-span-2 bg-primary text-primary-foreground hover:opacity-90")}>
<Calendar className="size-4" aria-hidden="true" />
Schedule a tour
</a>
)}
</div>
</motion.article>
);
}
export default AgentCard;app/page.tsx
import { AgentCard } from "@/components/facade/agent-card";
export default function Demo() {
return (
<div className="grid w-full max-w-[760px] grid-cols-1 justify-items-center gap-5 sm:grid-cols-2">
<AgentCard
name="Danielle Ortiz"
title="Realtor"
brokerage="Summit Peak Realty"
license="CO FA.100082417"
rating={4.9}
reviewCount={128}
languages={["English", "Spanish"]}
phone="(303) 555-0148"
email="danielle@summitpeakrealty.com"
stats={[
{ label: "Homes sold", value: "214" },
{ label: "Avg. days", value: "11" },
{ label: "Sale to list", value: "101%" },
]}
/>
<AgentCard
name="Marcus Bell"
title="Broker associate"
brokerage="Coastline Partners"
license="FL BK3318842"
rating={4.8}
reviewCount={86}
languages={["English", "Portuguese", "French"]}
phone="(305) 555-0192"
email="marcus@coastlinepartners.com"
available={false}
stats={[
{ label: "Homes sold", value: "167" },
{ label: "Volume", value: "$142M" },
{ label: "Years", value: "12" },
]}
/>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/agent-card.jsonUsage
import { AgentCard } from "@/components/facade/agent-card";
export default function Page() {
return <AgentCard />;
}Props
AgentCardProps is exported, so you can extend or wrap it. Every prop has a default; <AgentCard /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| name | string | |
| title | string | |
| brokerage | string | |
| license | string | |
| photoSrc | string | Optional headshot URL. Falls back to an initials avatar. |
| rating | number | |
| reviewCount | number | |
| languages | string[] | |
| phone | string | |
| string | ||
| scheduleHref | string | Link or handler for the schedule tour action. |
| onSchedule | () => void | |
| stats | Array<{ label: string; value: string }> | Optional stats shown under the header, e.g. homes sold. |
| available | boolean | Show the online status dot on the avatar. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade agent card to this page and adapt the copy to our business.