Announcement Bar Relay

Announcement

A queued announcement bar that hands off honestly: dismissing one notice shears it upward while the next arrives from below AND the bar's own height eases between the two measured heights, so the page reflows with the message instead of snapping under it, and the queue pips physically shorten by one.

Install
npx shadcn add https://design.helpmarq.com/r/announcement-bar-relay.json
Source
registry/core/announcement-bar-relay/component.tsx
"use client";

import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// AnnouncementBarRelay — a queued announcement bar that hands off honestly.
//
// The problem with every dismissible banner queue: the DOM swaps the message
// and the page below jumps by whatever the height difference happens to be.
// Here a hidden mirror row renders the NEXT message at the bar's exact content
// width, so its height h1 is known BEFORE the handoff starts. On dismiss the
// live height h0 is written to the bar as an explicit px height, and one rAF
// later three things start on the same clock:
//
//   (a) the outgoing message shears up and out   translateY(-100%) skewX(-3deg)
//   (b) the incoming message arrives from below  translateY(100%)  skewX(3deg)
//   (c) the BAR eases height h0 -> h1
//
// The 3deg shear is what makes the two messages read as one carriage
// travelling past a window rather than a crossfade. The height easing is what
// keeps the page below moving continuously instead of snapping at the swap.
// On settle the explicit height is cleared back to auto.
//
// The queue readout collapses in the same beat: the leaving pip animates its
// width (and margin) from 6px to 0, so the row of dots visibly gets shorter.
//
// Last item: same outgoing shear, the bar eases h0 -> 0, and the region
// unmounts on transitionend — guarded by a fallback timer, because a
// transitionend that never fires (tab hidden mid-flight) must not strand a
// zero-height region in the document.
//
// Tokens only: --border, --foreground, --muted, --surface, --accent. Accent is
// restricted to the action link and the focus rings; tone:"accent" shifts only
// the 2px leading rule, never the bar's background.
// ---------------------------------------------------------------------------

const SWAP_MS = 240; // message travel
const SWAP_DELAY_MS = 60; // lead-in on the incoming message
const HEIGHT_MS = 300; // bar height ease — outlasts the swap on purpose
const PIP_MS = 200; // queue pip collapse
const EASE = "cubic-bezier(0.4, 0, 0.2, 1)";
const SETTLE_FALLBACK_MS = 400; // transitionend guard

export interface AnnouncementItem {
  /** Stable id — also the localStorage suffix, so it must survive deploys. */
  id: string;
  /** The notice itself. One sentence; the bar is a single line at rest. */
  message: string;
  /** Optional inline link rendered after the message. */
  action?: { label: string; href: string };
  /** Shifts only the 2px leading rule. @default "muted" */
  tone?: "muted" | "accent";
}

export interface AnnouncementBarRelayProps {
  /** The queue, front to back. Dismissed ids are skipped. */
  items: AnnouncementItem[];
  /** Namespaced localStorage prefix; each dismissed id is written as
   * `${storageKeyPrefix}${id}` = "1". @default "ns-announcement-bar-relay:" */
  storageKeyPrefix?: string;
  /** Clear this queue's stored dismissals on mount — for demos and previews,
   * so a replaying page can't permanently empty itself. @default false */
  resetOnMount?: boolean;
  /** Fires once the last item has been dismissed and the bar has collapsed. */
  onExhausted?: () => void;
  className?: string;
}

function MessageBody({ item, linkTabIndex }: { item: AnnouncementItem; linkTabIndex?: number }) {
  return (
    <span className="block text-sm leading-6 text-foreground">
      {item.message}
      {item.action && (
        <>
          {" "}
          <a
            href={item.action.href}
            tabIndex={linkTabIndex}
            className="rounded-sm text-accent underline decoration-accent/40 underline-offset-[3px] outline-none transition-colors hover:decoration-accent focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
          >
            {item.action.label}
          </a>
        </>
      )}
    </span>
  );
}

