{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "announcement-bar",
  "title": "Announcement Bar",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "The thin inverted ink bar above everything — free-shipping thresholds, sale windows, drops — with a brand dot between messages. Static, or a marquee at 26s a loop that holds still under the pointer or focus and has a pause button; under reduced motion the marquee doesn't run. Dismissing folds it away and is remembered per version, so it stays gone across routes and visits until the announcement changes; pass `dismissed` from a cookie to skip it on the server.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/announcement-bar/announcement-bar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Pause, Play, X } from \"lucide-react\"\nimport { AnimatePresence, MotionConfig, motion, useAnimate, useReducedMotion } from \"motion/react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * The thin bar above everything: free-shipping thresholds, sale windows,\n * drops.\n *\n *   <AnnouncementBar items={[{ id: \"ship\", content: \"Free delivery over $300\" }]} />\n *   <AnnouncementBar items={items} variant=\"marquee\" />\n *\n * An inverted ink bar, square, with a brand dot between messages. Static\n * shows the messages side by side (the first alone on a phone); the marquee\n * runs them past at 26s a loop — slow enough to read — and stops while the\n * pointer or focus is on it, and for good with its pause button (anything\n * that moves for more than five seconds needs one). Under reduced motion\n * the marquee doesn't run at all; it shows as the static bar.\n *\n * Dismissing folds it away (Motion) and is remembered per `version`, so it\n * stays gone across routes and visits and comes back only when the\n * announcement changes. The server can't read localStorage: a dismissed bar\n * renders and folds away on load unless you pass `dismissed` from a cookie.\n */\n\nexport interface Announcement {\n  id: string\n  content: React.ReactNode\n  /** Makes the message a link. */\n  href?: string\n}\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\n\nexport interface AnnouncementBarProps extends React.HTMLAttributes<HTMLElement> {\n  items: Announcement[]\n  variant?: \"static\" | \"marquee\"\n  /** Seconds for one loop of the marquee. Defaults to 26. */\n  speed?: number\n  /** Shows the close button. Defaults to true. */\n  dismissible?: boolean\n  /** Where the dismissal is remembered. Defaults to \"ecomcn-announcement\". */\n  storageKey?: string\n  /** Change it and a dismissed bar comes back. Defaults to the items' ids. */\n  version?: string\n  /** Dismissed already — from a cookie you read on the server — so it never renders. */\n  dismissed?: boolean\n  onDismiss?: () => void\n  /** The landmark's name. Defaults to \"Announcements\". */\n  label?: string\n}\n\nconst listeners = new Set<() => void>()\nconst subscribeStorage = (onChange: () => void) => {\n  listeners.add(onChange)\n  window.addEventListener(\"storage\", onChange)\n  return () => {\n    listeners.delete(onChange)\n    window.removeEventListener(\"storage\", onChange)\n  }\n}\n\nfunction readDismissed(key: string, version: string) {\n  try {\n    return localStorage.getItem(key) === version\n  } catch {\n    return false // storage blocked: it just won't be remembered\n  }\n}\n\nexport function AnnouncementBar({\n  items,\n  variant = \"static\",\n  speed = 26,\n  dismissible = true,\n  storageKey = \"ecomcn-announcement\",\n  version: versionProp,\n  dismissed: dismissedProp,\n  onDismiss,\n  label = \"Announcements\",\n  className,\n  ...props\n}: AnnouncementBarProps) {\n  const reduce = useReducedMotion()\n  const version = versionProp ?? items.map((item) => item.id).join(\"|\")\n  const stored = React.useSyncExternalStore(\n    subscribeStorage,\n    () => readDismissed(storageKey, version),\n    () => false\n  )\n  const hidden = dismissedProp ?? stored\n\n  const dismiss = () => {\n    try {\n      localStorage.setItem(storageKey, version)\n    } catch {\n      /* private mode — dismissed for this page only */\n    }\n    listeners.forEach((notify) => notify())\n    onDismiss?.()\n  }\n\n  if (items.length === 0) return null\n  const marquee = variant === \"marquee\" && !reduce\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <AnimatePresence initial={false}>\n        {hidden ? null : (\n          <motion.aside\n            key=\"bar\"\n            aria-label={label}\n            data-slot=\"announcement-bar\"\n            data-variant={marquee ? \"marquee\" : \"static\"}\n            exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            transition={reduce ? { duration: 0.15 } : MORPH}\n            className={cn(\"overflow-hidden bg-foreground text-background\", className)}\n            {...(props as React.ComponentProps<typeof motion.aside>)}\n          >\n            <div className=\"relative flex h-10 items-center text-[12.5px] tracking-wide\">\n              {marquee ? <Marquee items={items} speed={speed} besideClose={dismissible} /> : <StaticMessages items={items} />}\n              {dismissible ? (\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  onClick={dismiss}\n                  aria-label=\"Dismiss the announcement\"\n                  className=\"absolute right-1 z-10 size-8 rounded-none text-background/70 hover:bg-background/10 hover:text-background focus-visible:ring-background/60\"\n                >\n                  <X className=\"size-4\" aria-hidden />\n                </Button>\n              ) : null}\n            </div>\n          </motion.aside>\n        )}\n      </AnimatePresence>\n    </MotionConfig>\n  )\n}\n\n/** The separator: a dot in your brand colour, ink-light when you have none. */\nfunction Dot() {\n  return <span aria-hidden className=\"mx-4 size-1 shrink-0 rounded-full bg-[var(--brand,currentColor)] opacity-90\" />\n}\n\nfunction Message({ item }: { item: Announcement }) {\n  return item.href ? (\n    <a href={item.href} className=\"underline-offset-4 hover:underline focus-visible:underline focus-visible:outline-none\">\n      {item.content}\n    </a>\n  ) : (\n    <span>{item.content}</span>\n  )\n}\n\n/** Side by side, centred; on a phone, the first message alone. */\nfunction StaticMessages({ items }: { items: Announcement[] }) {\n  return (\n    <ul className=\"flex w-full items-center justify-center px-12\">\n      {items.map((item, i) => (\n        <li key={item.id} className={cn(\"flex min-w-0 items-center\", i > 0 && \"max-sm:hidden\")}>\n          {i > 0 ? <Dot /> : null}\n          <span className=\"truncate\">\n            <Message item={item} />\n          </span>\n        </li>\n      ))}\n    </ul>\n  )\n}\n\n/**\n * Two copies of the messages, end to end, moved left by one copy's width\n * and repeated — seamless. The second copy is hidden from screen readers.\n */\nfunction Marquee({ items, speed, besideClose }: { items: Announcement[]; speed: number; besideClose: boolean }) {\n  const [scope, animate] = useAnimate<HTMLDivElement>()\n  const controls = React.useRef<ReturnType<typeof animate> | null>(null)\n  const [paused, setPaused] = React.useState(false)\n  const holds = React.useRef(0)\n\n  React.useEffect(() => {\n    controls.current = animate(scope.current, { x: [\"0%\", \"-50%\"] }, { duration: speed, ease: \"linear\", repeat: Infinity })\n    return () => controls.current?.stop()\n  }, [animate, scope, speed])\n\n  React.useEffect(() => {\n    if (paused) controls.current?.pause()\n    else if (holds.current === 0) controls.current?.play()\n  }, [paused])\n\n  // Hover and focus hold it still while they last; the button holds it for good.\n  const hold = () => {\n    holds.current += 1\n    controls.current?.pause()\n  }\n  const release = () => {\n    holds.current = Math.max(0, holds.current - 1)\n    if (holds.current === 0 && !paused) controls.current?.play()\n  }\n\n  const copy = (hiddenCopy: boolean) => (\n    <ul aria-hidden={hiddenCopy || undefined} className=\"flex shrink-0 items-center pr-0\">\n      {items.map((item) => (\n        <li key={item.id} className=\"flex items-center whitespace-nowrap\">\n          {hiddenCopy ? <span>{item.content}</span> : <Message item={item} />}\n          <Dot />\n        </li>\n      ))}\n    </ul>\n  )\n\n  return (\n    <>\n      <div\n        // Ends before the buttons — padding wouldn't clip the text running under them.\n        className={cn(\n          \"min-w-0 flex-1 overflow-hidden pl-3 [mask-image:linear-gradient(to_right,transparent,black_2.5rem,black_calc(100%-3rem),transparent)]\",\n          besideClose ? \"mr-20\" : \"mr-11\"\n        )}\n        onPointerEnter={hold}\n        onPointerLeave={release}\n        onFocus={hold}\n        onBlur={release}\n      >\n        <div ref={scope} className=\"flex w-max\">\n          {copy(false)}\n          {copy(true)}\n        </div>\n      </div>\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon\"\n        onClick={() => setPaused((p) => !p)}\n        aria-pressed={paused}\n        aria-label={paused ? \"Play the announcements\" : \"Pause the announcements\"}\n        className={cn(\n          \"absolute z-10 size-8 rounded-none text-background/70 hover:bg-background/10 hover:text-background focus-visible:ring-background/60\",\n          besideClose ? \"right-10\" : \"right-1\"\n        )}\n      >\n        {paused ? <Play className=\"size-3.5\" aria-hidden /> : <Pause className=\"size-3.5\" aria-hidden />}\n      </Button>\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/announcement-bar.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "marketing"
  ],
  "type": "registry:component"
}