Moderation Notice
Content-policy interruption states for chat — blocked input, withheld response and softened answer, with category chips that cross-highlight the flagged passage, a one-shot appeal action, and role=alert only when something was actually stopped.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/moderation-notice.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ModerationNotice" component: the panel a
chat shows when a content policy interrupted something. Dependencies:
lucide-react, plus shadcn/ui Badge and Button.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus "title" (the prop is a ReactNode headline, not a tooltip).
- variant?: "blocked-input" | "blocked-output" | "softened-output"
(default "blocked-output"). The three are ONE component because the DOM
skeleton, the slots and the recovery model are identical; only the role, the
chrome and the default copy differ.
- title?: ReactNode, reason?: ReactNode — undefined means "use the variant
default", null means "render nothing".
- categories?: readonly { id: string; label: string; score?: number }[]
- showScores?: boolean (default false).
- excerpt?: string — the flagged text quoted back.
- spans?: readonly { start: number; end: number; categoryId?: string }[] —
ranges inside excerpt, half-open [start, end).
- excerptLabel?, suggestionLabel? (default "Try instead"), useSuggestionLabel?
(default "Use this instead"), editLabel? (default "Edit message"),
appealLabel? / appealPendingLabel? / appealSentLabel?, policyLabel?
(default "Content policy"), caseIdLabel? (default "Reference"),
dismissLabel? — every visible string is a prop; nothing is hardcoded English
inside the tree.
- suggestion?: ReactNode, onUseSuggestion?: () => void, onEdit?: () => void,
onAppeal?: () => void, actions?: ReactNode (extra buttons in the same row).
- appealStatus?: "idle" | "pending" | "sent" — controlled; omitted means the
component owns it.
- policyHref?: string, caseId?: string, announce?: boolean (default true),
autoFocusAction?: boolean (default false), icon?: ReactNode (null = none).
- dismissible? / dismissed? / defaultDismissed? / onDismiss? — the usual
controlled + uncontrolled pair.
- children?: ReactNode — the content the notice governs, rendered BELOW the
notice box (the partially filtered answer under a softened-output note).
Behavior — which moment is this
- blocked-input: the message never reached the model. Copy must say so
explicitly ("Nothing left this device"), because the user's first fear is
that it was sent AND logged. Useful content: the flagged span plus a rewrite.
- blocked-output: an answer existed and was withheld. The user did nothing
obviously wrong, so this variant owes them categories, an appeal route and a
reference id.
- softened-output: an answer is on screen with parts removed. It is an
annotation, not an event.
Behavior — announcement
- Root has no role. The notice BOX carries role="alert" for the two blocking
variants (when announce is true) and role="note" otherwise. Assertive
interruption is earned by "the thing you asked for did not happen"; a
softened answer is already being read, so shouting over it is the second
harm.
- announce={false} downgrades a blocking notice to role="note" — use it when
the surrounding message list already owns a live region, so the same words
are not spoken twice.
- Edge case to document for consumers: a role="alert" element that is present
in the very first paint is often NOT announced. Mount the notice in response
to the send/generate result, not with the page.
- role="alert" implies aria-atomic, so any later change inside re-announces the
whole notice. The appeal confirmation therefore lives in its own
<p role="status">, mounted from the FIRST render and simply empty until the
appeal lands (sr-only while empty, so it contributes no flex gap). Two
reasons: a live region inserted together with its text is routinely missed,
and the innermost live region governs the update — the user hears "Review
requested" alone instead of the entire block again.
Behavior — spans and cross-highlight
- Normalise spans before rendering: truncate to integers, clamp both ends into
[0, excerpt.length], drop anything where end <= start (which also removes NaN
offsets for free, since every comparison with NaN is false), sort by start
then end, and resolve overlaps first-writer-wins by tracking a cursor and
letting each span claim only the part beyond it. The output is a flat list of
segments; the excerpt always renders in full, whatever the detector sent.
- A category chip is a <button aria-pressed> only when at least one surviving
span carries its id; otherwise it renders as an inert Badge. A control that
looks pressable and does nothing is worse than a label.
- Pressing a chip sets the active category: its passages keep the destructive
tint, every other flagged passage falls back to muted. Pressing again clears
it. Derive "active" from state ∩ available ids rather than storing it, so a
category list that changes underneath cannot leave every passage dimmed.
- Add one sr-only line under the excerpt ("2 highlighted passages"): <mark> is
announced inconsistently and the count is the part that matters.
Behavior — recovery and appeal
- onEdit and onUseSuggestion are slots, not behaviour: the component never
touches the composer. Wire onEdit to focus the input and select the flagged
range (you already have the offsets), and onUseSuggestion to replace the
draft with the suggestion.
- The suggestion block renders whenever `suggestion` is set; the accept button
only renders when onUseSuggestion is also set, so read-only guidance stays
read-only.
- Appeal is a state machine: idle -> (click) -> sent when uncontrolled, or
idle -> pending -> sent when the consumer owns appealStatus. `pending`
disables the button and shows a spinner; `sent` replaces the button entirely
with a check + confirmation line.
- One-shot lock: a ref guards onAppeal so a double-click, or an impatient click
while a controlled consumer is still writing to the network, cannot file two
reports. An effect clears the lock when the status returns to idle (new
message, reset), which is the only way back.
- The dismiss button uses the same one-shot pattern for onDismiss: an async
persistence hook must not be written twice.
- autoFocusAction moves focus to the first action button once per mount, guarded
by a ref so re-renders never yank focus back. Off by default — it is only
right immediately after the user pressed Send.
Rendering & styling
- Semantic tokens only: bg-card, bg-muted/40, bg-muted/50, border,
border-destructive/30, text-foreground, text-muted-foreground,
text-destructive, bg-destructive/10 on the flagged <mark>, text-primary on
the policy link, ring for focus. No hex, no rgb(), no oklch().
- Tone is the product decision: blocking variants are a normal card with a
tinted BORDER and a destructive icon — no red fill, no exclamation wall.
softened-output is quieter still (bg-muted/40, muted icon, smaller title).
Copy states what the system did and what to do next; it never characterises
the user ("you violated" is banned).
- Scores render as a three-step bar (>=0.8 high, >=0.5 medium, else low) with
the word in sr-only text. Never print a raw classifier percentage: four
digits read as a verdict the number cannot support.
- Entrance is one hoisted @keyframes (React 19 <style href precedence>), a 4px
rise over 160ms, disabled with motion-reduce:[animation:none]. Nothing about
the notice depends on it.
- Accessibility: icons aria-hidden; the dismiss button carries aria-label; the
policy link gets target=_blank + rel="noopener noreferrer" only for http(s)
hrefs; every interactive element keeps a focus-visible ring; chips are
reachable and toggled from the keyboard because they are real buttons.
- Layout: min-w-0 everywhere down the flex chain and wrap-anywhere on the
excerpt, so an unbroken 200-character prompt cannot widen the thread.
Customization levers
- Tone dial: swap border-destructive/30 for a plain border and the destructive
icon for a muted one to get a fully neutral treatment; or push the other way
with bg-destructive/5 on the notice box if your product wants more weight.
- Slots you can drop entirely: categories, excerpt+spans, suggestion, appeal,
policy footer, caseId. The minimum useful notice is title + reason.
- Excerpt length: pass the flagged sentence, not the transcript. If you must
show a long draft, add line-clamp to the excerpt paragraph plus a
show-more toggle rather than a fixed height that silently cuts.
- Density: the variant maps (noticeClass / iconClass / titleClass) are three
one-line lookups — change padding, radius and type scale there instead of
spraying overrides through the tree.
- Copy: every string is a prop; localise by passing them, or change the
defaults in one COPY record keyed by variant.
- Score bands: move the 0.8 / 0.5 thresholds, or render the number if your
audience is a trust-and-safety reviewer rather than an end user.
- Cross-highlight: if a chip should filter a LIST of flagged messages instead
of spans inside one excerpt, keep the pressed-chip state and swap what reads
it — the chip contract does not change.
- announce={false} plus a sibling live region is the right combination inside a
virtualised message list.Concepts
- Interception vs annotation — the variant is chosen by what actually happened to the content, not by how severe it felt: two variants report that something did not arrive (assertive,
role="alert"), the third annotates something that did (role="note"), and that single distinction decides whether a screen reader is interrupted. - Span normalisation — detector offsets arrive reversed, out of bounds, duplicated or NaN; they are truncated, clamped, dropped when empty and resolved first-writer-wins against a moving cursor, so the quoted text always renders in full no matter what the classifier sent.
- Chip ↔ span cross-highlight — a category only becomes a pressable filter when a surviving span carries its id; pressing it keeps that category's passages tinted and dims the others, which turns a list of policy labels into a way of reading the flagged sentence.
- One-shot appeal lock — a ref swallows every emission after the first, so a double-click (or an impatient click while a controlled consumer is still writing to the network) cannot file two reports; the lock reopens only when the status returns to
idle. - Inner live region —
role="alert"re-announces its whole subtree on any change, so the appeal confirmation sits in its ownrole="status"element that is mounted empty from the first render (a region inserted together with its text is routinely missed); the innermost live region governs the update, so the user hears "Review requested" instead of the entire notice again. - Recovery over verdict — the component's centre of gravity is the way out (a concrete rewrite, an edit that selects the flagged range, an appeal, a policy link), which is why the destructive token is limited to a border, an icon and the flagged passage.
Confidence Meter
A response-confidence reading — segmented bar or small ring banded by your own cut points, with a 'why' disclosure that breaks the score into per-signal factor rows.
Slash Command Menu
A caret-anchored "/" command popup for a textarea you already own — sectioned list, roving keyboard cursor, and one commit that either inserts a token or fires an action.