Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowDownLeft, BatteryFull, Check, CreditCard, PiggyBank, Plane, ShoppingBag, Signal, Smartphone, TabletSmartphone, TrendingUp, Wifi } from "lucide-react";
import { cn } from "@/lib/utils";

export type AppNotification = {
  title: string;
  body: string;
  time: string;
  icon: "income" | "card" | "savings" | "shopping" | "travel" | "invest";
};

export interface CtaAppDownloadProps {
  eyebrow?: string;
  title?: React.ReactNode;
  description?: string;
  bullets?: string[];
  appName?: string;
  primary?: { label: string; caption: string; href: string };
  secondary?: { label: string; caption: string; href: string };
  qrCaption?: string;
  rating?: string;
  notifications?: AppNotification[];
  /** Milliseconds between new notifications on the phone screen. */
  notificationInterval?: number;
  className?: string;
}

const NOTIF_ICONS = {
  income: { Icon: ArrowDownLeft, cls: "bg-emerald-500" },
  card: { Icon: CreditCard, cls: "bg-violet-500" },
  savings: { Icon: PiggyBank, cls: "bg-pink-500" },
  shopping: { Icon: ShoppingBag, cls: "bg-amber-500" },
  travel: { Icon: Plane, cls: "bg-sky-500" },
  invest: { Icon: TrendingUp, cls: "bg-indigo-500" },
} as const;

const DEFAULT_NOTIFICATIONS: AppNotification[] = [
  { icon: "income", title: "Salary received", body: "+$4,250.00 from Northwind Inc.", time: "3m" },
  { icon: "savings", title: "Round-ups saved", body: "You saved $18.40 this week", time: "2m" },
  { icon: "card", title: "Card payment", body: "$12.80 at Lumen Coffee", time: "5m" },
  { icon: "invest", title: "Portfolio up 2.4%", body: "Your Orbit index had a great day", time: "12m" },
  { icon: "travel", title: "Travel mode on", body: "No fees in 40+ countries", time: "1h" },
  { icon: "shopping", title: "Budget on track", body: "$320 left for shopping in May", time: "2h" },
];

/* ---------- deterministic QR-like pattern (decorative) ---------- */

