VisualSite
Announcement dot strip
A three-row dot pattern around the announcement label, with hover and timed highlights.
$ npx context-ds add announcement-dot-stripUsage
Use it for
Inside the announcement strip (positioned and isolated), with its label marked by the announcement text attribute.
Avoid
- Dots running under the label
- A dot strip on any band but the announcement
Example
examples/announcement-dot-strip.tsx
import { AnnouncementDotStrip, ANNOUNCEMENT_TEXT_ATTRIBUTE } from "@/components/visuals/announcement-dot-strip";export function Site() { return ( <div className="relative isolate flex h-10 w-full items-center justify-center overflow-hidden bg-announcement text-fg-brand"> <AnnouncementDotStrip /> <a href="https://context.dev/blog/answers" {...{ [ANNOUNCEMENT_TEXT_ATTRIBUTE]: "" }} className="text-body-sm text-fg underline-offset-4 hover:underline"> New: answers with citations, in one call </a> </div> );}Source
components/visuals/announcement-dot-strip.tsx
"use client";import * as React from "react";import { cn } from "@/lib/utils";const PITCH = 12;const DOT_RADIUS = PITCH * 0.32 - 0.5;const ROWS = 3;const RESTING_OPACITY = 0.14;const RAISED_OPACITY = 0.34;const TWINKLE_INTERVAL_MS = 500;const RAISE_HOLD_MS = 1000;const RAISE_TRANSITION_MS = 150;const LABEL_CLEARANCE = PITCH * 3;/** Mark the strip's label with this attribute, and the dots keep a clear gap around it. */export const ANNOUNCEMENT_TEXT_ATTRIBUTE = "data-announcement-text";const LABEL_SELECTOR = `[${ANNOUNCEMENT_TEXT_ATTRIBUTE}]`;interface Dot { key: string; cx: number; cy: number;}interface LabelBox { left: number; right: number; top: number; bottom: number;}function cellKey(column: number, row: number) { return `${column}:${row}`;}// Offsets rather than client rects: the strip has an entrance animation, and a transformed label would be measured in// the wrong place with nothing to trigger a re-measure once it settles.function measureLabelBox(host: HTMLElement): LabelBox | null { const label = host.querySelector<HTMLElement>(LABEL_SELECTOR); if (!label) return null; return { left: label.offsetLeft - LABEL_CLEARANCE, right: label.offsetLeft + label.offsetWidth + LABEL_CLEARANCE, top: label.offsetTop, bottom: label.offsetTop + label.offsetHeight, };}function buildDots(width: number, height: number, labelBox: LabelBox | null): Dot[] { const columns = Math.floor(width / PITCH); const offsetX = (width - columns * PITCH) / 2; const offsetY = (height - ROWS * PITCH) / 2; const dots: Dot[] = []; for (let row = 0; row < ROWS; row++) { for (let column = 0; column < columns; column++) { const cx = offsetX + column * PITCH + PITCH / 2; const cy = offsetY + row * PITCH + PITCH / 2; const isBesideLabel = labelBox && cx > labelBox.left && cx < labelBox.right; const overlapsLabelLine = labelBox && cy + DOT_RADIUS > labelBox.top && cy - DOT_RADIUS < labelBox.bottom; if (isBesideLabel && overlapsLabelLine) continue; dots.push({ key: cellKey(column, row), cx, cy }); } } return dots;}function pickRandom<T>(items: T[]) { return items[Math.floor(Math.random() * items.length)];}function animateStrip(host: HTMLElement, svg: SVGSVGElement, dots: Dot[]) { const circles = new Map<string, SVGCircleElement>(); for (const circle of svg.querySelectorAll<SVGCircleElement>("circle[data-dot]")) circles.set(circle.dataset.dot ?? "", circle); const gridLeft = (host.clientWidth % PITCH) / 2; const gridTop = (host.clientHeight - ROWS * PITCH) / 2; const raised = new Set<string>(); const timers = new Set<number>(); const raise = (key: string) => { const circle = circles.get(key); if (!circle || raised.has(key)) return; raised.add(key); circle.style.transitionDuration = `${RAISE_TRANSITION_MS}ms`; circle.style.fillOpacity = String(RAISED_OPACITY); const timer = window.setTimeout(() => { timers.delete(timer); raised.delete(key); circle.style.removeProperty("transition-duration"); circle.style.fillOpacity = String(RESTING_OPACITY); }, RAISE_HOLD_MS); timers.add(timer); }; // The twinkle rests while the strip is scrolled away. let onScreen = true; const visibility = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; }); visibility.observe(host); const dotKeys = dots.map((dot) => dot.key); const twinkle = window.setInterval(() => { if (!document.hidden && onScreen && dotKeys.length > 0) raise(pickRandom(dotKeys)); }, TWINKLE_INTERVAL_MS); let origin: DOMRect | null = null; const onPointerEnter = () => { origin = host.getBoundingClientRect(); }; const onPointerMove = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; origin ??= host.getBoundingClientRect(); const column = Math.floor((event.clientX - origin.left - gridLeft) / PITCH); const row = Math.floor((event.clientY - origin.top - gridTop) / PITCH); for (let dx = -1; dx <= 1; dx++) raise(cellKey(column + dx, row)); }; host.addEventListener("pointerenter", onPointerEnter); host.addEventListener("pointermove", onPointerMove, { passive: true }); return () => { window.clearInterval(twinkle); visibility.disconnect(); for (const timer of timers) window.clearTimeout(timer); host.removeEventListener("pointerenter", onPointerEnter); host.removeEventListener("pointermove", onPointerMove); };}/** * A few rows of faint dots along the announcement strip, with a clear gap around its label (the element marked * ANNOUNCEMENT_TEXT_ATTRIBUTE): dots brighten briefly at random and under the pointer. Place it inside the positioned, * isolated strip; it draws in `currentColor`, never takes pointer events, and holds still under reduced motion. */export function AnnouncementDotStrip({ className }: { className?: string }) { const svgRef = React.useRef<SVGSVGElement>(null); const [dots, setDots] = React.useState<Dot[] | null>(null); React.useEffect(() => { const host = svgRef.current?.parentElement; if (!host) return; const relayout = () => setDots(buildDots(host.clientWidth, host.clientHeight, measureLabelBox(host))); const observer = new ResizeObserver(relayout); observer.observe(host); const label = host.querySelector(LABEL_SELECTOR); if (label) observer.observe(label); let isMounted = true; void document.fonts.ready.then(() => { if (isMounted) relayout(); }); return () => { isMounted = false; observer.disconnect(); }; }, []); React.useEffect(() => { const svg = svgRef.current; const host = svg?.parentElement; if (!svg || !host || !dots || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; return animateStrip(host, svg, dots); }, [dots]); return ( <svg ref={svgRef} aria-hidden="true" data-slot="announcement-dot-strip" className={cn( "pointer-events-none absolute inset-0 -z-10 size-full transition-opacity duration-700 ease-out motion-reduce:transition-none", dots ? "opacity-100" : "opacity-0", className, )} > <g fill="currentColor" className="[&>circle]:transition-[fill-opacity] [&>circle]:duration-700 [&>circle]:ease-in-out motion-reduce:[&>circle]:transition-none"> {dots?.map((dot) => <circle key={dot.key} data-dot={dot.key} cx={dot.cx} cy={dot.cy} r={DOT_RADIUS} fillOpacity={RESTING_OPACITY} />)} </g> </svg> );}Reference: src/components/sections/shared/announcement-dot-strip.tsx.