Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { RotateCcw, X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The undo countdown bar. Only the keyframe lives here — the animation *name*,
 * timing and fill are applied as Tailwind classes and only the duration goes
 * inline, so `motion-reduce:[animation-name:none]` can actually win. An inline
 * `animation` shorthand would out-specify every class and quietly keep playing.
 */
const KEYFRAMES = `@keyframes zdm-countdown{from{transform:scaleX(1)}to{transform:scaleX(0)}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dismissible.json

Prompt

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-version and none are 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 onDismiss cannot 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 localStorage throws 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.

On This Page