Skip to content
Download ZIP

Amber palette · 7.5 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_announcement_bar_01 · version 1.0.0 · Amber palette
Using the PageSugar MCP server, fetch component cmp_announcement_bar_01 version 1.0.0 with variant "amber", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Amber
Version
1.0.0
Digest
Full digest
sha256-7537634b1b241ef02a4af30d5bb9807cd48d66916a2ee9dd3d76d2c7c071060b
AnnouncementBar.svelte Svelte · 10.6 KB Raw
<!--
	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, #fde68a);
		--_ink: var(--announcement-bar-ink, #451a03);
		--_muted: var(--announcement-bar-muted, #78350f);
		--_accent: var(--announcement-bar-accent, #451a03);
		--_on-accent: var(--announcement-bar-on-accent, #fef3c7);
		--_hairline: var(--announcement-bar-hairline, #451a0324);
		/* 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>

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.

Suggested location
src/lib/components/announcement-bar-01
Required props
idmessage

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.

Example

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}

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}
TypeScript
// +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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
idstringYesNoneStable 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.
messagestringYesNoneThe announcement text, ideally one sentence under about 90 characters. Wraps rather than truncates.
badgestringNoNoneShort label in a filled pill before the message, such as "New". Ignored when leading is given.
leadingSnippetNoNoneBadge or icon markup before the message, sitting on the first line. Replaces badge.
dismissiblebooleanNotrueShows the dismiss button and remembers dismissal per id. False for notices that should stay until they expire.
expiresAtstringNoNoneISO timestamp after which the bar is not rendered. Compared with now, never with Date.now during SSR.
nowstringNoNoneRequest time from the server (ISO) for the expiry check. Omitted, expiry is checked after hydration.
onDismiss(id: string) => voidNoNoneCalled 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.
labelstringNo'Announcement'Accessible name of the region. Translate for other languages.
dismissLabelstringNo'Dismiss announcement'Accessible name of the dismiss button. Translate for other languages.

Customization#

On this page

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".

Public CSS variables

VariableToken
--announcement-bar-surfacesurface
--announcement-bar-inkink
--announcement-bar-mutedmuted
--announcement-bar-accentaccent
--announcement-bar-on-accentonAccent
--announcement-bar-hairlinehairline

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Local interaction
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 30 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.