# Announcement bar

> A thin band above the site header with one short message, an optional badge and link, an optional dismiss button remembered per message id, and an expiry the server can decide.

- ID: `cmp_announcement_bar_01`
- Slug: `announcement-bar-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30T23:47:36Z
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: section
- Primary category: `navigation`
- Detail page: https://pagesugar.com/components/announcement-bar-01?variant=neutral
- Preview: https://pagesugar.com/preview/announcement-bar-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-dc9765b650a279ce6e61b7a991ed23070f1f2f8fddfca91708f16630c9edfcb0` |
| `blue` | Blue | no | `sha256-836a9de01c7cc2bfe5f14d898f53f112a26cf485b3272d7735c4631d2842b36e` |
| `amber` | Amber | no | `sha256-7537634b1b241ef02a4af30d5bb9807cd48d66916a2ee9dd3d76d2c7c071060b` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/announcement-bar-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Supply a stable message id, the message and an optional link; place the bar above your site header. Dismissal is stored in localStorage and expiry is checked against the now prop. The bar does not fetch or schedule announcements, track views or clicks, or rotate several messages, and it does not set cookies itself: do that in onDismiss if the server should stop rendering it.

Required props: `id`, `message`

```svelte
<script lang="ts">
	import AnnouncementBar from '$lib/components/announcement-bar-01/AnnouncementBar.svelte';

	let { data } = $props();
</script>

{#if !data.announcementDismissed}
	<AnnouncementBar
		id="spring-release"
		badge="New"
		message="Version 3 ships on 14 October."
		link={{ label: 'Read the release notes', href: '/changelog' }}
		expiresAt="2026-11-01T00:00:00Z"
		now={data.now}
		onDismiss={(id) => {
			document.cookie = `announcement-dismissed=${encodeURIComponent(id)}; path=/; max-age=31536000; samesite=lax`;
		}}
	/>
{/if}
```

Limitations:

- Before hydration the dismiss button does nothing; the message and link work without JavaScript.
- A bar dismissed on an earlier visit is hidden before its markup is parsed by a small inline script placed ahead of it. A Content Security Policy that forbids inline scripts blocks it, and the bar then hides only after hydration; read a cookie set in onDismiss on the server and skip rendering to avoid that.
- Without the now prop (or with an unparseable one), an expired bar is rendered by the server and removed after hydration. Expiry is checked against one moment: a page left open does not hide the bar when the deadline passes.
- Dismissal is per browser (localStorage key announcement-bar:dismissed:\<id\>); it does not follow a signed-in person across devices.
- One message only; the link label is kept whole with its arrow and wraps inside itself only when it is longer than the line.

## Usage guide

### Announcement bar

Place the bar above your site header, full width. It holds one sentence: an optional badge or
icon, the message, and an optional link.

#### Dismissal without a flash

Dismissal is stored in `localStorage` under `announcement-bar:dismissed:<id>`. A bar
dismissed on an earlier visit is hidden by a tiny inline script that runs just before the bar's
markup is parsed, so it never paints, and is removed on hydration. Change `id` to show a new announcement to everyone
again.

If your site sends a Content Security Policy without `'unsafe-inline'` for scripts, that
script is blocked and the bar hides only after hydration. Resolve dismissal on the server
instead:

```svelte
<!-- +layout.svelte -->
{#if !data.announcementDismissed}
	<AnnouncementBar
		id="halcyon-3"
		message="Halcyon 3 ships on 14 October."
		now={data.now}
		onDismiss={(id) => {
			document.cookie = `announcement-dismissed=${encodeURIComponent(id)}; path=/; max-age=31536000; samesite=lax`;
		}}
	/>
{/if}
```

```ts
// +layout.server.ts
export const load = ({ cookies }) => ({
	now: new Date().toISOString(),
	// Compare with the id the layout renders; a new id shows again.
	announcementDismissed: cookies.get('announcement-dismissed') === 'halcyon-3'
});
```

#### Expiry

Pass `expiresAt` and the request time as `now` from a server load. The comparison happens
during SSR, so an expired bar produces no markup at all. Without `now`, the server cannot know
the time the component should use, so the bar renders and is removed after hydration.

#### Stacking with a utility bar

