Announcement Banner
blockmarketingInteractive release, security, and maintenance broadcast center with rotating live ticker, CLI copy button, and floating pill / top-bar modes.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/announcement-banner.json$npx shadcn@latest add https://uipkge.dev/r/lit/announcement-banner.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/announcement-banner.json$bunx shadcn@latest add https://uipkge.dev/r/lit/announcement-banner.jsonManifest:
https://uipkge.dev/r/lit/announcement-banner.jsonInstalls to:src/uip/blocks/announcement-banner/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'floating-pill' | 'top-bar' | 'interactive-ticker' | interactive-ticker | optional |
autoplay | boolean | true | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
AnnouncementIteminterface AnnouncementItem {
id: string
category: 'release' | 'security' | 'maintenance' | 'feature'
badgeText: string
title: string
actionLabel: string
actionUrl?: string
cliSnippet?: string
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-announcement-banner': UipAnnouncementBanner
}
}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