Dismissible
A wrapper that adds a close button to any content and remembers it was closed — for this tab, forever, or until the copy is versioned up — with an undo window before anything is written.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/dismissible.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Dismissible" component (lucide-react for
the two icons; no other runtime dependencies).
Contract
- Export a forwardRef<HTMLDivElement, DismissibleProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "children">; merge className with
cn() and spread the remaining props on the root so consumers keep their
data-*, handlers and ref.
- Props: storageId: string (required in every mode, so switching the memory
policy later is a one-prop change); children: ReactNode (the wrapped
content); persistence?: "session" | "persist" | "once-per-version" | "none"
(default "session"); version?: string | number (default 1);
namespace?: string (default "dismissible"); undoMs?: number (default 5000);
dismissLabel, undoLabel, dismissedLabel?: string;
onDismiss?: () => void (fires once, when the dismissal commits);
onRestore?: () => void.
- Also export clearDismissed(storageId, namespace?) — the "show all tips again"
affordance. It removes the key from both storage areas and notifies mounted
instances, so they come back without a reload.
Behavior
- Storage key is `${namespace}:${storageId}`; the stored value is String(version).
The namespace keeps these entries out of the app's own keys. The version lives
in the value rather than in the key on purpose: forking the key per version
would leave one orphan entry per release forever, and would make "persist"
silently reappear on a version bump, contradicting its own contract. Read-time
policy is the only difference between the modes:
session -> sessionStorage, any stored value means dismissed
persist -> localStorage, any stored value means dismissed
once-per-version -> localStorage, dismissed only if the stored value equals
String(version), so shipping new copy asks again
none -> nothing is read or written; component state only
- SSR: the server cannot read storage, so the first client frame must render
what the server rendered. Read through useSyncExternalStore with a
getServerSnapshot that always returns null ("nothing stored"); subscribe to
the native "storage" event (other tabs) plus one custom same-tab event that
writes and clearDismissed dispatch. getSnapshot returns the raw string, not a
parsed object — a primitive is reference-stable for free, which is what
useSyncExternalStore requires.
- Dismissing is a two-step commit. Clicking the close button replaces the
content with a status row ("Dismissed" + Undo + a shrinking bar) and starts
the undo window; only when that window closes does the component write to
storage, set its committed state and fire onDismiss. Undo before then restores
the content with nothing written. Also commit on pagehide and on unmount if a
window is still open, guarded by a one-shot ref so onDismiss can never fire
twice.
- undoMs clamping: only a positive finite number is an undo window. 0, negative,
NaN and Infinity all mean "no undo" and commit on click — an undo window that
never closes would mean the dismissal is never remembered, which is the one
thing this component exists to do.
- Focus always has a destination, because the control that was clicked is about
to unmount. If the click was keyboard-activated (test the button with
:focus-visible), move focus to the Undo button; while focus is inside the
container the countdown pauses, so a keyboard visitor can always reach Undo.
If it was a pointer click, or there is no undo window, hand focus to the
nearest focusable element before the container in document order (fall back to
the first one after it, then to the parent with a temporary tabindex="-1"
removed on blur). Never let focus fall to <body>. Auto-focusing Undo for a
pointer user would freeze the countdown forever, so those two paths must
differ.
- Storage that is unavailable degrades silently to "none": reaching
window.localStorage can itself throw in private mode or under an enterprise
policy, so every read and write is wrapped and a failure means "not
remembered", never an exception out of a click handler.
- When storageId / namespace / persistence / version change, reset the local
"already dismissed" state during render (React's adjust-state-on-prop-change
pattern) — otherwise a version bump would be invisible on a mounted instance.
Rendering & styling
- Semantic tokens only: the wrapper adds nothing but `relative`; the close
button is size-7 text-muted-foreground with hover:bg-accent and
focus-visible:ring-2 ring-ring, absolutely pinned top-right, and grows its hit
area to 44px with `after:absolute after:-inset-2 after:content-['']` instead of
getting physically bigger. The status row uses border-dashed bg-muted/50 with
a bg-primary/60 countdown bar.
- The countdown bar is one @keyframes (scaleX 1 -> 0) shipped through a hoisted
<style> tag. Put the animation name/timing/fill in classes and only the
duration inline: an inline `animation` shorthand out-specifies every class and
would keep playing under motion-reduce:[animation-name:none].
- The close button carries a real accessible name via aria-label — name the
thing being closed ("Dismiss the 2.4 release notes"), never a bare ×. The X
and the arrow icons are aria-hidden. The status row is role="status" and
carries an sr-only sentence stating how long Undo stays available, so a screen
reader hears the deadline once instead of following a ticking clock.
Customization levers
- Memory policy is the main dial: persistence + version. "session" for tips that
may return tomorrow, "persist" for a decision that should stick, and
"once-per-version" for anything whose copy gets rewritten — bump version on a
meaningful rewrite, leave it alone for a typo fix.
- Undo window: undoMs={0} for an instant, non-undoable dismissal (pair it with a
clearDismissed() entry in a settings menu), or lengthen it to 8-10s for
destructive-feeling dismissals. The status row's copy is dismissedLabel.
- Button placement: the close button is absolutely positioned, so the wrapped
content only needs room for it (pr-12 on the content, or move the button with
className overrides / a left-hand corner for RTL layouts).
- Group reset: iterate your ids through clearDismissed(id, namespace) behind a
"show all tips again" button; mounted instances reappear immediately.
- Compose rather than reimplement: wrap a Banner, an Alert or a whole card — the
component imposes no layout of its own beyond `position: relative`.
- SSR flash: a visitor who dismissed this before sees the content for one frame
before storage resolves. If that matters, mirror the dismissal into a cookie
server-side and render the wrapper conditionally on the server instead.Concepts
- Dismissal memory lifetime — the question a close button really asks is "and does it come back?".
session,persist,once-per-versionandnoneare the four honest answers, and they differ only in how the same stored record is read. - Undo before commit — the dismissal is provisional while the Undo row is on screen; storage is the record of a decision, so nothing is written until the decision is final. Leaving the page mid-window counts as final, guarded by a one-shot so
onDismisscannot fire twice. - Content-versioned reappearance — the stored record carries the version it was dismissed at, so rewriting the copy is what brings the notice back, not an expiry timer. Bump on a meaningful rewrite, not on a typo fix.
- SSR-safe storage read — the server has no storage, so the server snapshot is always "nothing stored" and the first client frame agrees with it; the real value arrives on the next re-render. One frame of flash is the price of never shipping a hydration mismatch.
- Focus handoff on removal — removing the control you just activated drops focus on
<body>. Keyboard dismissals land on Undo (and pause the countdown while they sit there); pointer and no-undo dismissals land on the nearest focusable element before the container. - Silent storage degradation — merely touching
localStoragethrows in private mode or under an enterprise policy, so every read and write is wrapped and a failure quietly means "not remembered" rather than an exception thrown out of a click handler.
Announcer
A screen-reader announcement centre — two always-mounted live regions, a queue that spaces messages out, an invisible marker that forces repeats to be read, and a timed clear.
Overflow Tooltip
Text that reveals itself in a tooltip only when it is really truncated — width probe for a single line, height probe for a line clamp, and nothing at all when it fits.