The announcement goes first, above any utility bar and the header. Match the gutters
(`px-4 sm:px-6 lg:px-8`) and container width (`max-w-7xl`) to your header so the sentence and
the brand share a left edge when `align="start"`.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | Stable message id, used as the dismissal key. Change it, on the same instance or a new one, to show a new announcement to people who dismissed the last one. |
| `message` | `string` | yes |  | The announcement text, ideally one sentence under about 90 characters. Wraps rather than truncates. |
| `link` | `{ label: string; href: string }` | no |  | Optional link after the message, with a trailing arrow. The label must make sense out of context. |
| `badge` | `string` | no |  | Short label in a filled pill before the message, such as "New". Ignored when leading is given. |
| `leading` | `Snippet` | no |  | Badge or icon markup before the message, sitting on the first line. Replaces badge. |
| `dismissible` | `boolean` | no | `true` | Shows the dismiss button and remembers dismissal per id. False for notices that should stay until they expire. |
| `expiresAt` | `string` | no |  | ISO timestamp after which the bar is not rendered. Compared with now, never with Date.now during SSR. |
| `now` | `string` | no |  | Request time from the server (ISO) for the expiry check. Omitted, expiry is checked after hydration. |
| `onDismiss` | `(id: string) => void` | no |  | Called after dismissal with the message id, for example to set a cookie so the server can skip rendering. |
| `align` | 'center' \| 'start' | no | `'center'` | Where the sentence sits from the sm breakpoint up. Below it the text always starts at the gutter so it keeps the width. |
| `label` | `string` | no | `'Announcement'` | Accessible name of the region. Translate for other languages. |
| `dismissLabel` | `string` | no | `'Dismiss announcement'` | Accessible name of the dismiss button. Translate for other languages. |

## Customization

Change content through props and the leading snippet, retone through six --announcement-bar-\* variables on any ancestor, and edit the grid columns in the source for a different width or control size.

- Colours: set --announcement-bar-surface, -ink, -muted, -accent, -on-accent and -hairline on an ancestor. Keep ink and accent on surface at 4.5:1 or better, and on-accent on accent at 4.5:1; the dismiss hover fill is mixed from ink.
- Brand band: a mid-tone brand colour works as the surface with white ink and accent, as the blue palette shows; a pale tint wants dark ink, as in the amber palette.
- On a dark page: keep the default dark surface and set --announcement-bar-hairline to rgb(255 255 255 / 0.12) so the band still separates from a dark header below it.
- Alignment: align="start" lines the sentence up with a left-aligned header; center balances it with a spacer the width of the dismiss button.
- Width: content is capped at max-w-7xl with the page gutters (px-4 sm:px-6 lg:px-8); match your header. A normal sentence is capped at max-w-4xl; a message and link over 110 characters are set as a 36em paragraph with one start edge. Change the threshold in the long constant.
- Server-side dismissal: in onDismiss set a cookie with the id, read it in your root layout's load and skip rendering the bar. Pass now={new Date().toISOString()} from the same load for expiry.
- Links: link.href is rendered as given; the label reads as a destination ("Read the release notes"), not "Click here".

| Token | Public CSS variable |
| --- | --- |
| `surface` | `--announcement-bar-surface` |
| `ink` | `--announcement-bar-ink` |
| `muted` | `--announcement-bar-muted` |
| `accent` | `--announcement-bar-accent` |
| `onAccent` | `--announcement-bar-on-accent` |
| `hairline` | `--announcement-bar-hairline` |

## Accessibility

- Rendered as a section named by label ("Announcement"), so it is a region landmark. It is not role=alert or a live region, because it is present on load.
- The dismiss button is named by dismissLabel ("Dismiss announcement"); its icon is hidden from assistive technology.
- On dismiss, focus moves to the first element after the bar that accepts focus (usually a skip link or the header brand), skipping disabled, inert and hidden elements, else to the nearest one before it. A page with no other focusable element has nowhere to send it.
- Link text must make sense out of context; that is the consumer's responsibility. The trailing arrow and the separator dot are decorative.
- The link and the dismiss button show a 2 px focus outline in the accent colour with a 2 px offset; the dismiss button's hit area is 44 px square.
- The collapse on dismiss runs 160 ms on the exit curve and is instant under prefers-reduced-motion.
- Set lang and dir on the page (or an ancestor) for other languages, and translate label and dismissLabel.

