Announcement
blockmarketingAnnouncement band set covering six placements: a dismissible top banner in solid, soft, and gradient tones, a split release-notes band, a rotating multi-message strip, a session countdown bar, a delayed corner card, and a sticky version-pill banner.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/announcement.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/announcement.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/announcement.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/announcement.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/announcementInstalls to:app/components/blocks/announcement/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 |
class | HTMLAttributes['class'] | — | 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
}npm dependencies
Files installed (6)
app/components/blocks/announcement/AnnouncementBanner.vue8.9 kB
<script setup lang="ts"> import { computed, onMounted, onUnmounted, ref } from 'vue' import type { HTMLAttributes } from 'vue' import { ArrowRight, ChevronLeft, ChevronRight, Megaphone, Terminal, X } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' import { cn } from '@/lib/utils' interface AnnouncementItem { id: string category: 'release' | 'security' | 'maintenance' | 'feature' badgeText: string title: string actionLabel: string actionUrl?: string cliSnippet?: string } interface Props { variant?: 'floating-pill' | 'top-bar' | 'interactive-ticker' autoplay?: boolean class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { variant: 'interactive-ticker', autoplay: true, }) 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', }, ] const currentIndex = ref(0) const dismissed = ref(false) const isPaused = ref(false) const copiedCli = ref(false) let timer: ReturnType<typeof setInterval> | undefined function startAutoplay() { if (!props.autoplay) return stopAutoplay() timer = setInterval(() => { if (!isPaused.value) { nextAnnouncement() } }, 6000) } function stopAutoplay() { if (timer) clearInterval(timer) } onMounted(() => { startAutoplay() }) onUnmounted(() => { stopAutoplay() }) function nextAnnouncement() { currentIndex.value = (currentIndex.value + 1) % announcements.length } function prevAnnouncement() { currentIndex.value = (currentIndex.value - 1 + announcements.length) % announcements.length } function copyCliSnippet(snippet?: string) { if (!snippet) return navigator.clipboard.writeText(snippet) copiedCli.value = true setTimeout(() => (copiedCli.value = false), 2000) } const current = computed(() => announcements[currentIndex.value]) </script> <template> <div v-if="!dismissed" data-slot="announcement-banner" :data-variant="props.variant" role="region" aria-label="Announcement" :class="cn('relative z-50 transition-colors duration-200', props.class)" @mouseenter="isPaused = true" @mouseleave="isPaused = false" > <!-- 1. Interactive Ticker Mode (Default) --> <div v-if="props.variant === 'interactive-ticker'" 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"> <!-- Left: Status Beacon & Item Indicator --> <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 rounded-full" /> <span class="text-foreground font-semibold">Live Dispatch</span> </div> <Badge variant="secondary" class="px-2 py-0.5 font-mono text-xs" :class="[ 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 }} </Badge> </div> <!-- Center: Dynamic Headline & Action Link --> <div class="flex flex-1 items-center justify-center gap-2 truncate"> <p class="text-foreground truncate font-medium"> {{ current.title }} </p> <a v-if="current.actionUrl" :href="current.actionUrl" class="text-primary hidden shrink-0 items-center gap-1 font-semibold hover:underline sm:inline-flex" > {{ current.actionLabel }} <ArrowRight class="size-3" /> </a> </div> <!-- Right: Ticker Navigation & CLI / Dismiss --> <div class="flex shrink-0 items-center gap-2"> <button v-if="current.cliSnippet" 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="copyCliSnippet(current.cliSnippet)" > <Terminal class="text-primary size-3" /> <span>{{ copiedCli ? 'Copied CLI!' : 'Copy CLI' }}</span> </button> <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="prevAnnouncement" > <ChevronLeft class="size-3.5" /> </button> <span class="text-muted-foreground px-1.5 font-mono text-xs"> {{ 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="nextAnnouncement" > <ChevronRight class="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="dismissed = true" > <X class="size-3.5" /> </button> </div> </div> </div> <!-- 2. Floating Pill Mode --> <div v-else-if="props.variant === 'floating-pill'" 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" > <Badge variant="secondary" class="bg-primary/10 text-primary border-primary/20 px-2 py-0.5 font-mono text-xs"> {{ current.badgeText }} </Badge> <p class="text-foreground max-w-sm truncate font-medium"> {{ current.title }} </p> <a v-if="current.actionUrl" :href="current.actionUrl" class="text-primary inline-flex shrink-0 items-center gap-1 font-semibold hover:underline" > {{ current.actionLabel }} <ArrowRight class="size-3" /> </a> <button type="button" aria-label="Dismiss announcement" class="text-muted-foreground hover:text-foreground hover:bg-muted rounded-full p-1 transition-colors" @click="dismissed = true" > <X class="size-3.5" /> </button> </div> </div> <!-- 3. Top Solid Bar Mode --> <div v-else 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"> <Megaphone class="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"> <a v-if="current.actionUrl" :href="current.actionUrl" class="inline-flex items-center gap-1 font-semibold underline-offset-4 hover:underline" > {{ current.actionLabel }} <ArrowRight class="size-3" /> </a> <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="dismissed = true" > <X class="size-3.5" /> </button> </div> </div> </div> </div> </template>app/components/blocks/announcement/AnnouncementBannerSplit.vue2.3 kB
<script setup lang="ts"> import { ArrowRight, CircleAlert, Info } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' withDefaults(defineProps<{ tone?: 'neutral' | 'attention' }>(), { tone: 'neutral' }) </script> <template> <section data-slot="announcement-banner-split" class="border-b" :class="tone === 'attention' ? 'border-border bg-muted' : 'border-border bg-card'" > <div class="mx-auto flex max-w-6xl flex-col gap-4 px-6 py-4 sm:flex-row sm:items-center sm:gap-8"> <!-- Message column carries the classification; the action column stays the same width whatever the message length, so a stack of these banners aligns down the page. --> <div class="flex min-w-0 flex-1 items-start gap-3"> <component :is="tone === 'attention' ? CircleAlert : Info" class="mt-0.5 size-4 shrink-0" :class="tone === 'attention' ? 'text-destructive' : 'text-muted-foreground'" aria-hidden="true" /> <div class="min-w-0"> <div class="flex flex-wrap items-center gap-2"> <Badge :variant="tone === 'attention' ? 'outline' : 'secondary'"> {{ tone === 'attention' ? 'Scheduled maintenance' : 'Release 2.4.0' }} </Badge> <span class="text-muted-foreground font-mono text-xs">Aug 14, 2026 · 02:00–04:00 UTC</span> </div> <p class="mt-1.5 text-sm leading-relaxed"> <template v-if="tone === 'attention'"> Query serving stays online. Scheduled materialisation pauses for the window and resumes automatically. </template> <template v-else> Command palette, table virtualisation past 10k rows, and AA-contrast sidebar badges in dark mode. </template> </p> </div> </div> <Separator orientation="vertical" class="hidden h-10 sm:block" /> <div class="flex shrink-0 items-center gap-2"> <Button variant="outline" size="sm"> {{ tone === 'attention' ? 'Status page' : 'Full changelog' }} <ArrowRight class="ml-1.5 size-3.5" aria-hidden="true" /> </Button> <Button variant="ghost" size="sm">Subscribe</Button> </div> </div> </section> </template>app/components/blocks/announcement/AnnouncementCornerToast.vue3.2 kB
<script setup lang="ts"> import { onBeforeUnmount, onMounted, ref } from 'vue' import { ArrowRight, Bell, X } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' const props = withDefaults( defineProps<{ /** Milliseconds before the card slides in. */ delay?: number /** sessionStorage key holding the dismissal. */ storageKey?: string }>(), { delay: 1200, storageKey: 'uipkge:corner-announcement-dismissed', }, ) // Hidden on the server and on first paint; the delay timer is what reveals it. const visible = ref(false) let timer: ReturnType<typeof setTimeout> | undefined function dismiss() { visible.value = false try { window.sessionStorage.setItem(props.storageKey, '1') } catch { // Blocked storage: the dismissal still holds for this page view. } } function wasDismissed() { try { return window.sessionStorage.getItem(props.storageKey) === '1' } catch { return false } } onMounted(() => { if (!wasDismissed()) timer = setTimeout(() => (visible.value = true), props.delay) }) onBeforeUnmount(() => clearTimeout(timer)) </script> <template> <Transition enter-active-class="transition duration-300 ease-out" enter-from-class="translate-y-4 opacity-0" leave-active-class="transition duration-200 ease-in" leave-to-class="translate-y-4 opacity-0" > <div v-show="visible" data-slot="announcement-corner-toast" class="fixed right-4 bottom-4 z-50 w-[22rem] max-w-[calc(100vw-2rem)]" role="complementary" aria-label="Product announcement" > <Card class="shadow-lg"> <CardContent class="p-4"> <div class="flex items-start gap-3"> <span class="border-border bg-muted flex size-10 shrink-0 items-center justify-center rounded-lg border" aria-hidden="true" > <Bell class="text-primary size-4" /> </span> <div class="min-w-0 flex-1"> <Badge variant="secondary" class="mb-1.5">New</Badge> <p class="text-sm font-semibold">Scoping now follows SCIM groups</p> <p class="text-muted-foreground mt-1 text-xs leading-relaxed"> Move someone between teams in your IdP and their dashboards follow within the sync window. </p> </div> <Button variant="ghost" size="icon" class="-mt-1 -mr-1 size-7 shrink-0" aria-label="Dismiss announcement" @click="dismiss" > <X class="size-3.5" aria-hidden="true" /> </Button> </div> <Separator class="my-3" /> <div class="flex items-center gap-2"> <Button size="sm" class="h-7"> Read the changelog <ArrowRight class="ml-1 size-3.5" aria-hidden="true" /> </Button> <Button variant="ghost" size="sm" class="h-7" @click="dismiss">Not now</Button> </div> </CardContent> </Card> </div> </Transition> </template>app/components/blocks/announcement/AnnouncementCountdownBar.vue3.5 kB
<script setup lang="ts"> import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { ArrowRight, Timer, X } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' const props = withDefaults( defineProps<{ /** ISO timestamp the countdown runs to. */ deadline?: string /** sessionStorage key holding the dismissal. */ storageKey?: string }>(), { deadline: '', storageKey: 'uipkge:countdown-dismissed', }, ) const dismissed = ref(false) // Null until mounted: the server has no clock the client agrees with, so the // digits only appear once the timer is running. const remaining = ref<number | null>(null) let timer: ReturnType<typeof setInterval> | undefined const target = computed(() => { const parsed = props.deadline ? Date.parse(props.deadline) : Number.NaN // Fall back to 72 hours out so the demo and a mis-set prop still read sensibly. return Number.isNaN(parsed) ? Date.now() + 72 * 60 * 60 * 1000 : parsed }) const parts = computed(() => { const ms = Math.max(remaining.value ?? 0, 0) const total = Math.floor(ms / 1000) return [ { label: 'd', value: Math.floor(total / 86400) }, { label: 'h', value: Math.floor((total % 86400) / 3600) }, { label: 'm', value: Math.floor((total % 3600) / 60) }, { label: 's', value: total % 60 }, ] }) function dismiss() { dismissed.value = true try { window.sessionStorage.setItem(props.storageKey, '1') } catch { // Blocked storage: the dismissal still holds for this page view. } } onMounted(() => { try { dismissed.value = window.sessionStorage.getItem(props.storageKey) === '1' } catch { dismissed.value = false } const tick = () => (remaining.value = target.value - Date.now()) tick() timer = setInterval(tick, 1000) }) onBeforeUnmount(() => clearInterval(timer)) </script> <template> <div v-if="!dismissed" data-slot="announcement-countdown-bar" class="border-border bg-muted/60 border-b backdrop-blur" > <div class="mx-auto flex min-h-11 max-w-6xl flex-wrap items-center gap-x-4 gap-y-2 px-6 py-2 text-sm"> <Badge variant="secondary" class="shrink-0 gap-1.5"> <Timer class="size-3" aria-hidden="true" /> Ends soon </Badge> <p class="min-w-0"> <span class="font-medium">Annual plans are 20% off</span> <span class="text-muted-foreground"> for teams that start before the quarter closes.</span> </p> <!-- tabular-nums + fixed-width cells keep the bar from reflowing each second. --> <div v-if="remaining !== null" class="text-muted-foreground flex items-center gap-1 font-mono text-xs"> <span v-for="part in parts" :key="part.label" class="tabular-nums"> <span class="text-foreground inline-block min-w-[2ch] text-right font-semibold"> {{ String(part.value).padStart(2, '0') }} </span >{{ part.label }} </span> </div> <div class="ml-auto flex shrink-0 items-center gap-1"> <Button variant="ghost" size="sm" class="h-7"> Claim it <ArrowRight class="ml-1 size-3.5" aria-hidden="true" /> </Button> <Separator orientation="vertical" class="hidden h-5 sm:block" /> <Button variant="ghost" size="icon" class="size-7" aria-label="Dismiss announcement" @click="dismiss"> <X class="size-3.5" aria-hidden="true" /> </Button> </div> </div> </div> </template>app/components/blocks/announcement/AnnouncementRotatingStrip.vue2.5 kB
<script setup lang="ts"> import { onBeforeUnmount, onMounted, ref } from 'vue' import { ArrowRight } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' const props = withDefaults(defineProps<{ interval?: number }>(), { interval: 6000 }) const messages = [ { label: 'New', text: 'Row-level scoping now evaluates against SCIM groups.', cta: 'Changelog' }, { label: 'Event', text: 'Live walkthrough of the five-week rollout, every Thursday.', cta: 'Save a seat' }, { label: 'Docs', text: 'The reconciliation checklist for your first close is published.', cta: 'Read it' }, ] const index = ref(0) const paused = ref(false) let timer: ReturnType<typeof setInterval> | undefined function advance() { index.value = (index.value + 1) % messages.length } function start() { stop() timer = setInterval(() => { if (!paused.value) advance() }, props.interval) } function stop() { clearInterval(timer) timer = undefined } onMounted(start) onBeforeUnmount(stop) </script> <template> <div data-slot="announcement-rotating-strip" class="border-border bg-muted/60 border-b backdrop-blur" @mouseenter="paused = true" @mouseleave="paused = false" @focusin="paused = true" @focusout="paused = false" > <div class="mx-auto flex h-11 max-w-6xl items-center gap-3 px-6 text-sm"> <!-- aria-live announces the rotation to screen readers without stealing focus. --> <div class="flex min-w-0 flex-1 items-center gap-3" aria-live="polite" aria-atomic="true"> <Badge variant="secondary" class="shrink-0">{{ messages[index].label }}</Badge> <p class="min-w-0 truncate">{{ messages[index].text }}</p> <Button variant="link" size="sm" class="hidden h-auto shrink-0 p-0 text-xs sm:inline-flex"> {{ messages[index].cta }} <ArrowRight class="ml-1 size-3" aria-hidden="true" /> </Button> </div> <div class="flex shrink-0 items-center gap-1.5"> <button v-for="(message, i) in messages" :key="message.text" type="button" class="focus-visible:ring-ring size-1.5 rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" :class="i === index ? 'bg-foreground' : 'bg-muted-foreground/40 hover:bg-muted-foreground'" :aria-label="`Show announcement ${i + 1} of ${messages.length}`" :aria-current="i === index ? 'true' : undefined" @click="index = i" /> </div> </div> </div> </template>app/components/blocks/announcement/BannerAnnouncementSticky.vue1.7 kB
<script setup lang="ts"> import { ref } from 'vue' import { ArrowRight, Megaphone, X } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' const isVisible = ref(true) function dismissBanner() { isVisible.value = false } </script> <template> <aside v-if="isVisible" data-slot="banner-announcement-sticky" class="border-border bg-card/90 relative z-50 border-b px-4 py-2.5 backdrop-blur-md transition-colors sm:px-6" > <div class="mx-auto flex max-w-7xl items-center justify-between gap-4 font-mono text-xs"> <!-- Left / Center Content --> <div class="flex min-w-0 flex-1 items-center justify-center gap-3 sm:justify-start"> <Badge variant="secondary" class="bg-primary/10 text-primary border-primary/20 shrink-0 px-2 py-0.5 text-xs font-bold tracking-wider uppercase" > v2.4.0 Release </Badge> <p class="text-foreground flex items-center gap-1.5 truncate"> <Megaphone class="text-muted-foreground hidden size-3 shrink-0 sm:inline" /> <span>Announcing 450+ Marketing & SaaS Blocks with Tailwind v4 OKLCH token engine.</span> </p> <a href="#" class="text-primary inline-flex shrink-0 items-center gap-1 font-bold hover:underline"> <span>Read Release Notes</span> <ArrowRight class="size-3" /> </a> </div> <!-- Dismiss Trigger Button --> <button type="button" class="hover:bg-muted text-muted-foreground hover:text-foreground flex size-6 shrink-0 items-center justify-center rounded-md transition-colors" aria-label="Dismiss banner" @click="dismissBanner" > <X class="size-3.5" /> </button> </div> </aside> </template>
Raw manifest:https://uipkge.dev/r/vue/announcement.json