← PromoOpen in v0
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-02Code
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>
)
}