Fazekit

Code

"use client";
import * as React from "react";
import {
  AnimatePresence,
  motion,
  useMotionTemplate,
  useMotionValue,
  useReducedMotion,
  useSpring,
} from "motion/react";
import { ArrowRight, CheckCircle2, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";

export interface HeroSpotlightLogo {
  name: string;
  /** Any SVG/element; inherits `currentColor`. */
  logo: React.ReactNode;
}

export interface HeroSpotlightProps {
  eyebrow?: string;
  /** First line of the headline (solid). */
  headline?: string;
  /** Second line of the headline (gradient). */
  highlight?: string;
  description?: string;
  placeholder?: string;
  buttonLabel?: string;
  successMessage?: string;
  trustLabel?: string;
  logos?: HeroSpotlightLogo[];
  /** Called with the email. Return a promise to show the loading state. */
  onSubmit?: (email: string) => void | Promise<void>;
  className?: string;
}

const EASE = [0.22, 1, 0.36, 1] as const;

export function HeroSpotlight({
  eyebrow = "Private beta · 2,400 teams waiting",
  headline = "Infrastructure that",
  highlight = "scales with your ambition",
  description = "Orbit deploys your apps to 40 regions with zero config. Preview every branch, roll back in one click, and sleep through traffic spikes.",
  placeholder = "[email protected]",
  buttonLabel = "Join the waitlist",
  successMessage = "You're on the list — we'll be in touch soon.",
  trustLabel = "Trusted by engineering teams at",
  logos = DEFAULT_LOGOS,
  onSubmit,
  className,
}: HeroSpotlightProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLElement>(null);
  const mx = useMotionValue(640);
  const my = useMotionValue(180);
  const x = useSpring(mx, { stiffness: 140, damping: 22, mass: 0.4 });
  const y = useSpring(my, { stiffness: 140, damping: 22, mass: 0.4 });
  const glow = useMotionTemplate`radial-gradient(520px circle at ${x}px ${y}px, color-mix(in oklch, var(--color-primary) 22%, transparent), transparent 70%)`;
  const reveal = useMotionTemplate`radial-gradient(300px circle at ${x}px ${y}px, #000 0%, transparent 100%)`;

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    mx.jump(r.width / 2);
    my.jump(Math.min(220, r.height * 0.35));
    x.jump(r.width / 2);
    y.jump(Math.min(220, r.height * 0.35));
  }, [mx, my, x, y]);

  const onMove = (e: React.PointerEvent<HTMLElement>) => {
    if (reduce) return;
    const r = e.currentTarget.getBoundingClientRect();
    mx.set(e.clientX - r.left);
    my.set(e.clientY - r.top);
  };

  return (
    <section
      ref={ref}
      onPointerMove={onMove}
      className={cn("relative isolate w-full overflow-hidden bg-background text-foreground", className)}
    >
      {/* Grid + spotlight */}
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
        <div className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-border)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-border)_1px,transparent_1px)] bg-[size:48px_48px] [mask-image:radial-gradient(ellipse_80%_70%_at_50%_40%,#000_30%,transparent_100%)]" />
        <motion.div
          className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-primary)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-primary)_1px,transparent_1px)] bg-[size:48px_48px] opacity-50"
          style={{ maskImage: reveal, WebkitMaskImage: reveal }}
        />
        <motion.div className="absolute inset-0" style={{ background: glow }} />
        <div className="absolute left-1/2 top-0 h-px w-2/3 -translate-x-1/2 bg-linear-to-r from-transparent via-primary/60 to-transparent" />
        <div className="absolute inset-x-0 bottom-0 h-40 bg-linear-to-b from-transparent to-background" />
      </div>

      <div className="mx-auto flex max-w-5xl flex-col items-center px-5 py-20 text-center sm:px-8 sm:py-28">
        <motion.p
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.6, ease: EASE }}
          className="inline-flex items-center gap-2 rounded-full border bg-card/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur"
        >
          <span className="relative flex size-2">
            <span className="absolute inset-0 animate-ping rounded-full bg-emerald-500/60 motion-reduce:animate-none" />
            <span className="relative size-2 rounded-full bg-emerald-500" />
          </span>
          {eyebrow}
        </motion.p>

        <motion.h1
          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24, filter: "blur(10px)" }}
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          transition={{ duration: 0.9, delay: 0.1, ease: EASE }}
          className="mt-7 max-w-4xl text-balance text-[2.5rem] font-semibold leading-[1.04] tracking-tight sm:text-6xl lg:text-7xl"
        >
          <span className="bg-linear-to-b from-foreground to-foreground/70 bg-clip-text text-transparent">{headline}</span>{" "}
          <span className="bg-linear-to-r from-primary via-sky-500 to-emerald-400 bg-clip-text pb-1 text-transparent dark:via-sky-400 dark:to-emerald-300">
            {highlight}
          </span>
        </motion.h1>

        <motion.p
          initial={{ opacity: 0, y: 14 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.7, delay: 0.3, ease: EASE }}
          className="mt-6 max-w-2xl text-pretty text-base leading-relaxed text-muted-foreground sm:text-lg"
        >
          {description}
        </motion.p>

        <motion.div
          initial={{ opacity: 0, y: 14 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.7, delay: 0.45, ease: EASE }}
          className="mt-9 w-full max-w-md"
        >
          <EmailCapture placeholder={placeholder} buttonLabel={buttonLabel} successMessage={successMessage} onSubmit={onSubmit} />
        </motion.div>

        {logos.length > 0 && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            transition={{ duration: 0.8, delay: 0.7 }}
            className="mt-16 w-full sm:mt-20"
          >
            <p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">{trustLabel}</p>
            <ul className="mt-6 flex flex-wrap items-center justify-center gap-x-10 gap-y-6 sm:gap-x-14">
              {logos.map((l, i) => (
                <motion.li
                  key={l.name}
                  initial={{ opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.5, delay: 0.8 + i * 0.07, ease: EASE }}
                  className="text-foreground/45 transition-colors hover:text-foreground/80"
                  title={l.name}
                >
                  <span className="sr-only">{l.name}</span>
                  <span aria-hidden className="flex h-7 items-center">{l.logo}</span>
                </motion.li>
              ))}
            </ul>
          </motion.div>
        )}
      </div>
    </section>
  );
}

