Browse the library
Blocks / Hero
Hero, local business
FreeHome services hero with live rating, rotating service words and floating appointment, review and ETA cards.
components/facade/hero-local-business.tsx
"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import {
ArrowRight,
Calendar,
Car,
CircleCheck,
Clock,
MapPin,
Phone,
Shield,
Star,
Wrench,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface HeroLocalBusinessAppointment {
service: string;
date: string;
time: string;
address: string;
}
export interface HeroLocalBusinessReview {
name: string;
neighborhood: string;
quote: string;
}
export interface HeroLocalBusinessTechnician {
name: string;
eta: string;
vehicle: string;
}
export interface HeroLocalBusinessProps {
rating?: number;
reviewCount?: string;
eyebrowSuffix?: string;
headlinePrefix?: string;
rotatingWords?: string[];
headlineSuffix?: string;
description?: string;
primaryCta?: { label: string; href: string };
phone?: { label: string; href: string };
trustItems?: string[];
appointment?: HeroLocalBusinessAppointment;
review?: HeroLocalBusinessReview;
technician?: HeroLocalBusinessTechnician;
/** Interval in ms between word swaps. */
interval?: number;
className?: string;
}
const EASE = [0.22, 1, 0.36, 1] as const;
const TRUST_ICONS = [Shield, Zap, CircleCheck];
function initials(name: string) {
return name
.split(" ")
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function WordSwap({ words, interval }: { words: string[]; interval: number }) {
const reduce = useReducedMotion();
const [index, setIndex] = React.useState(0);
const longest = React.useMemo(
() => words.reduce((a, b) => (b.length > a.length ? b : a), ""),
[words],
);
React.useEffect(() => {
if (reduce || words.length < 2) return;
const id = window.setInterval(
() => setIndex((i) => (i + 1) % words.length),
interval,
);
return () => window.clearInterval(id);
}, [reduce, words.length, interval]);
return (
<span className="relative inline-grid align-bottom">
{/* Reserve width of the longest word so layout never jumps */}
<span aria-hidden className="invisible col-start-1 row-start-1">
{longest}
</span>
<span className="sr-only">{words.join(", ")}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={words[index]}
aria-hidden
className="col-start-1 row-start-1 bg-gradient-to-br from-brand to-brand/60 bg-clip-text text-transparent"
initial={{ y: "70%", opacity: 0, filter: "blur(8px)" }}
animate={{ y: "0%", opacity: 1, filter: "blur(0px)" }}
exit={{ y: "-70%", opacity: 0, filter: "blur(8px)" }}
transition={{ duration: 0.55, ease: EASE }}
>
{words[index]}
</motion.span>
</AnimatePresence>
</span>
);
}
function Stars({ value, className }: { value: number; className?: string }) {
return (
<span className={cn("flex items-center gap-0.5", className)} aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
"size-3.5",
i < Math.round(value)
? "fill-warning text-warning"
: "fill-muted text-muted",
)}
/>
))}
</span>
);
}
function FloatingCard({
children,
className,
mx,
my,
depth,
delay,
floatOffset,
}: {
children: React.ReactNode;
className?: string;
mx: MotionValue<number>;
my: MotionValue<number>;
depth: number;
delay: number;
floatOffset: number;
}) {
const reduce = useReducedMotion();
const x = useTransform(mx, (v) => v * depth);
const y = useTransform(my, (v) => v * depth);
return (
<motion.div
style={reduce ? undefined : { x, y }}
initial={{ opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.8, delay, ease: EASE }}
className={cn("absolute", className)}
>
<motion.div
animate={reduce ? undefined : { y: [0, -floatOffset, 0] }}
transition={{
duration: 6 + floatOffset / 2,
repeat: Infinity,
ease: "easeInOut",
delay: delay + 0.8,
}}
className="rounded-2xl border border-border bg-card/90 p-4 text-card-foreground shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_4%,transparent),0_24px_48px_-24px_color-mix(in_oklch,var(--foreground)_28%,transparent)] backdrop-blur-xl"
>
{children}
</motion.div>
</motion.div>
);
}
export function HeroLocalBusiness({
rating = 4.9,
reviewCount = "1,200+",
eyebrowSuffix = "reviews on Google",
headlinePrefix = "Austin's most trusted",
rotatingWords = ["plumbers", "electricians", "HVAC pros", "handymen"],
headlineSuffix = "on call today.",
description = "Upfront flat-rate pricing, background-checked technicians and a workmanship guarantee that actually means something. Book online in 60 seconds.",
primaryCta = { label: "Get a free quote", href: "#quote" },
phone = { label: "Call (512) 555-0142", href: "tel:+15125550142" },
trustItems = ["Licensed & insured", "Same-day service", "5-year warranty"],
appointment = {
service: "Water heater install",
date: "Thu, Mar 14",
time: "9:00 to 11:00 AM",
address: "2104 Travis Heights Blvd",
},
review = {
name: "Jessica Moreno",
neighborhood: "South Congress",
quote:
"Showed up in under two hours, explained everything and cleaned up after. Easily the best service call we've had.",
},
technician = {
name: "Marcus Bell",
eta: "12 min",
vehicle: "White Ford Transit",
},
interval = 2400,
className,
}: HeroLocalBusinessProps) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLDivElement>(null);
const gridId = `${React.useId().replace(/:/g, "")}-grid`;
const rawX = useMotionValue(0);
const rawY = useMotionValue(0);
const mx = useSpring(rawX, { stiffness: 120, damping: 30 });
const my = useSpring(rawY, { stiffness: 120, damping: 30 });
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (reduce || !ref.current) return;
const r = ref.current.getBoundingClientRect();
rawX.set(((e.clientX - r.left) / r.width - 0.5) * 2);
rawY.set(((e.clientY - r.top) / r.height - 0.5) * 2);
};
const onLeave = () => {
rawX.set(0);
rawY.set(0);
};
const fadeUp = (delay: number) => ({
initial: { opacity: 0, y: 16 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.7, delay, ease: EASE },
});
return (
<section
className={cn(
"relative isolate overflow-hidden bg-background text-foreground",
className,
)}
>
{/* Ambient background */}
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute -top-40 right-[-10%] h-[520px] w-[520px] rounded-full bg-brand/15 blur-[120px]" />
<div className="absolute bottom-[-20%] left-[-10%] h-[420px] w-[420px] rounded-full bg-accent blur-[120px] opacity-60" />
<div
className="absolute inset-0 opacity-[0.35] [mask-image:radial-gradient(ellipse_at_top,black,transparent_70%)]"
style={{
backgroundImage:
"radial-gradient(color-mix(in oklch, var(--foreground) 14%, transparent) 1px, transparent 1px)",
backgroundSize: "22px 22px",
}}
/>
</div>
<div className="mx-auto grid max-w-7xl items-center gap-14 px-5 py-20 sm:px-8 sm:py-24 lg:grid-cols-[1.05fr_1fr] lg:gap-10 lg:py-32">
{/* Copy */}
<div className="max-w-2xl">
<motion.a
{...fadeUp(0)}
href="#reviews"
className="group inline-flex items-center gap-2.5 rounded-full border border-border bg-card/70 py-1.5 pl-2 pr-3.5 text-sm shadow-sm backdrop-blur transition-colors hover:border-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="relative flex size-2 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping-slow rounded-full bg-success/60" />
<span className="relative inline-flex size-2 rounded-full bg-success" />
</span>
<Stars value={rating} />
<span className="font-medium text-foreground">{rating.toFixed(1)}</span>
<span className="text-muted-foreground">
from {reviewCount} {eyebrowSuffix}
</span>
<ArrowRight className="size-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden />
</motion.a>
<motion.h1
{...fadeUp(0.08)}
className="mt-7 font-display text-[2.6rem] font-semibold leading-[1.02] tracking-tight text-balance sm:text-6xl lg:text-[4.25rem]"
>
{headlinePrefix} <WordSwap words={rotatingWords} interval={interval} />
<br className="hidden sm:block" /> {headlineSuffix}
</motion.h1>
<motion.p
{...fadeUp(0.16)}
className="mt-6 max-w-xl text-lg leading-relaxed text-muted-foreground text-pretty"
>
{description}
</motion.p>
<motion.div
{...fadeUp(0.24)}
className="mt-9 flex flex-col gap-3 sm:flex-row sm:items-center"
>
<a
href={primaryCta.href}
className="group relative inline-flex h-12 items-center justify-center gap-2 overflow-hidden rounded-full bg-brand px-6 text-[15px] font-semibold text-brand-foreground shadow-[0_8px_24px_-8px_var(--brand)] transition-transform hover:-translate-y-px active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span
aria-hidden
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-brand-foreground/25 to-transparent transition-transform duration-700 group-hover:translate-x-full"
/>
<span className="relative">{primaryCta.label}</span>
<ArrowRight className="relative size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
</a>
<a
href={phone.href}
className="inline-flex h-12 items-center justify-center gap-2 rounded-full border border-border bg-card px-6 text-[15px] font-semibold text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Phone className="size-4 text-brand" aria-hidden />
{phone.label}
</a>
</motion.div>
<motion.ul
{...fadeUp(0.32)}
className="mt-10 flex flex-wrap gap-x-6 gap-y-3 border-t border-border pt-6 text-sm text-muted-foreground"
>
{trustItems.map((item, i) => {
const Icon = TRUST_ICONS[i % TRUST_ICONS.length] ?? Shield;
return (
<li key={item} className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-brand/10 text-brand">
<Icon className="size-3.5" aria-hidden />
</span>
<span className="font-medium text-foreground/80">{item}</span>
</li>
);
})}
</motion.ul>
</div>
{/* Card composition */}
<div
ref={ref}
onPointerMove={onMove}
onPointerLeave={onLeave}
className="relative mx-auto h-[460px] w-full max-w-[520px] sm:h-[500px]"
>
{/* Backdrop panel */}
<motion.div
aria-hidden
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1, ease: EASE }}
className="absolute inset-x-6 inset-y-8 overflow-hidden rounded-[2rem] border border-border bg-gradient-to-br from-muted to-card"
>
<svg className="absolute inset-0 size-full text-foreground/[0.06]" aria-hidden>
<defs>
<pattern id={gridId} width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M32 0H0V32" fill="none" stroke="currentColor" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${gridId})`} />
</svg>
{/* Stylized route map */}
<svg viewBox="0 0 400 400" className="absolute inset-0 size-full" aria-hidden>
<path
d="M40 340 C 120 300, 110 220, 190 210 S 300 150, 350 60"
fill="none"
stroke="var(--brand)"
strokeOpacity="0.18"
strokeWidth="14"
strokeLinecap="round"
/>
<motion.path
d="M40 340 C 120 300, 110 220, 190 210 S 300 150, 350 60"
fill="none"
stroke="var(--brand)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="1 10"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 2.2, delay: 0.6, ease: EASE }}
/>
</svg>
<div className="absolute right-[10%] top-[11%] flex size-10 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping-slow rounded-full bg-brand/30" />
<span className="relative flex size-7 items-center justify-center rounded-full bg-brand text-brand-foreground shadow-lg">
<MapPin className="size-3.5" aria-hidden />
</span>
</div>
</motion.div>
{/* Appointment card */}
<FloatingCard
mx={mx}
my={my}
depth={10}
delay={0.35}
floatOffset={6}
className="left-0 top-0 w-[78%] max-w-[300px] sm:top-2"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Upcoming visit
</span>
<span className="rounded-full bg-success/10 px-2 py-0.5 text-[11px] font-semibold text-success">
Confirmed
</span>
</div>
<div className="mt-3 flex items-start gap-3">
<div className="flex size-11 shrink-0 flex-col items-center justify-center rounded-xl bg-brand/10 text-brand">
<Calendar className="size-4" aria-hidden />
</div>
<div className="min-w-0">
<p className="truncate font-semibold">{appointment.service}</p>
<p className="mt-0.5 flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden />
{appointment.date}, {appointment.time}
</p>
<p className="mt-0.5 flex items-center gap-1.5 truncate text-sm text-muted-foreground">
<MapPin className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{appointment.address}</span>
</p>
</div>
</div>
</FloatingCard>
{/* Review card */}
<FloatingCard
mx={mx}
my={my}
depth={-16}
delay={0.5}
floatOffset={8}
className="right-0 top-[34%] w-[82%] max-w-[320px]"
>
<div className="flex items-center gap-3">
<span className="flex size-9 items-center justify-center rounded-full bg-gradient-to-br from-brand to-brand/60 text-xs font-semibold text-brand-foreground">
{initials(review.name)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{review.name}</p>
<p className="truncate text-xs text-muted-foreground">
{review.neighborhood}, verified customer
</p>
</div>
<Stars value={5} />
</div>
<p className="mt-3 text-sm leading-relaxed text-foreground/80">
“{review.quote}”
</p>
</FloatingCard>
{/* Technician card */}
<FloatingCard
mx={mx}
my={my}
depth={22}
delay={0.65}
floatOffset={5}
className="bottom-0 left-[4%] w-[80%] max-w-[300px] sm:bottom-2"
>
<div className="flex items-center gap-3">
<div className="relative">
<span className="flex size-11 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
{initials(technician.name)}
</span>
<span className="absolute -bottom-0.5 -right-0.5 flex size-5 items-center justify-center rounded-full border-2 border-card bg-brand text-brand-foreground">
<Wrench className="size-2.5" aria-hidden />
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">
Technician on the way
</p>
<p className="truncate font-semibold">{technician.name}</p>
</div>
<div className="text-right">
<p className="font-mono text-lg font-semibold leading-none text-brand">
{technician.eta}
</p>
<p className="mt-1 text-[11px] text-muted-foreground">ETA</p>
</div>
</div>
<div className="mt-3.5 h-1.5 overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-brand/70 to-brand"
initial={{ width: "15%" }}
animate={{ width: "72%" }}
transition={{ duration: 2.4, delay: 1, ease: EASE }}
/>
</div>
<p className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Car className="size-3.5" aria-hidden />
{technician.vehicle}
</p>
</FloatingCard>
</div>
</div>
</section>
);
}
export default HeroLocalBusiness;app/page.tsx
import { HeroLocalBusiness } from "@/components/facade/hero-local-business";
export default function Demo() {
return <HeroLocalBusiness />;
}Installation
$
npx shadcn@latest add https://facadeui.com/r/hero-local-business.jsonUsage
import { HeroLocalBusiness } from "@/components/facade/hero-local-business";
export default function Page() {
return <HeroLocalBusiness />;
}Props
HeroLocalBusinessProps is exported, so you can extend or wrap it. Every prop has a default; <HeroLocalBusiness /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| rating | number | |
| reviewCount | string | |
| eyebrowSuffix | string | |
| headlinePrefix | string | |
| rotatingWords | string[] | |
| headlineSuffix | string | |
| description | string | |
| primaryCta | { label: string; href: string } | |
| phone | { label: string; href: string } | |
| trustItems | string[] | |
| appointment | HeroLocalBusinessAppointment | |
| review | HeroLocalBusinessReview | |
| technician | HeroLocalBusinessTechnician | |
| interval | number | Interval in ms between word swaps. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade hero, local business to this page and adapt the copy to our business.