# Hero, local business

Home services hero with live rating, rotating service words and floating appointment, review and ETA cards.

- Type: block
- Category: Hero
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/hero-local-business.json
```

## Usage

```tsx
import { HeroLocalBusiness } from "@/components/facade/hero-local-business";

export default function Page() {
  return <HeroLocalBusiness />;
}
```

## Source

```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">
              &ldquo;{review.quote}&rdquo;
            </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;
```