function EmailCapture({
  placeholder,
  buttonLabel,
  successMessage,
  onSubmit,
}: Required<Pick<HeroSpotlightProps, "placeholder" | "buttonLabel" | "successMessage">> & Pick<HeroSpotlightProps, "onSubmit">) {
  const id = React.useId();
  const [email, setEmail] = React.useState("");
  const [state, setState] = React.useState<"idle" | "loading" | "done" | "error">("idle");

  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return setState("error");
    setState("loading");
    await (onSubmit ? onSubmit(email) : new Promise((r) => setTimeout(r, 900)));
    setState("done");
  };

  return (
    <AnimatePresence mode="wait" initial={false}>
      {state === "done" ? (
        <motion.p
          key="done"
          role="status"
          initial={{ opacity: 0, scale: 0.96 }}
          animate={{ opacity: 1, scale: 1 }}
          className="flex h-12 items-center justify-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-4 text-sm font-medium text-emerald-700 dark:text-emerald-300"
        >
          <CheckCircle2 className="size-4" aria-hidden />
          {successMessage}
        </motion.p>
      ) : (
        <motion.form key="form" exit={{ opacity: 0, scale: 0.96 }} onSubmit={submit} noValidate>
          <div
            className={cn(
              "flex flex-col gap-2 rounded-2xl border bg-card/70 p-1.5 shadow-lg shadow-primary/5 backdrop-blur-md transition focus-within:border-primary/50 focus-within:ring-4 focus-within:ring-primary/15 sm:flex-row sm:rounded-full",
              state === "error" && "border-destructive/60",
            )}
          >
            <label htmlFor={id} className="sr-only">
              Email address
            </label>
            <input
              id={id}
              type="email"
              autoComplete="email"
              value={email}
              onChange={(e) => (setEmail(e.target.value), state === "error" && setState("idle"))}
              placeholder={placeholder}
              aria-invalid={state === "error"}
              aria-describedby={`${id}-msg`}
              className="h-11 w-full min-w-0 bg-transparent sm:w-auto sm:flex-1 px-4 text-sm outline-none placeholder:text-muted-foreground"
            />
            <button
              type="submit"
              disabled={state === "loading"}
              className="group inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl bg-primary px-5 text-sm font-semibold text-primary-foreground transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-70 sm:rounded-full"
            >
              {state === "loading" ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
              {buttonLabel}
              {state !== "loading" && <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />}
            </button>
          </div>
          <p id={`${id}-msg`} aria-live="polite" className={cn("mt-2 h-4 text-xs", state === "error" ? "text-destructive" : "text-muted-foreground")}>
            {state === "error" ? "Please enter a valid email address." : "No spam. Unsubscribe anytime."}
          </p>
        </motion.form>
      )}
    </AnimatePresence>
  );
}

/* Invented brands, drawn by hand. */
const DEFAULT_LOGOS: HeroSpotlightLogo[] = [
  {
    name: "Northwind",
    logo: (
      <svg viewBox="0 0 114 28" className="h-6 w-auto" fill="currentColor">
        <path d="M4 22 14 4l10 18h-6l-4-7-4 7z" />
        <text x="30" y="20" fontSize="16" fontWeight="700" letterSpacing="-0.5" fontFamily="inherit">Northwind</text>
      </svg>
    ),
  },
  {
    name: "Acme",
    logo: (
      <svg viewBox="0 0 84 28" className="h-6 w-auto" fill="currentColor">
        <circle cx="12" cy="14" r="9" fill="none" stroke="currentColor" strokeWidth="3.5" />
        <circle cx="12" cy="14" r="3" />
        <text x="28" y="20" fontSize="17" fontWeight="800" letterSpacing="1" fontFamily="inherit">ACME</text>
      </svg>
    ),
  },
  {
    name: "Lumen",
    logo: (
      <svg viewBox="0 0 80 28" className="h-6 w-auto" fill="currentColor">
        <path d="M12 3a11 11 0 1 0 0 22V3z" />
        <path d="M14 7a7 7 0 0 1 0 14z" opacity=".55" />
        <text x="30" y="20" fontSize="17" fontWeight="600" fontFamily="inherit">lumen</text>
      </svg>
    ),
  },
  {
    name: "Orbit",
    logo: (
      <svg viewBox="0 0 76 28" className="h-6 w-auto" fill="currentColor">
        <ellipse cx="12" cy="14" rx="11" ry="5" fill="none" stroke="currentColor" strokeWidth="2.5" transform="rotate(-25 12 14)" />
        <circle cx="12" cy="14" r="4" />
        <text x="30" y="20" fontSize="17" fontWeight="700" fontStyle="italic" fontFamily="inherit">Orbit</text>
      </svg>
    ),
  },
  {
    name: "Halcyon",
    logo: (
      <svg viewBox="0 0 118 28" className="h-6 w-auto" fill="currentColor">
        <rect x="2" y="4" width="8" height="20" rx="2" />
        <rect x="12" y="10" width="8" height="14" rx="2" opacity=".6" />
        <text x="28" y="20" fontSize="16" fontWeight="700" letterSpacing="2" fontFamily="inherit">HALCYON</text>
      </svg>
    ),
  },
];

More in Heroes

View all →