Promo

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-01
Open in v0

Code

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>
  )
}