← PromoOpen in v0
Promo 01 — Announcement Bar
Slim dismissible top announcement bar with auto-rotating messages, a copy-to-clipboard promo code chip and an optional CTA — reduced-motion aware and SSR-safe.
$ npx shadcn@latest add @ludusvibe/promo-01Code
Installed to components/sections/promo-01.tsx — it's yours to edit.
"use client"
import * as React from "react"
import { AnimatePresence, motion } from "motion/react"
import { ArrowRight, Check, Copy, Sparkles, Truck, X } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { fadeIn, transition } from "@/lib/motion"
import { cn } from "@/lib/utils"
export type PromoMessage = {
/** The announcement copy. */
text: string
/** Optional promo code — rendered as a click-to-copy chip. */
code?: string
/** Optional inline call-to-action link. */
cta?: { label: string; href: string }
}
export type Promo01Props = {
/** Rotating announcements. Provide 1 for a static bar, 2+ to auto-rotate. */
messages?: PromoMessage[]
/** Auto-advance between messages. Ignored for a single message or reduced motion. Default true. */
rotate?: boolean
/** Show the dismiss button. Default true. */
dismissible?: boolean
className?: string
}
const DEFAULT_MESSAGES: PromoMessage[] = [
{
text: "Free carbon-neutral shipping on every order over $50.",
cta: { label: "Shop new arrivals", href: "#" },
},
{
text: "Take 20% off your first order with code",
code: "WELCOME20",
},
{
text: "Bestsellers are back in stock — selling fast this week.",
cta: { label: "Browse bestsellers", href: "#" },
},
]
const ROTATE_INTERVAL = 4500
const COPIED_RESET = 1800
export function Promo01({
messages = DEFAULT_MESSAGES,
rotate = true,
dismissible = true,
className,
}: Promo01Props) {
const [dismissed, setDismissed] = React.useState(false)
const [active, setActive] = React.useState(0)
const [copied, setCopied] = React.useState(false)
const [reduced, setReduced] = React.useState(false)
// Detect reduced-motion preference on mount (SSR-safe).
React.useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)")
const update = () => setReduced(mql.matches)
update()
mql.addEventListener("change", update)
return () => mql.removeEventListener("change", update)
}, [])
const canRotate = rotate && messages.length > 1 && !reduced
// Auto-advance the active message.
React.useEffect(() => {
if (!canRotate) return
const id = window.setInterval(() => {
setActive((i) => (i + 1) % messages.length)
}, ROTATE_INTERVAL)
return () => window.clearInterval(id)
}, [canRotate, messages.length])
// Keep the active index in range if messages change.
React.useEffect(() => {
setActive((i) => (i < messages.length ? i : 0))
}, [messages.length])
// Reset the "copied" affordance shortly after a copy.
React.useEffect(() => {
if (!copied) return
const id = window.setTimeout(() => setCopied(false), COPIED_RESET)
return () => window.clearTimeout(id)
}, [copied])
if (dismissed || messages.length === 0) return null
const current = messages[Math.min(active, messages.length - 1)]
const handleCopy = async (code: string) => {
try {
await navigator.clipboard.writeText(code)
} catch {
// Clipboard may be unavailable (insecure context / denied) — still flash feedback.
}
setCopied(true)
}
const Body = (
<div className="flex flex-wrap items-center justify-center gap-x-2.5 gap-y-1">
<Truck aria-hidden className="hidden size-4 shrink-0 sm:block" />
<Sparkles aria-hidden className="size-4 shrink-0 sm:hidden" />
<span className="text-balance">{current.text}</span>
{current.code ? (
<button
type="button"
onClick={() => handleCopy(current.code as string)}
aria-label={
copied ? "Code copied to clipboard" : `Copy code ${current.code}`
}
className="inline-flex items-center gap-1.5 rounded-md border border-dashed border-primary-foreground/40 bg-primary-foreground/10 px-2 py-0.5 font-mono text-xs font-semibold tracking-wide tabular-nums transition-colors hover:bg-primary-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-foreground/70"
>
{copied ? (
<>
<Check aria-hidden className="size-3.5" />
Copied
</>
) : (
<>
{current.code}
<Copy aria-hidden className="size-3.5 opacity-80" />
</>
)}
</button>
) : null}
{current.cta ? (
<a
href={current.cta.href}
className={cn(
buttonVariants({ variant: "link", size: "sm" }),
"group h-auto gap-1 px-0 text-sm font-semibold text-primary-foreground underline-offset-4 hover:text-primary-foreground/80"
)}
>
{current.cta.label}
<ArrowRight
aria-hidden
className="size-4 transition-transform group-hover:translate-x-0.5"
/>
</a>
) : null}
</div>
)
return (
<div
role="region"
aria-label="Site announcement"
className={cn(
"relative w-full bg-primary py-2.5 text-sm text-primary-foreground",
className
)}
>
<div className="mx-auto flex max-w-6xl items-center gap-3 px-4">
<div
aria-live="polite"
className="flex min-w-0 flex-1 items-center justify-center"
>
{reduced || messages.length <= 1 ? (
Body
) : (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active}
variants={fadeIn}
initial="hidden"
animate="visible"
exit="hidden"
transition={transition}
className="w-full"
>
{Body}
</motion.div>
</AnimatePresence>
)}
</div>
{dismissible ? (
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss announcement"
className="-mr-1 inline-flex size-6 shrink-0 items-center justify-center rounded-md text-primary-foreground/80 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-foreground/70"
>
<X aria-hidden className="size-4" />
</button>
) : null}
</div>
</div>
)
}