{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "banner",
  "title": "Banner",
  "description": "Announcement banner: dismissible sliding bar, hero pill, scrolling ticker or shimmer bar, with remembered dismissal.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "https://trytweenly.vercel.app/r/tweenly-theme.json"
  ],
  "files": [
    {
      "path": "registry/new-york/banner/banner.tsx",
      "content": "\"use client\"\n\nimport { Fragment, useState, useSyncExternalStore } from \"react\"\nimport { AnimatePresence, motion } from \"motion/react\"\nimport { ArrowRight, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\n\nexport type BannerVariant = \"bar\" | \"pill\" | \"marquee\" | \"shimmer\"\nexport type BannerTone = \"accent\" | \"neutral\" | \"inverted\"\nexport type BannerPosition = \"static\" | \"sticky\" | \"fixed\"\n\nexport interface BannerProps {\n  /** Visual style. Default: \"bar\" */\n  variant?: BannerVariant\n  /** The announcement. */\n  message?: React.ReactNode\n  /** Marquee variant: messages scrolled in sequence. Falls back to `message`. */\n  messages?: React.ReactNode[]\n  /** Short label in a badge before the message. Pill variant defaults to \"New\". */\n  badge?: React.ReactNode\n  /** Makes the message a link. */\n  href?: string\n  /** Call to action shown after the message. */\n  cta?: { label: string; href: string }\n  /** Show a close button. Default: false */\n  dismissible?: boolean\n  /** Remember dismissal in localStorage under this key. Without it, dismissal lasts until remount. */\n  storageKey?: string\n  /** Icon shown before the message. */\n  icon?: React.ReactNode\n  /** Color scheme. Default: \"accent\" */\n  tone?: BannerTone\n  /** Accent color for the accent tone and badges (any CSS color). Default: \"#ff4d12\" */\n  accent?: string\n  /** Positioning. Fixed pins to the top of the viewport. Default: \"static\" */\n  position?: BannerPosition\n  /** Marquee variant: seconds for one full loop. Default: 30 */\n  duration?: number\n  /** Called after the banner is dismissed. */\n  onDismiss?: () => void\n  className?: string\n}\n\nconst ENTER = [0.22, 1, 0.36, 1] as const\nconst IN_OUT = [0.76, 0, 0.24, 1] as const\nconst DISMISS_EVENT = \"tw-banner-dismiss\"\n\nfunction subscribe(onChange: () => void) {\n  window.addEventListener(\"storage\", onChange)\n  window.addEventListener(DISMISS_EVENT, onChange)\n  return () => {\n    window.removeEventListener(\"storage\", onChange)\n    window.removeEventListener(DISMISS_EVENT, onChange)\n  }\n}\n\nfunction readStored(key: string) {\n  try {\n    return window.localStorage.getItem(key) === \"dismissed\"\n  } catch {\n    return false\n  }\n}\n\nconst toneClass: Record<BannerTone, string> = {\n  accent: \"text-white\",\n  neutral: \"border-b border-border bg-panel text-foreground\",\n  inverted: \"bg-foreground text-background\",\n}\n\nexport function Banner({\n  variant = \"bar\",\n  message,\n  messages,\n  badge,\n  href,\n  cta,\n  dismissible = false,\n  storageKey,\n  icon,\n  tone = \"accent\",\n  accent = \"#ff4d12\",\n  position = \"static\",\n  duration = 30,\n  onDismiss,\n  className,\n}: BannerProps) {\n  const reduced = useReducedMotion()\n  const [closed, setClosed] = useState(false)\n  // Hidden on the server when a storage key is set, so a dismissed banner never flashes\n  const stored = useSyncExternalStore(\n    subscribe,\n    () => (storageKey ? readStored(storageKey) : false),\n    () => Boolean(storageKey)\n  )\n  const visible = !closed && !stored\n\n  const dismiss = () => {\n    setClosed(true)\n    if (storageKey) {\n      try {\n        window.localStorage.setItem(storageKey, \"dismissed\")\n        window.dispatchEvent(new Event(DISMISS_EVENT))\n      } catch {\n        // Storage unavailable (private mode); dismissal still applies for this session\n      }\n    }\n    onDismiss?.()\n  }\n\n  const isPill = variant === \"pill\"\n  const surfaceStyle = tone === \"accent\" && !isPill ? { backgroundColor: accent } : undefined\n\n  const badgeEl = (label: React.ReactNode) => (\n    <span\n      className={cn(\n        \"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide\",\n        tone === \"accent\" && !isPill ? \"bg-white/20 text-white\" : \"text-white\"\n      )}\n      style={tone === \"accent\" && !isPill ? undefined : { backgroundColor: accent }}\n    >\n      {label}\n    </span>\n  )\n\n  const closeButton = dismissible && (\n    <button\n      type=\"button\"\n      aria-label=\"Dismiss announcement\"\n      onClick={(e) => {\n        e.preventDefault()\n        e.stopPropagation()\n        dismiss()\n      }}\n      className={cn(\n        \"relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full opacity-70 outline-none transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current\",\n        isPill && \"-mr-1.5 ml-0.5\"\n      )}\n    >\n      <X className=\"size-3.5\" aria-hidden=\"true\" />\n    </button>\n  )\n\n  const ctaEl = cta && (\n    <a\n      href={cta.href}\n      className={cn(\n        \"group/cta relative z-10 inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-current\",\n        tone === \"neutral\" ? \"bg-foreground/[0.07] hover:bg-foreground/[0.12]\" : \"bg-white/15 hover:bg-white/25\",\n        tone === \"inverted\" && \"bg-background/15 hover:bg-background/25\"\n      )}\n    >\n      {cta.label}\n      <ArrowRight className=\"size-3 transition-transform duration-200 group-hover/cta:translate-x-0.5\" aria-hidden=\"true\" />\n    </a>\n  )\n\n  const text = href ? (\n    <a href={href} className=\"outline-none hover:underline hover:underline-offset-4 focus-visible:underline\">\n      {message}\n    </a>\n  ) : (\n    message\n  )\n\n  const positionClass =\n    position === \"fixed\"\n      ? isPill\n        ? \"fixed left-1/2 top-4 z-50 -translate-x-1/2\"\n        : \"fixed inset-x-0 top-0 z-50\"\n      : position === \"sticky\"\n        ? cn(\"sticky z-50\", isPill ? \"top-4\" : \"top-0\")\n        : \"relative\"\n\n  // ---------------------------------------------------------------- pill\n  if (isPill) {\n    const pillTone =\n      tone === \"inverted\"\n        ? \"border-transparent bg-foreground text-background\"\n        : \"border-border bg-card text-foreground\"\n    return (\n      <div className={cn(\"flex justify-center\", position !== \"fixed\" && \"w-full\", positionClass, className)}>\n        <AnimatePresence>\n          {visible && (\n            <motion.div\n              initial={reduced ? { opacity: 0 } : { opacity: 0, y: -10, scale: 0.96 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}\n              transition={{ duration: reduced ? 0.15 : 0.5, ease: ENTER }}\n            >\n              <div\n                className={cn(\n                  \"group/pill relative inline-flex max-w-full items-center gap-2.5 rounded-full border py-1 pl-1 pr-3 text-[13px] shadow-sm transition-colors has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-[#ff4d12]/60\",\n                  pillTone,\n                  href && (tone === \"inverted\" ? \"hover:bg-foreground/90\" : \"hover:bg-foreground/[0.04]\")\n                )}\n                style={tone === \"accent\" ? { borderColor: `color-mix(in oklab, ${accent} 35%, transparent)` } : undefined}\n              >\n                {badgeEl(badge ?? \"New\")}\n                {icon && <span className=\"flex shrink-0 [&_svg]:size-4\">{icon}</span>}\n                {href ? (\n                  // Stretched link: the whole pill is clickable, the close button stays separate\n                  <a href={href} className=\"truncate font-medium outline-none after:absolute after:inset-0 after:rounded-full\">\n                    {message}\n                  </a>\n                ) : (\n                  <span className=\"truncate font-medium\">{message}</span>\n                )}\n                <ArrowRight\n                  className=\"size-3.5 shrink-0 opacity-60 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover/pill:translate-x-1 group-hover/pill:opacity-100\"\n                  aria-hidden=\"true\"\n                />\n                {closeButton}\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    )\n  }\n\n  // ---------------------------------------------------------------- bars\n  const ticker = messages && messages.length > 0 ? messages : [message]\n\n  const body =\n    variant === \"marquee\" ? (\n      <div\n        className=\"group/ticker relative flex min-w-0 flex-1 overflow-hidden\"\n        // Alpha mask for edge fading, not a color gradient\n        style={{ maskImage: \"linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)\" }}\n      >\n        {reduced ? (\n          <div className=\"flex w-full items-center justify-center gap-6 truncate px-4\">{ticker[0]}</div>\n        ) : (\n          <div\n            className=\"flex w-max shrink-0 group-hover/ticker:[animation-play-state:paused]\"\n            style={{ animation: `tw-banner-ticker ${duration}s linear infinite` }}\n          >\n            {[0, 1].map((copy) => (\n              <div key={copy} aria-hidden={copy === 1 || undefined} className=\"flex shrink-0 items-center\">\n                {/* Repeat so a single copy always spans wide screens */}\n                {[0, 1, 2].map((r) =>\n                  ticker.map((m, i) => (\n                    <Fragment key={`${r}-${i}`}>\n                      <span className=\"whitespace-nowrap px-6\" aria-hidden={r > 0 || undefined}>\n                        {m}\n                      </span>\n                      <span aria-hidden=\"true\" className=\"size-1 shrink-0 rounded-full bg-current opacity-40\" />\n                    </Fragment>\n                  ))\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    ) : (\n      <div className=\"relative flex min-w-0 flex-1 items-center justify-center gap-3 overflow-hidden\">\n        {badge && badgeEl(badge)}\n        {icon && <span className=\"flex shrink-0 [&_svg]:size-4\">{icon}</span>}\n        <span className=\"truncate font-medium\">{text}</span>\n        {ctaEl}\n        {variant === \"shimmer\" && !reduced && (\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-y-0 left-0 w-[10%]\"\n            style={{\n              // Solid, hard-edged light band swept across the bar\n              backgroundColor: \"rgba(255,255,255,0.4)\",\n              mixBlendMode: tone === \"neutral\" ? \"normal\" : \"overlay\",\n              animation: \"tw-banner-shimmer 3.2s cubic-bezier(0.76, 0, 0.24, 1) infinite\",\n              opacity: tone === \"neutral\" ? 0.5 : 1,\n            }}\n          />\n        )}\n      </div>\n    )\n\n  return (\n    <div className={cn(positionClass, className)}>\n      <AnimatePresence>\n        {visible && (\n          <motion.div\n            className=\"overflow-hidden\"\n            initial={reduced ? { opacity: 0 } : { height: 0 }}\n            animate={reduced ? { opacity: 1 } : { height: \"auto\" }}\n            exit={reduced ? { opacity: 0 } : { height: 0 }}\n            transition={{ duration: reduced ? 0.15 : 0.5, ease: IN_OUT }}\n          >\n            <motion.div\n              role=\"region\"\n              aria-label=\"Announcement\"\n              initial={reduced ? false : { y: \"-100%\" }}\n              animate={{ y: 0 }}\n              exit={reduced ? undefined : { y: \"-100%\" }}\n              transition={{ duration: 0.55, ease: ENTER }}\n              className={cn(\"relative flex items-center gap-2 px-4 py-2 text-[13px]\", toneClass[tone])}\n              style={surfaceStyle}\n            >\n              {dismissible && <span aria-hidden=\"true\" className=\"size-6 shrink-0\" />}\n              {body}\n              {variant === \"marquee\" && ctaEl}\n              {dismissible ? closeButton : null}\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <style>{`\n        @keyframes tw-banner-ticker { to { transform: translateX(-50%) } }\n        @keyframes tw-banner-shimmer { 0% { transform: translateX(-150%) skewX(-20deg) } 60%, 100% { transform: translateX(1100%) skewX(-20deg) } }\n      `}</style>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/banner",
  "type": "registry:component"
}