Known limitations:

- The dismiss button has no effect until JavaScript has run.
- A strict Content Security Policy blocks the inline script that hides a previously dismissed bar before first paint.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `AnnouncementBar.svelte`
- Suggested directory: `src/lib/components/announcement-bar-01`
- Files: 1
- Artifact digest: `sha256-dc9765b650a279ce6e61b7a991ed23070f1f2f8fddfca91708f16630c9edfcb0`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `AnnouncementBar.svelte`

Role: entry · 10917 bytes · SHA-256 `c236bd1e85f1babf08b4a36f53d12176225be080723d99658aeba8b4077fc37a`

```svelte
<!--
	Announcement bar: one short site-wide message in a thin band above the site header, with an
	optional badge, link and dismiss control. The message is set as a single sentence (badge,
	text, then the link with its arrow joined to the last word), centred on the page by a spacer
	that mirrors the dismiss control, so the line centres on the band and not on what is left.
-->
<script lang="ts">
	import { onMount, type Snippet } from 'svelte';
	import { cubicIn } from 'svelte/easing';

	interface AnnouncementLink {
		label: string;
		href: string;
	}

	interface Props {
		/** Stable message id, used as the dismissal key. Change it to show a new announcement. */
		id: string;
		/** The announcement text. Keep it to one sentence. */
		message: string;
		/** Optional link set after the message. */
		link?: AnnouncementLink;
		/** Short label in a pill before the message, such as "New". */
		badge?: string;
		/** Badge or icon markup before the message; replaces `badge` when both are given. */
		leading?: Snippet;
		/** Shows the dismiss button. */
		dismissible?: boolean;
		/** ISO timestamp after which the bar is not rendered. */
		expiresAt?: string;
		/** Request time from the server for the expiry check; omitted, expiry is checked after hydration. */
		now?: string;
		/** Called after dismissal with the message id, for example to set a cookie. */
		onDismiss?: (id: string) => void;
		/** Where the sentence sits from the sm breakpoint up; below it the text always starts at the edge. */
		align?: 'center' | 'start';
		/** Accessible name of the region. */
		label?: string;
		/** Accessible name of the dismiss button. */
		dismissLabel?: string;
	}

	let {
		id,
		message,
		link,
		badge,
		leading,
		dismissible = true,
		expiresAt,
		now,
		onDismiss,
		align = 'center',
		label = 'Announcement',
		dismissLabel = 'Dismiss announcement'
	}: Props = $props();

	const uid = $props.id();
	const domId = `${uid}-announcement`;
	const guardId = `${domId}-guard`;
	const storageKey = $derived(`announcement-bar:dismissed:${id}`);

	let root = $state<HTMLElement>();
	let dismissed = $state(false);
	/** Set after hydration when no server time was supplied. */
	let clientNow = $state<number>();
	/** Collapse length; zero until a person dismisses, so hydration never animates the bar away. */
	let outDuration = $state(0);

	const expired = $derived.by(() => {
		const end = expiresAt ? Date.parse(expiresAt) : NaN;
		if (Number.isNaN(end)) return false;
		const serverNow = now ? Date.parse(now) : NaN;
		const at = Number.isNaN(serverNow) ? clientNow : serverNow;
		return at !== undefined && at >= end;
	});

	const centred = $derived(align === 'center');
	/*
	 * A sentence too long for one line at the measure below is set as a short paragraph: still
	 * centred on the band as a block, but its lines start on one edge so the eye finds each one.
	 */
	const long = $derived(message.length + (link?.label.length ?? 0) > 110);

	/*
	 * The last word of the message carries the separator dot, so when the link wraps the dot
	 * ends the line like punctuation instead of leading the next one.
	 */
	const words = $derived.by(() => {
		const text = message.trimEnd();
		const at = text.lastIndexOf(' ');
		return at < 0
			? { head: '', last: text }
			: { head: text.slice(0, at + 1), last: text.slice(at + 1) };
	});

	/*
	 * Runs as the server HTML is parsed, before the bar's own markup arrives, so a bar dismissed
	 * on an earlier visit never paints on reload. It adds a style rule hiding this bar; hydration
	 * then removes the bar and the rule. A Content Security Policy without 'unsafe-inline' blocks
	 * it, and the bar hides after hydration instead; gate rendering on a cookie set in onDismiss
	 * to avoid that.
	 */
	const guard = $derived(
		dismissible
			? `<script>try{if(localStorage.getItem(${json(storageKey)})){var s=document.createElement('style');s.id=${json(guardId)};s.textContent='[id='+${json(JSON.stringify(domId))}+']{display:none!important}';document.head.appendChild(s)}}catch(e){}</` +
					`script>`
			: ''
	);
	function json(value: string) {
		return JSON.stringify(value).replace(/</g, '\\u003c');
	}

	onMount(() => {
		document.getElementById(guardId)?.remove();
		if (!now || Number.isNaN(Date.parse(now))) clientNow = Date.now();
	});

	// Reconciles with storage on hydration and whenever the id changes, so a new id shows again.
	$effect(() => {
		const key = storageKey;
		outDuration = 0;
		if (!dismissible) {
			dismissed = false;
			return;
		}
		try {
			dismissed = Boolean(localStorage.getItem(key));
		} catch {
			// Storage blocked (private mode, disabled cookies): the bar simply shows.
			dismissed = false;
		}
	});

	const FOCUSABLE =
		'a[href], button, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])';

	/**
	 * Moves focus to the first element after the bar that actually takes it, else the nearest one
	 * before it, so focus is not dropped to the body when the bar is removed.
	 */
	function moveFocusFrom(bar: HTMLElement) {
		const all = [...document.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
			(el) =>
				!bar.contains(el) &&
				!el.matches(':disabled') &&
				!el.closest('[inert]') &&
				el.getClientRects().length > 0
		);
		const after = all.filter(
			(el) => bar.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING
		);
		const before = all
			.filter((el) => bar.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)
			.reverse();
		for (const el of [...after, ...before]) {
			el.focus();
			if (document.activeElement === el) return;
		}
	}

	function dismiss() {
		if (root) moveFocusFrom(root);
		try {
			localStorage.setItem(storageKey, '1');
		} catch {
			// Storage blocked: the bar hides for this page view only.
		}
		outDuration = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 160;
		dismissed = true;
		onDismiss?.(id);
	}

	/** Height and opacity collapse on the exit curve; zero length under reduced motion. */
	function collapse(node: HTMLElement, { duration }: { duration: number }) {
		const height = node.offsetHeight;
		return {
			duration,
			easing: cubicIn,
			css: (t: number) => `height:${t * height}px;opacity:${t};overflow:hidden`
		};
	}
</script>

{#if !dismissed && !expired}
	<!-- eslint-disable-next-line svelte/no-at-html-tags -- a fixed script built from JSON-escaped strings -->
	{@html guard}
	<section
		bind:this={root}
		id={domId}
		aria-label={label}
		class="announcement-bar border-b border-(--_hairline) bg-(--_surface) text-(--_ink)"
		out:collapse={{ duration: outDuration }}
	>
		<div
			class={[
				'mx-auto grid max-w-7xl items-start px-4 sm:px-6 lg:px-8',
				!dismissible && 'grid-cols-1',
				dismissible && !centred && 'grid-cols-[minmax(0,1fr)_2.75rem]',
				dismissible &&
					centred &&
					'grid-cols-[minmax(0,1fr)_2.75rem] sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem]'
			]}
		>
			{#if dismissible && centred}
				<!-- Mirrors the dismiss button so the sentence centres on the band. -->
				<span class="hidden sm:block" aria-hidden="true"></span>
			{/if}
			<p
				class={[
					'announcement-bar__text py-3 text-sm leading-5 break-words',
					long ? 'max-w-[36em] text-pretty' : 'max-w-4xl',
					centred && 'sm:mx-auto',
					centred && !long ? 'text-pretty sm:text-center sm:text-balance' : 'text-pretty'
				]}
			>
				{#if leading}
					<span class="me-1 inline-flex h-5 items-center align-top">{@render leading()}</span>
				{:else if badge}
					<span
						class="me-1 inline-flex h-5 items-center rounded-full bg-(--_accent) px-2 align-top text-xs font-medium text-(--_on-accent)"
						>{badge}</span
					>
				{/if}
				<span
					>{words.head}<span class="whitespace-nowrap"
						>{words.last}{#if link}<span
								class={['ms-2 me-2 hidden text-(--_muted)', !long && 'sm:inline']}
								aria-hidden="true">·</span
							>{/if}</span
					></span
				>{#if link}<wbr /><a
						href={link.href}
						class={[
							"group/link relative mt-2 block w-fit rounded-sm font-medium text-(--_accent) decoration-1 underline-offset-4 transition-[color] duration-150 ease-(--_ease) after:absolute after:inset-x-0 after:-inset-y-3 after:content-[''] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) focus-visible:outline-solid",
							!long && 'sm:mt-0 sm:inline sm:after:hidden'
						]}
						><span>{link.label}</span>&#8288;<svg
							class="ms-1 inline-block size-3.5 align-[-0.125em] transition-transform duration-150 ease-(--_ease) motion-safe:group-hover/link:translate-x-0.5 motion-reduce:transition-none rtl:-scale-x-100 rtl:motion-safe:group-hover/link:-translate-x-0.5"
							viewBox="0 0 16 16"
							fill="none"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							stroke-linejoin="round"
							aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" /></svg
						></a
					>
				{/if}
			</p>
			{#if dismissible}
				<button
					type="button"
					class="group/dismiss -me-3 flex size-11 items-center justify-center rounded-lg text-(--_muted) outline-none"
					aria-label={dismissLabel}
					onclick={dismiss}
				>
					<span
						class="flex size-7 items-center justify-center rounded-md transition-[background-color,color,scale] duration-150 ease-(--_ease) group-hover/dismiss:bg-(--_press) group-hover/dismiss:text-(--_ink) group-focus-visible/dismiss:text-(--_ink) group-focus-visible/dismiss:outline-2 group-focus-visible/dismiss:outline-offset-2 group-focus-visible/dismiss:outline-(--_accent) group-focus-visible/dismiss:outline-solid group-active/dismiss:scale-[.94] group-active/dismiss:duration-75 motion-reduce:group-active/dismiss:scale-100"
					>
						<svg
							class="size-4"
							viewBox="0 0 16 16"
							fill="none"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg
						>
					</span>
				</button>
			{/if}
		</div>
	</section>
{/if}

<style>
	/* Public tokens: set --announcement-bar-* on this bar or any ancestor to retone it. */
	.announcement-bar {
		--_surface: var(--announcement-bar-surface, #09090b);
		--_ink: var(--announcement-bar-ink, #fafafa);
		--_muted: var(--announcement-bar-muted, #a1a1aa);
		--_accent: var(--announcement-bar-accent, #fafafa);
		--_on-accent: var(--announcement-bar-on-accent, #09090b);
		--_hairline: var(--announcement-bar-hairline, rgb(255 255 255 / 0.1));
		/* Hover fill of the dismiss control, mixed from the ink so every palette gets one. */
		--_press: color-mix(in oklab, var(--_ink) 14%, transparent);
		/* The state-change curve shared by the link, its arrow and the dismiss control. */
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}

	/* Japanese breaks at phrase boundaries rather than mid-word. */
	.announcement-bar__text:lang(ja) {
		word-break: auto-phrase;
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-dc9765b650a279ce6e61b7a991ed23070f1f2f8fddfca91708f16630c9edfcb0`
- Entry: `AnnouncementBar.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_announcement_bar_01/1.0.0/neutral/sha256-dc9765b650a279ce6e61b7a991ed23070f1f2f8fddfca91708f16630c9edfcb0/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_announcement_bar_01/1.0.0/neutral/sha256-dc9765b650a279ce6e61b7a991ed23070f1f2f8fddfca91708f16630c9edfcb0/bundle.zip (7637 bytes, sha256 `89e5dec3e6cba9b34c26649a76811b656cb2cc1159da06816658b0738fad4b00`)

Files:

- `AnnouncementBar.svelte` (entry, 10917 bytes): https://pagesugar.com/artifacts/cmp_announcement_bar_01/1.0.0/neutral/sha256-dc9765b650a279ce6e61b7a991ed23070f1f2f8fddfca91708f16630c9edfcb0/source/AnnouncementBar.svelte
