Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { ChevronLeft, ChevronRight, Sparkles, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The entry animations ship with the component: React 19 hoists <style href> into
 * <head> and dedupes by href, so several announcements on one page still emit one
 * rule set. Everything animated here is decorative — each user carries a
 * motion-reduce:[animation:none] pair, and nothing depends on a frame landing.
 */
const KEYFRAMES = `@keyframes zg-feature-scrim-in{from{opacity:0}to{opacity:1}}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/feature-announcement.json

Prompt

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 queueseenIds is 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 on onFeatureSeen, feed it back as seenIds) 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 onDismiss exactly 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 now prop against calendar days in an explicit timeZone, 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”.

On This Page