function mulberry32(seed: number) {
  return () => {
    seed |= 0;
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const QR_SIZE = 25;
const QR_CELLS: [number, number][] = (() => {
  const rand = mulberry32(20260924);
  const cells: [number, number][] = [];
  const inFinder = (x: number, y: number) =>
    (x < 8 && y < 8) || (x >= QR_SIZE - 8 && y < 8) || (x < 8 && y >= QR_SIZE - 8);
  const inCenter = (x: number, y: number) => Math.abs(x - 12) <= 2 && Math.abs(y - 12) <= 2;
  for (let y = 0; y < QR_SIZE; y++)
    for (let x = 0; x < QR_SIZE; x++) if (!inFinder(x, y) && !inCenter(x, y) && rand() > 0.52) cells.push([x, y]);
  return cells;
})();

function QrPattern({ className }: { className?: string }) {
  const finder = (x: number, y: number) => (
    <g key={`${x}-${y}`}>
      <rect x={x + 0.5} y={y + 0.5} width={6} height={6} rx={1.6} fill="none" stroke="currentColor" strokeWidth={1} />
      <rect x={x + 2} y={y + 2} width={3} height={3} rx={0.8} fill="currentColor" />
    </g>
  );
  return (
    <svg viewBox={`0 0 ${QR_SIZE} ${QR_SIZE}`} className={className} aria-hidden shapeRendering="geometricPrecision">
      {QR_CELLS.map(([x, y]) => (
        <rect key={`${x}-${y}`} x={x + 0.1} y={y + 0.1} width={0.8} height={0.8} rx={0.25} fill="currentColor" />
      ))}
      {finder(0, 0)}
      {finder(QR_SIZE - 7, 0)}
      {finder(0, QR_SIZE - 7)}
      <rect x={10.2} y={10.2} width={4.6} height={4.6} rx={1.2} className="fill-primary" />
      <path d="M11.6 12.6l0.9 0.9 1.7-1.9" fill="none" stroke="white" strokeWidth={0.55} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/* ---------- phone ---------- */

function Phone({ appName, notifications, interval }: { appName: string; notifications: AppNotification[]; interval: number }) {
  const reduce = useReducedMotion();
  const [tick, setTick] = React.useState(0);

  React.useEffect(() => {
    if (reduce || notifications.length === 0) return;
    const id = setInterval(() => setTick((t) => t + 1), interval);
    return () => clearInterval(id);
  }, [interval, notifications.length, reduce]);

  const visible = reduce ? 3 : Math.min(3, tick + 1);
  const stack = Array.from({ length: visible }, (_, i) => {
    const n = tick - i;
    return { key: n, n: notifications[((n % notifications.length) + notifications.length) % notifications.length] };
  });

  return (
    <motion.div
      className="relative"
      animate={reduce ? undefined : { y: [0, -10, 0] }}
      transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}
    >
      {/* side buttons */}
      <span aria-hidden className="absolute -left-[3px] top-28 h-8 w-[3px] rounded-l bg-neutral-700" />
      <span aria-hidden className="absolute -left-[3px] top-40 h-14 w-[3px] rounded-l bg-neutral-700" />
      <span aria-hidden className="absolute -right-[3px] top-36 h-20 w-[3px] rounded-r bg-neutral-700" />

      <div className="relative h-[520px] w-[256px] rounded-[3rem] bg-neutral-900 p-[10px] shadow-[0_40px_80px_-20px_rgb(0_0_0/0.45)] ring-1 ring-neutral-700 sm:h-[580px] sm:w-[284px] dark:ring-neutral-600">
        <div className="relative size-full overflow-hidden rounded-[2.4rem] bg-background">
          {/* wallpaper */}
          <div aria-hidden className="absolute inset-0 bg-[radial-gradient(120%_60%_at_100%_0%,color-mix(in_oklch,var(--primary)_28%,transparent),transparent),radial-gradient(90%_50%_at_0%_100%,rgb(236_72_153/0.18),transparent)]" />

          {/* status bar */}
          <div className="relative flex items-center justify-between px-6 pt-3.5 sm:px-7 text-[11px] font-semibold text-foreground">
            <span>9:41</span>
            <span className="flex items-center gap-1">
              <Signal className="size-3" />
              <Wifi className="size-3" />
              <BatteryFull className="size-4" />
            </span>
          </div>
          <div aria-hidden className="absolute left-1/2 top-2.5 h-6 w-[72px] -translate-x-1/2 sm:w-[88px] rounded-full bg-neutral-900" />

          {/* app content */}
          <div className="relative px-4 pt-5">
            <div className="flex items-center justify-between">
              <div>
                <p className="text-[11px] text-muted-foreground">Good morning</p>
                <p className="text-sm font-semibold text-foreground">Sam Carter</p>
              </div>
              <span className="grid size-8 place-items-center rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 text-[11px] font-semibold text-white">SC</span>
            </div>

            <div className="mt-4 rounded-2xl bg-gradient-to-br from-violet-600 via-fuchsia-600 to-rose-500 p-4 text-white shadow-lg shadow-fuchsia-500/20">
              <p className="text-[11px] text-white/80">Total balance</p>
              <p className="mt-1 text-2xl font-semibold tracking-tight">$12,480.20</p>
              <div className="mt-3 flex items-end gap-1" aria-hidden>
                {[40, 62, 48, 70, 55, 82, 66, 90, 74, 96].map((h, i) => (
                  <motion.span
                    key={i}
                    className="w-full rounded-sm bg-white/35"
                    initial={{ height: 4 }}
                    animate={{ height: h * 0.32 }}
                    transition={{ delay: 0.3 + i * 0.05, type: "spring", stiffness: 200, damping: 18 }}
                  />
                ))}
              </div>
            </div>

            <p className="mt-5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Activity</p>
            <div className="relative mt-2 h-[228px] sm:h-[270px]">
              <AnimatePresence initial={false} mode="popLayout">
                {stack.map(({ key, n }, i) => {
                  const { Icon, cls } = NOTIF_ICONS[n.icon];
                  return (
                    <motion.div
                      key={key}
                      layout
                      initial={{ opacity: 0, y: -40, scale: 0.9 }}
                      animate={{ opacity: 1 - i * 0.18, y: 0, scale: 1 - i * 0.02 }}
                      exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
                      transition={{ type: "spring", stiffness: 380, damping: 30 }}
                      className="mb-2 flex items-center gap-3 rounded-2xl border bg-card/90 p-3 shadow-sm backdrop-blur"
                    >
                      <span className={cn("grid size-8 shrink-0 place-items-center rounded-xl text-white", cls)}>
                        <Icon aria-hidden className="size-4" />
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="flex items-center justify-between gap-2">
                          <span className="truncate text-[12px] font-semibold text-foreground">{n.title}</span>
                          <span className="shrink-0 text-[10px] text-muted-foreground">{i === 0 ? "now" : n.time}</span>
                        </span>
                        <span className="block truncate text-[11px] text-muted-foreground">{n.body}</span>
                      </span>
                    </motion.div>
                  );
                })}
              </AnimatePresence>
            </div>
          </div>

          {/* tab bar */}
          <div className="absolute inset-x-3 bottom-3 flex items-center justify-around rounded-2xl border bg-card/90 py-2.5 backdrop-blur">
            {[0, 1, 2, 3].map((i) => (
              <span key={i} aria-hidden className={cn("h-1.5 rounded-full", i === 0 ? "w-6 bg-primary" : "w-1.5 bg-muted-foreground/40")} />
            ))}
          </div>
          <span aria-hidden className="absolute bottom-1 left-1/2 h-1 w-24 -translate-x-1/2 rounded-full bg-foreground/30" />
        </div>
      </div>
      <p className="sr-only">{appName} app preview showing live account notifications.</p>
    </motion.div>
  );
}

function StoreButton({ label, caption, href, icon, variant }: { label: string; caption: string; href: string; icon: React.ReactNode; variant: "solid" | "outline" }) {
  return (
    <a
      href={href}
      className={cn(
        "group inline-flex h-14 items-center gap-2.5 rounded-2xl px-3.5 transition sm:gap-3 sm:px-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        variant === "solid" ? "bg-foreground text-background shadow-lg hover:-translate-y-0.5 hover:shadow-xl" : "border bg-card text-foreground hover:-translate-y-0.5 hover:bg-accent",
      )}
    >
      <span className="transition-transform group-hover:scale-110">{icon}</span>
      <span className="text-left leading-tight">
        <span className="block text-[10px] font-medium uppercase tracking-wider opacity-70">{caption}</span>
        <span className="block text-base font-semibold">{label}</span>
      </span>
    </a>
  );
}

export function CtaAppDownload({
  eyebrow = "Mobile app",
  title = (
    <>
      Your money,{" "}
      <span className="bg-gradient-to-r from-violet-600 via-fuchsia-500 to-rose-500 bg-clip-text text-transparent dark:from-violet-400 dark:via-fuchsia-400 dark:to-rose-400">
        always in reach
      </span>
    </>
  ),
  description = "Get instant alerts, freeze your card in a tap and watch your savings grow — all from one beautifully simple app.",
  bullets = ["Real-time notifications", "Face unlock & card freeze", "Free transfers in 40+ countries"],
  appName = "Lumen",
  primary = { label: "iOS", caption: "Download for", href: "#ios" },
  secondary = { label: "Android", caption: "Get it for", href: "#android" },
  qrCaption = "Scan with your phone camera to download",
  rating = "4.9 · 38k ratings",
  notifications = DEFAULT_NOTIFICATIONS,
  notificationInterval = 2400,
  className,
}: CtaAppDownloadProps) {
  const reduce = useReducedMotion();
  return (
    <section className={cn("relative w-full bg-background px-4 py-10 sm:px-6 sm:py-12", className)}>
      <div className="relative mx-auto max-w-6xl overflow-hidden rounded-[2.5rem] border bg-card">
        <div aria-hidden className="pointer-events-none absolute inset-0 [background-image:linear-gradient(to_right,color-mix(in_oklch,var(--foreground)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklch,var(--foreground)_6%,transparent)_1px,transparent_1px)] [background-size:40px_40px] [mask-image:radial-gradient(70%_80%_at_75%_50%,#000,transparent)]" />
        <div aria-hidden className="pointer-events-none absolute -right-20 top-1/2 size-[520px] -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--primary)_30%,transparent),transparent)]" />

        <div className="relative grid items-center gap-12 px-6 py-12 sm:px-12 sm:py-14 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-10 lg:py-10 lg:pl-16 lg:pr-24">
          <motion.div
            initial={reduce ? false : { opacity: 0, y: 20 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true }}
            transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
          >
            <p className="inline-flex items-center gap-2 rounded-full border bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
              <Smartphone aria-hidden className="size-3.5 text-primary" />
              {eyebrow}
            </p>
            <h2 className="mt-5 text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl lg:text-6xl">{title}</h2>
            <p className="mt-5 max-w-lg text-pretty text-base leading-relaxed text-muted-foreground sm:text-lg">{description}</p>
            <ul className="mt-6 space-y-2.5">
              {bullets.map((b) => (
                <li key={b} className="flex items-center gap-3 text-sm text-foreground/85 sm:text-base">
                  <span className="grid size-5 place-items-center rounded-full bg-primary/12 text-primary">
                    <Check aria-hidden className="size-3" strokeWidth={3} />
                  </span>
                  {b}
                </li>
              ))}
            </ul>

            <div className="mt-9 grid grid-cols-2 gap-2.5 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
              <StoreButton {...primary} variant="solid" icon={<Smartphone aria-hidden className="size-6" />} />
              <StoreButton {...secondary} variant="outline" icon={<TabletSmartphone aria-hidden className="size-6" />} />
            </div>

            <div className="mt-8 hidden items-center gap-4 sm:flex">
              <div className="rounded-2xl border bg-background p-2.5 shadow-sm">
                <QrPattern className="size-20 text-foreground" />
              </div>
              <div>
                <p className="max-w-[12rem] text-sm font-medium text-foreground">{qrCaption}</p>
                <p className="mt-1 text-xs text-muted-foreground">
                  <span className="text-amber-500">★★★★★</span> {rating}
                </p>
              </div>
            </div>
          </motion.div>

          <div className="flex justify-center lg:justify-end">
            <Phone appName={appName} notifications={notifications} interval={notificationInterval} />
          </div>
        </div>
      </div>
    </section>
  );
}

More in Call to Action

View all →