Menu

Change language

GitHub repo

Announcement Banner

blockmarketing

Interactive release, security, and maintenance broadcast center with rotating live ticker, CLI copy button, and floating pill / top-bar modes.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/announcement-banner.json
Manifest:https://uipkge.dev/r/lit/announcement-banner.jsonInstalls to:src/uip/blocks/announcement-banner/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
variant'floating-pill' | 'top-bar' | 'interactive-ticker'interactive-tickeroptional
autoplaybooleantrueoptional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

AnnouncementItem
interface AnnouncementItem {
  id: string
  category: 'release' | 'security' | 'maintenance' | 'feature'
  badgeText: string
  title: string
  actionLabel: string
  actionUrl?: string
  cliSnippet?: string
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-announcement-banner': UipAnnouncementBanner
  }
}

npm dependencies

Files installed (1)

  • src/uip/blocks/announcement-banner/announcement-banner.ts12.5 kB
    import { LitElement, css, html, nothing } from 'lit'
    import { ArrowRight, ChevronLeft, ChevronRight, Megaphone, Terminal, X } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect import: registers <uip-badge> for standalone use (consumers
    // who install only this block still get a working badge).
    import '../../components/badge/badge'
    
    interface AnnouncementItem {
      id: string
      category: 'release' | 'security' | 'maintenance' | 'feature'
      badgeText: string
      title: string
      actionLabel: string
      actionUrl?: string
      cliSnippet?: string
    }
    
    const announcements: AnnouncementItem[] = [
      {
        id: 'ann-1',
        category: 'release',
        badgeText: 'v2.4.0 Release',
        title: 'Tailwind CSS v4 OKLCH tokens & 281+ production workbenches are live.',
        actionLabel: 'Read Changelog',
        actionUrl: 'https://uipkge.dev/changelog',
        cliSnippet: 'npx shadcn-vue@latest add https://uipkge.dev/r/vue/init.json',
      },
      {
        id: 'ann-2',
        category: 'feature',
        badgeText: 'Dual-Framework',
        title: '100% Vue 3.5 & React 19 component parity achieved with zero runtime CSS.',
        actionLabel: 'Explore Parity Matrix',
        actionUrl: 'https://uipkge.dev/components',
        cliSnippet: 'npx shadcn@latest add https://uipkge.dev/r/react/init.json',
      },
      {
        id: 'ann-3',
        category: 'security',
        badgeText: 'Security Notice',
        title: 'Upstream Reka UI & Radix dependency security patches verified and updated.',
        actionLabel: 'Security Advisory',
        actionUrl: 'https://uipkge.dev/security',
      },
    ]
    
    /**
     * <uip-announcement-banner> — the registry AnnouncementBanner block as a web component.
     *
     * Class strings are React's AnnouncementBanner verbatim. React state
     * (useState) becomes @state fields; the autoplay interval runs from
     * `connectedCallback` / `updated` and is cleared in
     * `disconnectedCallback`. Dismissal is internal like React (no callbacks);
     * `reset()` restores a dismissed banner for demos.
     */
    export class UipAnnouncementBanner extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        variant: { reflect: true },
        autoplay: { type: Boolean },
        currentIndex: { state: true },
        dismissed: { state: true },
        isPaused: { state: true },
        copiedCli: { state: true },
      }
    
      variant: 'floating-pill' | 'top-bar' | 'interactive-ticker' = 'interactive-ticker'
      autoplay = true
      private currentIndex = 0
      private dismissed = false
      private isPaused = false
      private copiedCli = false
      private timer?: number
      private copyTimer?: number
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'announcement-banner')
        this.setAttribute('data-variant', this.variant)
        this.syncTimer()
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        this.stopTimer()
        if (this.copyTimer) window.clearTimeout(this.copyTimer)
      }
    
      protected updated(changed: Map<string, unknown>) {
        if (changed.has('variant')) this.setAttribute('data-variant', this.variant)
        if (changed.has('autoplay') || changed.has('isPaused')) this.syncTimer()
      }
    
      private syncTimer() {
        this.stopTimer()
        if (!this.autoplay || this.isPaused) return
        this.timer = window.setInterval(() => {
          this.currentIndex = (this.currentIndex + 1) % announcements.length
        }, 6000)
      }
    
      private stopTimer() {
        if (this.timer) window.clearTimeout(this.timer)
        this.timer = undefined
      }
    
      /** Restores a dismissed banner (demo affordance; React remounts via `key`). */
      reset() {
        this.dismissed = false
        this.currentIndex = 0
      }
    
      private nextAnnouncement() {
        this.currentIndex = (this.currentIndex + 1) % announcements.length
      }
    
      private prevAnnouncement() {
        this.currentIndex = (this.currentIndex - 1 + announcements.length) % announcements.length
      }
    
      private copyCliSnippet(snippet?: string) {
        if (!snippet) return
        try {
          navigator.clipboard?.writeText(snippet)?.catch(() => {})
        } catch {
          // Clipboard unavailable (insecure context): still flash the label.
        }
        this.copiedCli = true
        if (this.copyTimer) window.clearTimeout(this.copyTimer)
        this.copyTimer = window.setTimeout(() => {
          this.copiedCli = false
        }, 2000)
      }
    
      render() {
        if (this.dismissed) return nothing
        const current = announcements[this.currentIndex]
        return html`
          <div
            data-slot="announcement-banner"
            data-variant=${this.variant}
            role="region"
            aria-label="Announcement"
            class=${cn('relative z-50 transition-colors duration-200')}
            @mouseenter=${() => {
              this.isPaused = true
            }}
            @mouseleave=${() => {
              this.isPaused = false
            }}
          >
            ${this.variant === 'interactive-ticker'
              ? html`<div
                  class="bg-muted/70 border-border text-foreground flex items-center justify-between gap-3 overflow-hidden border-b px-4 py-2 text-xs shadow-xs backdrop-blur-md"
                >
                  <div class="mx-auto flex w-full max-w-6xl items-center justify-between gap-4">
                    <div class="flex shrink-0 items-center gap-3">
                      <div class="text-muted-foreground flex items-center gap-1.5 font-mono text-xs">
                        <span class="bg-primary size-2 animate-pulse rounded-full"></span>
                        <span class="text-foreground font-semibold">Live Dispatch</span>
                      </div>
    
                      <uip-badge
                        variant="secondary"
                        class=${cn(
                          'px-2 py-0.5 font-mono text-xs',
                          current.category === 'release' && 'bg-primary/10 text-primary border-primary/20',
                          current.category === 'security' && 'border-warning/20 bg-warning/10 text-warning',
                          current.category === 'feature' && 'border-success/20 bg-success/10 text-success',
                        )}
                        >${current.badgeText}</uip-badge
                      >
                    </div>
    
                    <div class="flex flex-1 items-center justify-center gap-2 truncate">
                      <p class="text-foreground truncate font-medium">${current.title}</p>
                      ${current.actionUrl
                        ? html`<a
                            href=${current.actionUrl}
                            class="text-primary hidden shrink-0 items-center gap-1 font-semibold hover:underline sm:inline-flex"
                          >
                            ${current.actionLabel} ${icon(ArrowRight, 'arrow-right', 'size-3')}
                          </a>`
                        : nothing}
                    </div>
    
                    <div class="flex shrink-0 items-center gap-2">
                      ${current.cliSnippet
                        ? html`<button
                            type="button"
                            class="bg-background border-border text-muted-foreground hover:text-foreground hidden items-center gap-1.5 rounded border px-2 py-0.5 font-mono text-xs transition-colors md:inline-flex"
                            @click=${() => this.copyCliSnippet(current.cliSnippet)}
                          >
                            ${icon(Terminal, 'terminal', 'text-primary size-3')}
                            <span>${this.copiedCli ? 'Copied CLI!' : 'Copy CLI'}</span>
                          </button>`
                        : nothing}
    
                      <div class="border-border bg-background flex items-center overflow-hidden rounded-md border">
                        <button
                          type="button"
                          class="hover:bg-muted text-muted-foreground hover:text-foreground p-1 transition-colors"
                          aria-label="Previous announcement"
                          @click=${this.prevAnnouncement}
                        >
                          ${icon(ChevronLeft, 'chevron-left', 'size-3.5')}
                        </button>
                        <span class="text-muted-foreground px-1.5 font-mono text-xs">
                          ${this.currentIndex + 1}/${announcements.length}
                        </span>
                        <button
                          type="button"
                          class="hover:bg-muted text-muted-foreground hover:text-foreground p-1 transition-colors"
                          aria-label="Next announcement"
                          @click=${this.nextAnnouncement}
                        >
                          ${icon(ChevronRight, 'chevron-right', 'size-3.5')}
                        </button>
                      </div>
    
                      <button
                        type="button"
                        aria-label="Dismiss banner"
                        class="text-muted-foreground hover:text-foreground hover:bg-muted rounded-md p-1 transition-colors"
                        @click=${() => {
                          this.dismissed = true
                        }}
                      >
                        ${icon(X, 'x', 'size-3.5')}
                      </button>
                    </div>
                  </div>
                </div>`
              : nothing}
            ${this.variant === 'floating-pill'
              ? html`<div class="mx-auto w-fit max-w-2xl px-4 py-2">
                  <div
                    class="bg-card/90 border-border text-foreground hover:border-primary/40 flex items-center gap-3 rounded-full border py-1.5 pr-2 pl-3 text-xs shadow-md backdrop-blur-md transition-colors"
                  >
                    <uip-badge variant="secondary" class="bg-primary/10 text-primary border-primary/20 px-2 py-0.5 font-mono text-xs"
                      >${current.badgeText}</uip-badge
                    >
                    <p class="text-foreground max-w-sm truncate font-medium">${current.title}</p>
                    ${current.actionUrl
                      ? html`<a
                          href=${current.actionUrl}
                          class="text-primary inline-flex shrink-0 items-center gap-1 font-semibold hover:underline"
                        >
                          ${current.actionLabel} ${icon(ArrowRight, 'arrow-right', 'size-3')}
                        </a>`
                      : nothing}
                    <button
                      type="button"
                      aria-label="Dismiss announcement"
                      class="text-muted-foreground hover:text-foreground hover:bg-muted rounded-full p-1 transition-colors"
                      @click=${() => {
                        this.dismissed = true
                      }}
                    >
                      ${icon(X, 'x', 'size-3.5')}
                    </button>
                  </div>
                </div>`
              : nothing}
            ${this.variant === 'top-bar'
              ? html`<div
                  class="bg-primary text-primary-foreground flex items-center justify-between gap-3 px-4 py-2 text-xs shadow-xs"
                >
                  <div class="mx-auto flex w-full max-w-6xl items-center justify-between gap-4">
                    <div class="flex items-center gap-2 truncate">
                      ${icon(Megaphone, 'megaphone', 'size-4 shrink-0')}
                      <span class="font-mono font-bold">[${current.badgeText}]</span>
                      <span class="truncate">${current.title}</span>
                    </div>
                    <div class="flex shrink-0 items-center gap-3">
                      ${current.actionUrl
                        ? html`<a
                            href=${current.actionUrl}
                            class="inline-flex items-center gap-1 font-semibold underline-offset-4 hover:underline"
                          >
                            ${current.actionLabel} ${icon(ArrowRight, 'arrow-right', 'size-3')}
                          </a>`
                        : nothing}
                      <button
                        type="button"
                        aria-label="Dismiss banner"
                        class="text-primary-foreground/80 hover:text-primary-foreground hover:bg-primary-foreground/15 rounded p-1 transition-colors"
                        @click=${() => {
                          this.dismissed = true
                        }}
                      >
                        ${icon(X, 'x', 'size-3.5')}
                      </button>
                    </div>
                  </div>
                </div>`
              : nothing}
          </div>
        `
      }
    }
    
    customElements.get('uip-announcement-banner') ||
      customElements.define('uip-announcement-banner', UipAnnouncementBanner)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-announcement-banner': UipAnnouncementBanner
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/announcement-banner.json