export function AnnouncementBarRelay({
  items,
  storageKeyPrefix = "ns-announcement-bar-relay:",
  resetOnMount = false,
  onExhausted,
  className = "",
}: AnnouncementBarRelayProps) {
  // Storage is read in an effect, never during render, so the server markup and
  // the first client paint agree: item 0 renders optimistically and is swapped
  // after hydration if it turns out to be already dismissed.
  const [dismissed, setDismissed] = useState<string[]>([]);
  const [outgoing, setOutgoing] = useState<AnnouncementItem | null>(null);
  const [entering, setEntering] = useState(false); // incoming still at its start pose
  const [leaving, setLeaving] = useState(false); // outgoing shearing out
  const [barHeight, setBarHeight] = useState<number | null>(null);
  const [animating, setAnimating] = useState(false);
  const [gone, setGone] = useState(false);

  const barRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const mirrorRef = useRef<HTMLDivElement>(null);
  const busyRef = useRef(false);
  const pendingRef = useRef<{ h1: number; last: boolean } | null>(null);
  const finishRef = useRef<(() => void) | null>(null);
  const timerRef = useRef<number | undefined>(undefined);
  const rafRef = useRef(0);
  const onExhaustedRef = useRef(onExhausted);
  onExhaustedRef.current = onExhausted;

  const labelId = useId();

  useEffect(() => {
    if (typeof window === "undefined") return;
    let store: Storage | null = null;
    try {
      store = window.localStorage;
    } catch {
      return; // storage denied — the bar simply never persists
    }
    if (!store) return;
    if (resetOnMount) {
      for (const item of items) {
        try {
          store.removeItem(`${storageKeyPrefix}${item.id}`);
        } catch {
          /* quota / private mode — nothing to do */
        }
      }
      return;
    }
    const seen: string[] = [];
    for (const item of items) {
      try {
        if (store.getItem(`${storageKeyPrefix}${item.id}`) === "1") seen.push(item.id);
      } catch {
        /* ignore */
      }
    }
    if (seen.length) setDismissed(seen);
    // Intentionally mount-only: the queue is fixed for the bar's lifetime.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(
    () => () => {
      window.clearTimeout(timerRef.current);
      cancelAnimationFrame(rafRef.current);
    },
    []
  );

  const remaining = items.filter((item) => !dismissed.includes(item.id));
  const current = remaining[0] ?? null;
  const next = remaining[1] ?? null;
  const position = Math.min(items.length, items.length - remaining.length + 1);

  const handleDismiss = useCallback(() => {
    if (busyRef.current || !current) return;
    const bar = barRef.current;
    const content = contentRef.current;
    if (!bar || !content) return;
    busyRef.current = true;

    try {
      window.localStorage.setItem(`${storageKeyPrefix}${current.id}`, "1");
    } catch {
      /* storage denied — dismissal is still honoured for this session */
    }

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const isLast = next == null;

    if (reduced) {
      // One paint: no shear, no height easing, no pip collapse.
      setDismissed((d) => [...d, current.id]);
      if (isLast) {
        setGone(true);
        onExhaustedRef.current?.();
      }
      busyRef.current = false;
      return;
    }

    // h0 is the live border-box height; h1 comes from the mirror, which is
    // already rendering `next` at the bar's exact content width. `chrome` is
    // whatever the bar adds around its content row (the hairline rule), so the
    // two measurements are in the same box model.
    const h0 = bar.getBoundingClientRect().height;
    const chrome = bar.offsetHeight - content.offsetHeight;
    const h1 = isLast ? 0 : (mirrorRef.current?.offsetHeight ?? content.offsetHeight) + chrome;

    pendingRef.current = { h1, last: isLast };
    setBarHeight(h0);
    setAnimating(true);
    setOutgoing(current);
    setEntering(true);
    setDismissed((d) => [...d, current.id]);
  }, [current, next, storageKeyPrefix]);

  // Start every clock in the same rAF, one paint after the start poses have
  // actually rendered — otherwise the browser coalesces start and end and
  // nothing transitions.
  useLayoutEffect(() => {
    const pending = pendingRef.current;
    if (!pending || !outgoing) return;
    pendingRef.current = null;

    rafRef.current = requestAnimationFrame(() => {
      setBarHeight(pending.h1);
      setEntering(false);
      setLeaving(true);
    });

    const finish = () => {
      if (!finishRef.current) return;
      finishRef.current = null;
      window.clearTimeout(timerRef.current);
      setAnimating(false);
      setBarHeight(null);
      setOutgoing(null);
      setLeaving(false);
      busyRef.current = false;
      if (pending.last) {
        setGone(true);
        onExhaustedRef.current?.();
      }
    };
    finishRef.current = finish;
    timerRef.current = window.setTimeout(finish, HEIGHT_MS + SETTLE_FALLBACK_MS);
    return () => cancelAnimationFrame(rafRef.current);
  }, [outgoing]);

  const onBarTransitionEnd = (event: React.TransitionEvent<HTMLDivElement>) => {
    if (event.propertyName !== "height" || event.target !== barRef.current) return;
    finishRef.current?.();
  };

  if (gone || (!current && !outgoing)) return null;

  const shown = current ?? outgoing;
  if (!shown) return null;

  const toneAccent = (current ?? outgoing)?.tone === "accent";

  return (
    <div
      ref={barRef}
      role="region"
      aria-labelledby={labelId}
      data-announcement-bar=""
      onTransitionEnd={onBarTransitionEnd}
      style={{
        height: barHeight == null ? undefined : `${barHeight}px`,
        transition: animating ? `height ${HEIGHT_MS}ms ${EASE}` : undefined,
      }}
      className={`ns-abr relative w-full overflow-hidden border-b border-border bg-surface ${className}`}
    >
      <span id={labelId} className="sr-only">
        Announcements
      </span>
      <span
        aria-hidden="true"
        className="pointer-events-none absolute inset-y-0 left-0 w-[2px]"
        style={{ background: toneAccent ? "var(--accent)" : "var(--border)" }}
      />

      <div ref={contentRef} className="flex items-center gap-4 px-4 py-2.5 pl-5 sm:px-6 sm:pl-7">
        <div className="relative min-w-0 flex-1" aria-live="polite">
          {outgoing && (
            <div aria-hidden="true" className="ns-abr-msg absolute inset-x-0 top-0" data-leave={leaving ? "" : undefined}>
              <MessageBody item={outgoing} linkTabIndex={-1} />
            </div>
          )}
          {current && (
            <div key={current.id} className="ns-abr-msg" data-enter={entering ? "" : undefined}>
              <MessageBody item={current} />
            </div>
          )}
          {/* Inside the live region on purpose: the pips and the counter are
              aria-hidden, so this is the only way the new position is spoken
              at all — outside it, it would change silently. */}
          <span className="sr-only">
            Announcement {position} of {items.length}
          </span>
        </div>

        <div className="flex shrink-0 items-center gap-3">
          <div className="flex items-center" aria-hidden="true">
            <span className="flex items-center">
              {outgoing && <span className="ns-abr-pip ns-abr-pip-on" data-collapse={leaving ? "" : undefined} />}
              {remaining.map((item, i) => (
                <span key={item.id} className={`ns-abr-pip ${i === 0 ? "ns-abr-pip-on" : ""}`} />
              ))}
            </span>
            <span className="ml-1 font-mono text-[11px] tabular-nums text-muted">
              {position}/{items.length}
            </span>
          </div>

          <button
            type="button"
            data-announcement-dismiss=""
            onClick={handleDismiss}
            aria-label={`Dismiss: ${shown.message}`}
            className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted outline-none transition-colors hover:bg-foreground/[0.07] hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
          >
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" className="h-3.5 w-3.5" aria-hidden="true">
              <path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round" />
            </svg>
          </button>
        </div>
      </div>

      {/* Height oracle: the next message rendered at the bar's exact content
          width, so h1 is a measured fact before the handoff starts. */}
      <div
        ref={mirrorRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 flex items-center gap-4 px-4 py-2.5 pl-5 sm:px-6 sm:pl-7"
        style={{ visibility: "hidden" }}
      >
        <div className="min-w-0 flex-1">{next && <MessageBody item={next} linkTabIndex={-1} />}</div>
        {/* The right-hand block is reproduced exactly — pips, counter, button box —
            at the count it will have AFTER the dismissal, so the mirror's message
            column is the same width as the real one and a wrap is measured, not
            missed. */}
        <div className="flex shrink-0 items-center gap-3">
          <div className="flex items-center">
            <span className="flex items-center">
              {remaining.slice(1).map((item) => (
                <span key={item.id} className="ns-abr-pip" />
              ))}
            </span>
            <span className="ml-1 font-mono text-[11px] tabular-nums text-muted">
              {Math.min(items.length, position + 1)}/{items.length}
            </span>
          </div>
          <span className="h-7 w-7" />
        </div>
      </div>

      <style>{`
        .ns-abr-msg {
          transform: translateY(0) skewX(0deg);
          opacity: 1;
          transition: transform ${SWAP_MS}ms ${EASE} ${SWAP_DELAY_MS}ms, opacity ${SWAP_MS}ms ${EASE} ${SWAP_DELAY_MS}ms;
          will-change: transform;
        }
        .ns-abr-msg[data-enter] {
          transform: translateY(100%) skewX(3deg);
          opacity: 0;
          transition: none;
        }
        .ns-abr-msg[data-leave] {
          transform: translateY(-100%) skewX(-3deg);
          opacity: 0;
          transition: transform ${SWAP_MS}ms ${EASE}, opacity ${SWAP_MS}ms ${EASE};
        }
        .ns-abr-pip {
          box-sizing: border-box;
          display: inline-block;
          width: 6px;
          height: 6px;
          margin-right: 6px;
          border-radius: 9999px;
          border: 1px solid var(--border);
          transition: width ${PIP_MS}ms ${EASE}, margin-right ${PIP_MS}ms ${EASE};
        }
        .ns-abr-pip-on {
          background: var(--foreground);
          border-color: var(--foreground);
        }
        .ns-abr-pip[data-collapse] {
          width: 0;
          margin-right: 0;
          border-width: 0;
        }
        @media (prefers-reduced-motion: reduce) {
          .ns-abr-msg,
          .ns-abr-msg[data-leave],
          .ns-abr-pip {
            transition: none;
          }
        }
      `}</style>
    </div>
  );
}
Use when

Pick announcement-bar-relay when several notices have to share one page-width bar and you care that the content below doesn't jump as they hand off — it measures the next message's height before the swap and eases the bar between the two, and shows a pip row so a visitor knows how many notices are left. Pick banner-tear-stub instead for a single dismissible notice the user may want back (it leaves a reopenable stub), or toast-gravity-stack when the notices are transient and self-dismissing rather than page furniture.

Build spec

Build <AnnouncementBarRelay items storageKeyPrefix="ns-announcement-bar-relay:" resetOnMount=false onExhausted? className?> where items is ({ id, message, action?: { label, href }, tone?: 'muted' | 'accent' })[]. STRUCTURE: a <div role=region> labelled 'Announcements' by an sr-only span, carrying the current message and its optional inline action link on the left, and on the right a queue readout plus a dismiss control — a <button data-announcement-dismiss aria-label="Dismiss: {message}"> with an SVG cross. The readout is a row of 6px pips, one per remaining item, the head one filled with --foreground and the rest hollow 1px --border rings, followed by a Geist Mono tabular-nums counter reading position/total (`1/3`), with an sr-only 'Announcement 1 of 3' carrying the same fact to a screen reader while the pips and counter are aria-hidden — that sr-only line lives INSIDE the aria-live=polite message column, so the new position is spoken with the new message instead of changing silently. THE MECHANIC — the reason this exists: an ordinary banner queue swaps the DOM and the page below jumps by whatever the height delta happens to be. Here a hidden mirror row (aria-hidden, visibility:hidden, position:absolute, inset-x-0) renders the NEXT message at the bar's exact content width, so its height h1 is a measured fact before the handoff begins. On dismiss the live height h0 is read from the bar's border box, the chrome delta (bar.offsetHeight − content.offsetHeight, i.e. the hairline rule) is added to the mirror's offsetHeight to get h1 in the same box model, h0 is written to the bar as an explicit px height, and then in one requestAnimationFrame — after the start poses have actually painted, otherwise the browser coalesces start and end and nothing moves — three clocks start together: (a) the outgoing message transitions to translateY(-100%) skewX(-3deg) opacity 0 over 240ms cubic-bezier(0.4,0,0.2,1); (b) the incoming message, rendered at translateY(100%) skewX(3deg) opacity 0 with transition:none, releases to identity over 240ms with a 60ms lead-in delay; (c) the BAR ITSELF eases height h0 → h1 over 300ms on the same curve, deliberately outlasting the swap so the content below the bar travels continuously rather than snapping at the instant the DOM changes. The 3deg shear is the whole point of the pairing — it makes the two messages read as one carriage travelling past a window rather than a crossfade. On settle the explicit height is cleared back to auto so the bar is fluid again. Simultaneously the leaving pip collapses from 6px to 0 width with its 6px right margin and its border going with it over 200ms, so the queue is visibly one dot shorter rather than silently re-rendering. LAST ITEM: the same outgoing shear, but h1 is 0, the bar eases to zero height and the region unmounts on the height transitionend, guarded by a 400ms fallback timer so a transitionend that never fires (tab hidden mid-flight) can't strand a zero-height region in the document; onExhausted fires there. PERSISTENCE: each dismissed id is written as `${storageKeyPrefix}${id}` = '1', and storage is read in a useEffect and never during render, so the server markup and the first client paint agree — item 0 renders optimistically and is swapped after hydration if it turns out to be already dismissed. Every storage access is try/caught, so private mode or a denied origin degrades to a session-only bar instead of throwing. resetOnMount clears this queue's own keys at mount, which is what a demo or preview should pass (with its own namespaced prefix) so a replaying page cannot permanently empty itself or collide with any key the host reads. Reduced motion: no shear, no height easing, no pip collapse — the message swaps in a single paint and the bar height changes instantly, and the dismissal still persists. Pure DOM/CSS/SVG, zero dependencies, no canvas. Tokens only — --border, --foreground, --muted, --surface, --accent — with accent restricted to the action link and the focus rings; tone:'accent' shifts only the 2px leading rule down the bar's left edge, never the bar's background, so a whole row of notices never turns into a colour-coded traffic light. The dismiss button and the action link both carry a visible focus-visible ring in --accent, and the dismiss button is keyboard-operable as an ordinary button, so the entire queue can be walked with Tab and Enter alone.

Tags
announcementbannerqueuedismisspage-furnituremarketing