
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
Announcement 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.
Also available for React ->$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.jsonnpx shadcn-vue@latest add @uipkge/announcementInstalls to:app/components/blocks/announcement/| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'floating-pill''top-bar''interactive-ticker' | 'interactive-ticker' | optional |
autoplay | boolean | true | optional |
class | HTMLAttributes['class'] | — | optional |
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
}<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>
<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>
<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>
<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>
<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>
<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