Promo

Promo 02 — Deal Countdown

Signature flash-sale band over a masked interactive mesh backdrop: indexed caption bar, oversized Swiss headline with an animated discount, a live hairline countdown timer and a promo-code CTA over grain.

$ npx shadcn@latest add @ludusvibe/promo-02
Open in v0

Code

Installed to components/sections/promo-02.tsx — it's yours to edit.

"use client"

import * as React from "react"
import { motion } from "motion/react"
import { ArrowRight } from "lucide-react"

import { buttonVariants } from "@/components/ui/button"
import { AnimatedNumber } from "@/components/ludus/animated-number"
import { Grain } from "@/components/ludus/grain"
import { MeshBackdrop } from "@/components/ludus/mesh-backdrop"
import { fadeUp, stagger, viewport } from "@/lib/motion"
import { cn } from "@/lib/utils"

type Cta = { label: string; href: string }

export type Promo02Props = {
  index?: string
  label?: string
  note?: string
  title?: React.ReactNode
  /** Headline discount figure, counted up via AnimatedNumber. */
  discount?: number
  /** Promo code shown in the dashed chip. */
  code?: string
  cta?: Cta
  /** Hours until the sale ends — drives the countdown. */
  endsInHours?: number
  /** Heading element — use "h1" when this is the page's main heading. */
  as?: "h1" | "h2"
  className?: string
}

const UNITS = [
  { key: "days", label: "Days" },
  { key: "hours", label: "Hours" },
  { key: "minutes", label: "Minutes" },
  { key: "seconds", label: "Seconds" },
] as const

function pad(n: number): string {
  return n.toString().padStart(2, "0")
}

export function Promo02({
  index = "07",
  label = "Sale",
  note = "Ends soon",
  title = "Winter sale.",
  discount = 40,
  code = "WINTER40",
  cta = { label: "Shop the sale", href: "#" },
  endsInHours = 48,
  as: Heading = "h2",
  className,
}: Promo02Props) {
  const [remaining, setRemaining] = React.useState<number | null>(null)

  React.useEffect(() => {
    const target = Date.now() + endsInHours * 3600 * 1000
    const tick = () => setRemaining(Math.max(0, target - Date.now()))
    tick()
    const id = window.setInterval(tick, 1000)
    return () => window.clearInterval(id)
  }, [endsInHours])

  const totalSeconds = remaining === null ? null : Math.floor(remaining / 1000)
  const values: Record<string, string> =
    totalSeconds === null
      ? { days: "--", hours: "--", minutes: "--", seconds: "--" }
      : {
          days: pad(Math.max(0, Math.floor(totalSeconds / 86400))),
          hours: pad(Math.max(0, Math.floor((totalSeconds % 86400) / 3600))),
          minutes: pad(Math.max(0, Math.floor((totalSeconds % 3600) / 60))),
          seconds: pad(Math.max(0, totalSeconds % 60)),
        }

  return (
    <section className={cn("relative overflow-hidden bg-background", className)}>
      <MeshBackdrop
        interactive
        className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,black,transparent_78%)]"
      />
      <Grain />

      <motion.div
        initial="hidden"
        whileInView="visible"
        viewport={viewport}
        variants={stagger(0.09)}
        className="relative mx-auto max-w-6xl px-6 py-20 md:py-28"
      >
        {/* Indexed caption bar */}
        <motion.div
          variants={fadeUp}
          className="flex items-baseline justify-between border-b pb-3 text-xs tracking-[0.2em] text-muted-foreground uppercase"
        >
          <span>
            <span className="text-primary">{index}</span> — {label}
          </span>
          <span className="hidden sm:block">{note}</span>
        </motion.div>

        {/* Two zones: headline / countdown */}
        <div className="mt-12 grid gap-x-12 gap-y-12 lg:grid-cols-[1.618fr_1fr] lg:items-end">
          {/* Headline zone */}
          <motion.div variants={fadeUp} className="flex flex-col">
            <Heading className="text-[clamp(2.25rem,5.5vw,4rem)] leading-[1.0] font-medium tracking-tight text-balance">
              {title}
            </Heading>
            <p className="mt-6 max-w-[60ch] text-pretty text-muted-foreground md:text-lg">
              Up to{" "}
              <AnimatedNumber
                value={discount}
                suffix="%"
                className="font-medium text-primary"
              />{" "}
              off everything — for the next few hours only.
            </p>

            <div className="mt-8 flex flex-wrap items-center gap-4">
              <a
                href={cta.href}
                className={cn(buttonVariants({ size: "lg" }), "group gap-3")}
              >
                {cta.label}
                <ArrowRight className="transition-transform group-hover:translate-x-0.5" />
              </a>
              <div className="flex items-center gap-2 border border-dashed px-4 py-2 text-xs tracking-[0.2em] text-muted-foreground uppercase">
                <span className="text-muted-foreground/70">Code</span>
                <span className="font-medium tabular-nums text-foreground">
                  {code}
                </span>
              </div>
            </div>
          </motion.div>

          {/* Countdown zone */}
          <motion.div variants={fadeUp}>
            <p className="mb-3 text-xs tracking-[0.2em] text-muted-foreground uppercase">
              Time left
            </p>
            <div className="grid grid-cols-4 gap-px border bg-border">
              {UNITS.map((unit) => (
                <div
                  key={unit.key}
                  className="flex flex-col items-center gap-2 rounded-none bg-background p-4 md:p-6"
                >
                  <span className="text-3xl font-medium tabular-nums tracking-tight md:text-4xl">
                    {values[unit.key]}
                  </span>
                  <span className="text-xs tracking-[0.2em] text-muted-foreground uppercase">
                    {unit.label}
                  </span>
                </div>
              ))}
            </div>
          </motion.div>
        </div>
      </motion.div>
    </section>
  )
}