Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  ArrowRight,
  BarChart3,
  Bell,
  ChevronRight,
  Home,
  Inbox,
  Layers,
  Search,
  Settings,
  Users,
} from "lucide-react";
import { cn } from "@/lib/utils";

export interface HeroRotatingWordsProps {
  /** Text before the rotating word. */
  prefix?: string;
  /** Words that cycle in the second line. */
  words?: string[];
  /** Milliseconds each word stays on screen. */
  interval?: number;
  description?: string;
  primaryCta?: { label: string; href?: string };
  secondaryCta?: { label: string; href?: string };
  /** Show the tilted app screenshot. */
  showScreenshot?: boolean;
  className?: string;
}

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

export function HeroRotatingWords({
  prefix = "One workspace for",
  words = ["product teams", "founders", "designers", "engineers", "agencies"],
  interval = 2400,
  description = "Plan roadmaps, track metrics and ship updates in one calm place. Northwind replaces six tools with a single, blazing-fast workspace.",
  primaryCta = { label: "Get started — it's free", href: "#" },
  secondaryCta = { label: "Book a demo", href: "#" },
  showScreenshot = true,
  className,
}: HeroRotatingWordsProps) {
  const reduce = useReducedMotion();
  const [index, setIndex] = React.useState(0);

  React.useEffect(() => {
    if (words.length < 2) return;
    const id = setInterval(() => setIndex((i) => (i + 1) % words.length), interval);
    return () => clearInterval(id);
  }, [words.length, interval]);

  const word = words[index % Math.max(1, words.length)] ?? "";

  return (
    <section className={cn("relative isolate w-full overflow-hidden bg-background text-foreground", className)}>
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
        <div className="absolute left-1/2 top-[-12rem] h-[34rem] w-[60rem] max-w-[160%] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--color-primary)_22%,transparent),transparent)]" />
        <div className="absolute inset-0 bg-[radial-gradient(var(--color-border)_1px,transparent_1px)] bg-[size:22px_22px] [mask-image:linear-gradient(to_bottom,#000,transparent_70%)]" />
      </div>

      <div className="mx-auto flex max-w-6xl flex-col items-center px-5 pt-16 text-center sm:px-8 sm:pt-20">
        <motion.h1
          initial={{ opacity: 0, y: 16 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.7, ease: EASE }}
          className="text-balance text-[2.5rem] font-semibold leading-[1.08] tracking-tight sm:text-6xl lg:text-7xl"
        >
          <span className="block">{prefix}</span>
          <span className="sr-only">{words.join(", ")}</span>
          <span aria-hidden className="relative block h-[1.2em] overflow-hidden">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={word}
                className="absolute inset-x-0 block whitespace-nowrap bg-linear-to-r from-primary via-fuchsia-500 to-orange-400 bg-clip-text pb-2 text-transparent"
                initial={reduce ? { opacity: 0 } : { y: "100%", opacity: 0, filter: "blur(8px)" }}
                animate={{ y: "0%", opacity: 1, filter: "blur(0px)" }}
                exit={reduce ? { opacity: 0 } : { y: "-100%", opacity: 0, filter: "blur(8px)" }}
                transition={{ duration: 0.6, ease: EASE }}
              >
                {word}
              </motion.span>
            </AnimatePresence>
          </span>
        </motion.h1>

        <motion.p
          initial={{ opacity: 0, y: 14 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.7, delay: 0.15, ease: EASE }}
          className="mt-6 max-w-xl 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.25, ease: EASE }}
          className="mt-9 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row"
        >
          <a
            href={primaryCta.href ?? "#"}
            className="group inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-semibold text-primary-foreground shadow-md shadow-primary/25 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 sm:w-auto"
          >
            {primaryCta.label}
            <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
          </a>
          <a
            href={secondaryCta.href ?? "#"}
            className="inline-flex h-11 w-full items-center justify-center gap-1 rounded-lg border bg-card px-5 text-sm font-semibold transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"
          >
            {secondaryCta.label}
            <ChevronRight className="size-4 text-muted-foreground" aria-hidden />
          </a>
        </motion.div>

        {showScreenshot && (
          <div className="mt-14 w-full [perspective:1600px] sm:mt-16">
            <motion.div
              initial={reduce ? { opacity: 0 } : { opacity: 0, rotateX: 28, y: 80, scale: 0.94 }}
              animate={{ opacity: 1, rotateX: 0, y: 0, scale: 1 }}
              transition={{ duration: 1.3, delay: 0.35, ease: EASE }}
              style={{ transformOrigin: "50% 0%" }}
              className="relative mx-auto max-w-5xl"
            >
              <div aria-hidden className="absolute -inset-x-8 -top-8 bottom-0 -z-10 rounded-[2rem] bg-linear-to-b from-primary/25 via-fuchsia-500/10 to-transparent blur-3xl" />
              <AppScreenshot />
            </motion.div>
          </div>
        )}
      </div>
    </section>
  );
}

const NAV = [
  { icon: Home, label: "Home" },
  { icon: Inbox, label: "Inbox", badge: "4" },
  { icon: BarChart3, label: "Analytics", active: true },
  { icon: Layers, label: "Projects" },
  { icon: Users, label: "Team" },
  { icon: Settings, label: "Settings" },
];

// 12 points of a smooth growth curve, 0–100.
const SERIES_A = [22, 30, 27, 38, 35, 48, 44, 58, 62, 57, 72, 84];
const SERIES_B = [14, 18, 20, 22, 26, 25, 31, 33, 38, 36, 42, 46];

