Feature Announcement
A what's-new surface — modal dialog or anchored popover — that steps through the features a reader has not seen yet and reports every seen id plus the reason it closed.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/feature-announcement.jsonPrompt
Build a React + TypeScript + Tailwind "FeatureAnnouncement" block (uses
react-dom's createPortal, lucide-react icons and the local cn() helper).
Contract
- export const FeatureAnnouncement = forwardRef<HTMLDivElement,
FeatureAnnouncementProps>(…). The forwarded ref points at the panel, which
only exists while open. Props extend Omit<HTMLAttributes<HTMLDivElement>,
"title">; className merges through cn() and the remaining props spread onto
the panel.
- FeatureAnnouncementItem = { id: string; title: string; description?:
ReactNode; media?: ReactNode; tag?: ReactNode; publishedAt?: string (ISO
8601); action?: { label: string; href?: string; onClick?: () => void } }.
- Props: features: FeatureAnnouncementItem[]; open: boolean; onOpenChange:
(open: boolean) => void; seenIds?: string[]; presentation: "modal" |
"anchored" = "modal"; anchor?: string | RefObject<HTMLElement | null>;
side: "top" | "bottom" = "bottom"; align: "start" | "center" | "end" =
"center"; highlightAnchor = true; initialFocus?: "panel" | "none"; now?:
string | number | Date; newWithinDays = 14; locale = "en-US"; timeZone =
"UTC"; title: ReactNode = "What's new"; labels?: { back, next, done, close
}; onFeatureSeen?: (id: string) => void; onDismiss?: (dismissal:
FeatureAnnouncementDismissal) => void; returnFocusRef?:
RefObject<HTMLElement | null>; container?: Element | DocumentFragment |
null.
- FeatureAnnouncementDismissal = { reason: "completed" | "close" | "escape" |
"outside" | "focus-out" | "empty"; seenIds: string[] (shown this session,
in order); remainingIds: string[] (queued but never reached) }.
- Deliberately no zod contract and no `status` prop: this block fetches
nothing. Its interesting states are a queue of many, a queue of one, the
refusal (nothing unseen left) and the anchor-missing fallback.
- The component stores nothing. It reports what was seen and why it closed;
persistence — localStorage, a user row, a cookie — belongs to the consumer.
Behavior
- QUEUE FREEZE. `seenIds` is subtracted from `features` exactly once, on the
closed → open edge (adjust-state during render, not an effect, so the first
paint already shows the right queue). Ids are frozen for the session; the
items themselves are re-resolved from the live `features` on every render,
so copy edits or a locale switch flow through while the SET of steps stays
put. Without the freeze, the ordinary consumer wiring — persist on
onFeatureSeen, feed the result back as seenIds — deletes the step the reader
is currently looking at.
- Ids are deduped keeping the first occurrence (a repeated id would collide as
a React key and unmount the wrong step) and an item with an empty id is
dropped entirely (it could never be marked seen, so it would greet the same
reader forever).
- REFUSAL. If nothing unseen is left — or every queued feature was deleted
mid-session — the panel renders nothing at all and fires onDismiss with
reason "empty" plus onOpenChange(false), so the caller can record "this
reader is caught up" instead of holding an open flag over an empty dialog.
Silence would leave that flag stuck true forever.
- The rendered index is clamped against the resolved queue length, so a
feature deleted mid-session shortens the queue without ever rendering
undefined.
- SEEN ON VIEW. The visible step's id is reported through onFeatureSeen once
per id per session (a Set ref written before the call), collected in view
order, and handed back in the dismissal payload together with the ids that
were never reached.
- ONE DISMISSAL PER SESSION. The guard is a ref read AND written synchronously
inside the handler: a state flag only becomes visible after a re-render, and
a double click on Done — or Escape held down — lands well before that. Every
exit the panel performs funnels through it, so onDismiss fires exactly once
per session. A consumer that flips `open` back itself is telling the
component rather than asking it, and deliberately gets no callback.
- MODAL presentation: a portalled scrim (default document.body, or
`container`), aria-modal="true", Tab trapped inside the panel (Shift+Tab
wraps from the first focusable to the last), and a body scroll lock. The
lock is COUNTED on document.body data attributes (zyScrollLocks,
zyScrollLockOverflow, zyScrollLockPadding), never in module-level variables:
every component here is installed as its own copy, so a drawer or a lightbox
opened over this one runs a different copy of the identical lock, and two
blind copies restore each other's values until the page never scrolls again.
The scrollbar compensation is MEASURED across the overflow:hidden write, not
guessed from innerWidth - clientWidth, which over-pads under
`scrollbar-gutter: stable` and is a silent no-op under macOS overlay
scrollbars.
- The scrim dismisses on pointerdown whose target IS the scrim, not on a
bubbled click: a click fires even when the press started inside the panel,
so selecting the last word of a sentence and releasing outside would close
the dialog mid-drag.
- ANCHORED presentation: the anchor (CSS selector or ref) is resolved inside a
requestAnimationFrame callback rather than synchronously in an effect body —
a selector can only be queried after the commit that mounted it, and a
synchronous setState there cascades a render. Until the answer lands the
component renders nothing, which is one frame of nothing instead of a flash
of the modal fallback.
- Anchored placement maths, one pass per frame: read the anchor rect, the
panel rect and documentElement.clientWidth/clientHeight. room.bottom =
viewportHeight - rect.bottom - GAP - MARGIN, room.top = rect.top - GAP -
MARGIN. FLIP to the opposite side only when the panel does not fit on the
requested side AND the other side is genuinely roomier, so a panel too tall
for both stays where the author asked and scrolls instead of ping-ponging on
every scroll event. top = bottom ? rect.bottom + GAP : rect.top - GAP -
panelHeight; left = start ? rect.left : end ? rect.right - panelWidth :
rect.left + rect.width/2 - panelWidth/2. Both axes are clamped into
[MARGIN, viewport - MARGIN - panelSize], and the clamp's upper bound is
itself max(MARGIN, …) so a panel bigger than the viewport pins to the margin
rather than inverting the range and flying off-screen.
- Re-measurement runs on a ResizeObserver over both anchor and panel plus
rAF-throttled window resize and CAPTURE-phase scroll (scroll does not
bubble, but capture still passes through window for scrolls fired inside any
nested container). The observer's first callback IS the initial measurement,
so nothing calls setState in an effect body; scroll events originating
inside the panel are ignored. Writes are guarded by a field-by-field
equality check, so the observer re-firing for our own position write is one
no-op rather than a loop. Until the first layout the panel renders at
opacity 0.
- highlightAnchor draws a non-interactive outline (pointer-events-none,
aria-hidden) around the anchor rect inflated by 4px, reusing
getComputedStyle(anchor).borderRadius so it hugs the anchor's real shape.
This is deliberately NOT a spotlight: the page stays lit and clickable.
- An anchor that does not resolve degrades to the modal contract — scrim,
trap, scroll lock — and console.warns once per instance. An announcement
pinned to nothing, with no keyboard story, is worse than a dialog.
- FOCUS. Modal always moves focus to the panel itself (tabIndex={-1}, not the
first control, so a screen reader reads the dialog's name and description
before the buttons) and does so only once the panel is positioned —
focus() on an invisible element fails silently and every keyboard path dies
with it. Anchored honours initialFocus="none" for an announcement nobody
asked for; the panel is then announced through the live region instead.
- On close, focus goes to the first STILL-CONNECTED node of: whatever had
focus when the panel opened → returnFocusRef → the anchor. isConnected is
the point: the trigger is routinely unmounted by whatever the announcement
was about, and focus() on a detached node silently drops focus onto <body>.
The two ANCHORED exits that have already moved the reader — a press somewhere
else on the page, Tab out to the anchor — skip the restore; every modal exit
performs it, scrim press included, because focus was trapped inside the panel
and has nowhere else to be.
- The step body is keyed by feature id, so it unmounts on every move. If focus
was inside it (a CTA, a link in the description), it is handed to the Next
button — the one control that survives every step — instead of falling to
<body>.
- KEYBOARD. Escape dismisses (preventDefault + stopPropagation, so only the
innermost layer acts on one press). ArrowRight / ArrowLeft step, Home / End
jump to the first / last step; all four are ignored while focus sits in a
text field, and all four CLAMP at the ends — only Done, Escape, the close
button or an outside press ends a session, so a stray arrow can never
dismiss an announcement nobody read. Tab is trapped in modal mode; in
anchored mode tabbing past either end returns focus to the anchor and closes
with reason "focus-out", because the panel is portalled to the end of the
document and Tab would otherwise land somewhere unrelated.
- The Back button uses aria-disabled + an early return in its handler, never
the native `disabled` attribute: the reader may be standing on it, and a
natively disabled control drops focus to <body>.
- ARIA: role="dialog", aria-modal only in modal mode, aria-labelledby pointing
at both the panel title and the current feature heading, aria-describedby at
the description when there is one. The dots are real buttons carrying
aria-current="step" and an accessible name that includes the feature title;
their padding is the touch target, since a 8px dot is not one. A persistent
sr-only role="status" mirrors "title. N of M." — persistent because a live
region inserted together with its text is routinely missed — and it stays
silent for the first step when focus moved into the panel, which already
announced it.
- TIME IS AN INPUT. Every relative label comes from `now`; nothing reads a
clock during render, so server and client agree on the first paint. Day
arithmetic runs on calendar days derived from Intl.DateTimeFormat
.formatToParts in `timeZone` — "yesterday" is a date change, not a 24-hour
window — and that parts formatter is pinned to en-US even when the display
locale is not, because ar-EG prints ٢٠٢٦ and Number() of that is NaN. Omit
`now` and there is no New chip and no relative wording at all. A publishedAt
that will not parse prints verbatim rather than "Invalid Date"; a
future-dated one (clock skew) still counts as new and prints its absolute
date instead of a negative countdown. Constructing Intl formatters is
wrapped in try/catch: an unknown IANA zone throws a RangeError, and an
announcement must not blank out because someone typed "UTC+8".
- CLEANUP. Every rAF is cancelled, the ResizeObserver disconnected, the
capture-phase scroll / resize / document pointerdown listeners removed, and
the scroll lock released — on unmount AND on every dependency change
(presentation flip, anchor swap, close).
- Consumer-owned interactions: the per-feature CTA renders a real <a> when it
has an href and a <button> when it has an onClick, and is not painted at all
when it has neither, so no dead affordance ever ships. Navigation is not
intercepted and does not itself count as a dismissal.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground for the panel,
bg-background/80 + backdrop-blur for the scrim, bg-primary +
text-primary-foreground for the primary button, bg-primary/10 +
border-primary/40 + text-primary for the New chip, ring-primary/70 for the
anchor outline, bg-muted for the media box, text-muted-foreground for dates
and counters, border / ring-ring everywhere else. No hex, rgb() or oklch().
- The panel is w-[min(26rem,calc(100vw-1rem))] with a max-height, header and
footer pinned and only the body scrolling (overscroll-contain). Media sits
in a 16/9 box with overflow-hidden so every step is the same height. Every
text column is min-w-0 and wraps with wrap-anywhere, so an unbreakable build
id or signed URL cannot widen the panel.
- Three keyframes (scrim fade, panel rise, step fade) ship via a React 19
hoisted <style href precedence="medium"> tag — no Tailwind config edits, and
it dedupes by href across instances. All three carry
motion-reduce:[animation:none], as do the colour transitions; nothing here
is required for the announcement to work.
- Decorative icons are aria-hidden; every control has a focus-visible ring
offset against the popover surface.
Customization levers
- Presentation: `presentation` picks the whole contract, `side` / `align`
pick the anchored placement (flip and clamp stay automatic), and
highlightAnchor={false} drops the outline when the anchor is obvious.
- Copy: `title` renames the panel ("What's new" → "Release 4.2"), `labels`
swaps back / next / done / close independently, and each item's tag is free
text (Beta, Pro, Improved).
- Recency: newWithinDays widens or narrows the New chip; `locale` and
`timeZone` decide how dates read; omit `now` to drop recency wording
entirely on a page that has no trustworthy clock.
- Density: the panel width, the max-height, the 16/9 media box and the
ANCHOR_GAP / EDGE_MARGIN / HIGHLIGHT_PADDING constants are each a single
number to change; the maths reads them, nothing is hardcoded twice.
- Sub-blocks: drop `media`, `tag`, `publishedAt` or `action` per item and the
matching row disappears; a single-item queue automatically hides the dots,
the counter and the Back button.
- Placement host: `container` portals into a shadow root, a fullscreen element
or a scoped preview stage instead of document.body.
- Focus policy: initialFocus="none" for an anchored panel that appears without
being asked for; returnFocusRef for the case where the trigger unmounts with
the announcement.Concepts
- Frozen queue —
seenIdsis subtracted once, at the moment the panel opens, and the resulting id list is held for the whole session. The consumer's own wiring (persist ononFeatureSeen, feed it back asseenIds) would otherwise delete the step being read; item content is still resolved live, so only the SET of steps is frozen. - Seen on view, dismissal reported once — a step reports its id the moment it becomes visible, and whatever ends the session (finished, closed, Escape, outside press, tabbed away, nothing to show) funnels through one ref guard that fires
onDismissexactly once with the reason plus the seen and unreached ids. The block persists nothing itself. - Refusal as a first-class state — a returning reader with nothing unseen gets no panel at all, and the caller is told so with reason
empty; staying silent would leave the caller's open flag stuck true over a dialog that never paints. - Two contracts, one panel — modal means scrim,
aria-modal, a real Tab trap and a counted body scroll lock; anchored means a measured, flipped and clamped popover, an outline around the anchor instead of a dimmed page, dismissal on an outside press, and Tab out returning focus to the anchor rather than stranding it in a portal at the end of the document. An anchor that does not resolve degrades to the modal contract instead of hanging in a corner. - Deliberate focus successor — focus moves to the panel itself once it is positioned, is handed to the Next button when the keyed step body unmounts under it, and on close goes to the first still-connected node of previous focus →
returnFocusRef→ anchor. Nothing is ever left on<body>. - Injected now — “Shipped yesterday” and the New chip are computed from a
nowprop against calendar days in an explicittimeZone, never from a render-time clock, so the same props always render the same panel; an unparseable date prints verbatim instead of becoming “Invalid Date”.
Onboarding Checklist
A getting-started panel driven by a task contract: a progress ring, rows that expand to a description and one real action, instructions that collapse themselves once a task is ticked, an honest time-left figure, and an inline celebration whose dismiss says where to find the panel again.
API Playground
A try-it console that generates parameter, header and body editors from an operation definition, sends the request through an injected fetcher, and reports status, timing, size and headers with a pretty/raw toggle.