function toPath(values: number[], w: number, h: number) {
  const step = w / (values.length - 1);
  const pts = values.map((v, i) => [i * step, h - (v / 100) * h] as const);
  return pts.reduce((d, [x, y], i) => {
    if (i === 0) return `M${x},${y}`;
    const [px, py] = pts[i - 1];
    const cx = (px + x) / 2;
    return `${d} C${cx},${py} ${cx},${y} ${x},${y}`;
  }, "");
}

function AppScreenshot() {
  const W = 600;
  const H = 180;
  const a = toPath(SERIES_A, W, H);
  const b = toPath(SERIES_B, W, H);
  return (
    <div aria-hidden className="overflow-hidden rounded-t-2xl border border-b-0 bg-card text-left shadow-2xl shadow-black/10 [mask-image:linear-gradient(to_bottom,#000_70%,transparent)] dark:shadow-black/40">
      <div className="flex">
        {/* Sidebar */}
        <aside className="hidden w-52 shrink-0 border-r bg-muted/40 p-3 md:block">
          <div className="flex items-center gap-2 px-2 py-1.5">
            <span className="grid size-6 place-items-center rounded-md bg-primary text-[11px] font-bold text-primary-foreground">N</span>
            <span className="text-sm font-semibold">Northwind</span>
          </div>
          <div className="mt-3 flex items-center gap-2 rounded-md border bg-background px-2 py-1.5 text-xs text-muted-foreground">
            <Search className="size-3.5" /> Search
            <span className="ml-auto rounded border px-1 text-[10px]">⌘K</span>
          </div>
          <nav className="mt-4 space-y-0.5">
            {NAV.map(({ icon: Icon, label, badge, active }) => (
              <div
                key={label}
                className={cn(
                  "flex items-center gap-2 rounded-md px-2 py-1.5 text-xs",
                  active ? "bg-background font-medium text-foreground shadow-sm" : "text-muted-foreground",
                )}
              >
                <Icon className="size-3.5" />
                {label}
                {badge && <span className="ml-auto rounded-full bg-primary/15 px-1.5 text-[10px] font-medium text-primary">{badge}</span>}
              </div>
            ))}
          </nav>
          <p className="mt-6 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Favorites</p>
          {["Q4 launch", "Growth board", "Design system"].map((t, i) => (
            <div key={t} className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
              <span className={cn("size-2 rounded-sm", ["bg-violet-500", "bg-emerald-500", "bg-amber-500"][i])} />
              {t}
            </div>
          ))}
        </aside>

        {/* Main */}
        <div className="min-w-0 flex-1 p-4 sm:p-6">
          <div className="flex items-center justify-between gap-3">
            <div>
              <p className="text-[11px] text-muted-foreground">Analytics / Overview</p>
              <p className="text-base font-semibold sm:text-lg">Revenue performance</p>
            </div>
            <div className="flex items-center gap-2">
              <span className="hidden rounded-md border px-2 py-1 text-[11px] text-muted-foreground sm:inline">Last 12 months</span>
              <span className="grid size-7 place-items-center rounded-md border"><Bell className="size-3.5 text-muted-foreground" /></span>
            </div>
          </div>

          <div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
            {[
              { k: "MRR", v: "$84.2k", d: "+18%" },
              { k: "Customers", v: "2,914", d: "+6%" },
              { k: "Churn", v: "1.8%", d: "-0.4%" },
              { k: "NPS", v: "67", d: "+5" },
            ].map((s, i) => (
              <div key={s.k} className={cn("rounded-lg border bg-background p-3", i > 1 && "hidden lg:block")}>
                <p className="text-[11px] text-muted-foreground">{s.k}</p>
                <div className="mt-1 flex items-baseline gap-2">
                  <span className="text-lg font-semibold tabular-nums">{s.v}</span>
                  <span className="text-[11px] font-medium text-emerald-600 dark:text-emerald-400">{s.d}</span>
                </div>
              </div>
            ))}
          </div>

          <div className="mt-4 rounded-lg border bg-background p-3 sm:p-4">
            <div className="mb-3 flex items-center gap-4 text-[11px] text-muted-foreground">
              <span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-primary" /> This year</span>
              <span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-muted-foreground/40" /> Last year</span>
            </div>
            <svg viewBox={`0 0 ${W} ${H + 4}`} className="h-36 w-full sm:h-48" preserveAspectRatio="none">
              <defs>
                <linearGradient id="hrw-fill" x1="0" x2="0" y1="0" y2="1">
                  <stop offset="0%" stopColor="var(--color-primary)" stopOpacity="0.28" />
                  <stop offset="100%" stopColor="var(--color-primary)" stopOpacity="0" />
                </linearGradient>
              </defs>
              {[0.25, 0.5, 0.75].map((f) => (
                <line key={f} x1="0" x2={W} y1={H * f} y2={H * f} stroke="var(--color-border)" strokeDasharray="4 4" />
              ))}
              <motion.path
                d={`${a} L${W},${H} L0,${H} Z`}
                fill="url(#hrw-fill)"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                transition={{ duration: 0.8, delay: 1.4 }}
              />
              <motion.path
                d={b}
                fill="none"
                stroke="var(--color-muted-foreground)"
                strokeOpacity="0.4"
                strokeWidth="2"
                vectorEffect="non-scaling-stroke"
                initial={{ pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={{ duration: 1.4, delay: 0.9, ease: "easeInOut" }}
              />
              <motion.path
                d={a}
                fill="none"
                stroke="var(--color-primary)"
                strokeWidth="2.5"
                strokeLinecap="round"
                vectorEffect="non-scaling-stroke"
                initial={{ pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={{ duration: 1.6, delay: 0.9, ease: "easeInOut" }}
              />
            </svg>
          </div>
        </div>
      </div>
    </div>
  );
}

More in Heroes

View all →