Alert Banner
StableDetectedIssuePrimitive · Clinical
Interruption is a scarce resource and every alert spends it. The failure mode of clinical alerting is not missing alerts, it is too many — fire enough and clinicians dismiss everything, including the one that mattered. So the tiers here are a budget rather than a palette. The specific finding is stated rather than the category, because “Potassium 6.8 — critical high” earns its interruption and “Abnormal result” does not. Dismissing a critical alert captures a reason, because an alert everyone silently clears should be retired and you cannot know that without the reasons.
pnpm dlx shadcn@latest add @oxygenui/alert-bannerFirst install? Add "@oxygenui": "https://oxygenui.design/r/{name}.json" to the registries block of your components.json first — or skip the config and pass https://oxygenui.design/r/alert-banner.json directly.
Preview
Every state, switchable.
Critical: Potassium 6.8 mmol/L — critical high
Repeat sample and review cardiac monitoring.
Chemistry · resulted 06:42
High: Penicillin allergy — amoxicillin ordered
Documented anaphylaxis in 2019.
Moderate: Lithium level due — last drawn 94 days ago
Information: Care plan reviewed 3 days ago
Only critical takes focus. The finding is stated, not the category — “Abnormal result” teaches dismissal without reading.
Usage
Props are the FHIR resource.
import { AlertBanner } from "@/components/oxygen/alert-banner";
<AlertBanner
severity="critical"
finding="Potassium 6.8 mmol/L — critical high"
detail="Repeat sample and review cardiac monitoring."
source="Chemistry · resulted 06:42"
dismissReasons={["Already actioned", "Known for this patient", "Not clinically relevant"]}
onDismiss={(d) => alertMetrics.record(d)}
/>Dependencies
- @oxygenui-design/fhir@^0.1.0
- lucide-react
- clsx
- tailwind-merge
| Prop | Type | Default |
|---|---|---|
| finding The specific finding. Never a category like "Abnormal result". | string | — |
| actions Inline actions. Responding should not require navigating away. | React.ReactNode | — |
| className | string | undefined | — |
| detail | string | undefined | — |
| dismissReasons Reasons offered when dismissing a critical alert. Dismissal without a reason is what makes alert performance unmeasurable. | string[] | undefined | — |
| onDismiss | ((dismissal: AlertDismissal) => void) | undefined | — |
| severity | AlertSeverity | undefined | "info" |
| source Where it came from, so a clinician can judge it. | string | undefined | — |
Guidance
When to use it, and when not to.
DECISION SURFACE / 6 RULES
Recommended context
Use it
- Anywhere a clinical finding must be seen before the user proceeds.
- With the specific value and interpretation in finding.
- With dismissReasons on critical alerts, so rule performance is measurable.
Guardrails
Don't
- Declaring everything critical. The tier is a budget and it is finite.
- Category text like \u201cAbnormal result\u201d \u2014 it teaches dismissal without reading.
- Dismissal with no reason on a critical alert; that is how bad rules survive.
Quality
What was tested, and what is still missing.
- Tiered live regions
- Only critical is assertive. Everything else is polite and waits its turn in the reading order.
- Severity as a word
- The tier is in the accessible name, so it survives greyscale and forced-colors.
- Named dismiss
- The dismiss control names the alert it closes rather than being a bare X.
Open gaps
Known limitations
- Does not deduplicate or rank multiple simultaneous alerts \u2014 compose a stack.
- Suppression across encounters is application state, not component state.
- Emits dismissal reasons; storing and analysing them is yours.
Source
Exactly what lands in your repository.
Read directly from the published registry, so this can never drift from what the CLI installs.
Show sourceHide source211 lines
"use client";
/**
* AlertBanner — the standard surface for a clinical alert that must be seen.
*
* Interruption is a scarce resource and every alert spends it. The failure
* mode of clinical alerting is not missing alerts, it is too many: fire enough
* of them and clinicians dismiss everything, including the one that mattered.
*
* So the severity tiers here are a budget, not a palette:
*
* critical — may take focus, cannot be dismissed without an action recorded
* high — persistent, dismissible, does not steal focus
* moderate — inline, quiet
* low/info — inline, quiet, no icon weight
*
* Two rules that keep the budget honest:
*
* 1. The finding is stated, not the category. "Potassium 6.8 — critical
* high" earns its interruption. "Abnormal result" does not, and teaches
* people to dismiss without reading.
* 2. Dismissing a critical alert requires a reason and emits it. An alert
* everyone silently clears is an alert that should be retired, and you
* cannot know that without the reasons.
*/
import * as React from "react";
import { CircleAlert, Info, TriangleAlert, X } from "lucide-react";
import type { AlertSeverity } from "@oxygenui-design/fhir";
import { cn } from "@/lib/utils";
export interface AlertDismissal {
reason?: string;
at: Date;
}
export interface AlertBannerProps {
severity?: AlertSeverity;
/** The specific finding. Never a category like "Abnormal result". */
finding: string;
detail?: string;
/** Where it came from, so a clinician can judge it. */
source?: string;
/** Inline actions. Responding should not require navigating away. */
actions?: React.ReactNode;
/**
* Reasons offered when dismissing a critical alert. Dismissal without a
* reason is what makes alert performance unmeasurable.
*/
dismissReasons?: string[];
onDismiss?: (dismissal: AlertDismissal) => void;
className?: string;
}
/** Written out in full — Tailwind cannot see a class built from a variable. */
const SEVERITY_CLASS: Record<AlertSeverity, string> = {
critical:
"border-[var(--ox-status-critical)] bg-[var(--ox-status-critical-bg)] text-[var(--ox-status-critical)]",
high: "border-[var(--ox-status-high-border)] bg-[var(--ox-status-high-bg)] text-[var(--ox-status-high)]",
moderate:
"border-[var(--ox-status-high-border)] bg-[var(--ox-status-high-bg)] text-[var(--ox-status-high)]",
low: "border-[var(--ox-status-low-border)] bg-[var(--ox-status-low-bg)] text-[var(--ox-status-low)]",
info: "border-[var(--ox-border)] bg-[var(--ox-bg-subtle)] text-[var(--ox-text-muted)]",
};
const SEVERITY_ICON: Record<AlertSeverity, React.ComponentType<{ className?: string }>> = {
critical: TriangleAlert,
high: CircleAlert,
moderate: CircleAlert,
low: Info,
info: Info,
};
const SEVERITY_LABEL: Record<AlertSeverity, string> = {
critical: "Critical",
high: "High",
moderate: "Moderate",
low: "Low",
info: "Information",
};
export function AlertBanner({
severity = "info",
finding,
detail,
source,
actions,
dismissReasons,
onDismiss,
className,
}: AlertBannerProps) {
const [dismissing, setDismissing] = React.useState(false);
const [reason, setReason] = React.useState("");
const Icon = SEVERITY_ICON[severity];
const isCritical = severity === "critical";
// Only the top tier gets to interrupt. Everything else waits its turn in the
// reading order rather than jumping the queue.
const live = isCritical ? "assertive" : "polite";
// A critical alert requires a reason if reasons were supplied; a lesser one
// dismisses on a click, because friction everywhere is friction nowhere.
const needsReason = isCritical && Boolean(dismissReasons?.length);
function dismiss() {
onDismiss?.({ reason: reason || undefined, at: new Date() });
setDismissing(false);
setReason("");
}
return (
<div
role={isCritical ? "alert" : "status"}
aria-live={live}
data-severity={severity}
className={cn(
"flex flex-col gap-2 rounded-[var(--ox-radius)] border px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)]",
isCritical && "border-2",
SEVERITY_CLASS[severity],
className,
)}
>
<div className="flex items-start gap-2">
<Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
{/* Severity as a word, so it survives greyscale, forced-colors, and
being read aloud. */}
<p className="text-[length:var(--ox-text-sm)] font-semibold leading-snug">
<span className="sr-only">{SEVERITY_LABEL[severity]}: </span>
{finding}
</p>
{detail && (
<p className="mt-0.5 text-[length:var(--ox-text-xs)] leading-relaxed text-[var(--ox-text-muted)]">
{detail}
</p>
)}
{source && (
<p className="mt-0.5 text-[length:var(--ox-text-2xs)] text-[var(--ox-text-subtle)]">
{source}
</p>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={() => (needsReason ? setDismissing(true) : dismiss())}
aria-label={`Dismiss alert: ${finding}`}
className="shrink-0 rounded-[var(--ox-radius-sm)] p-1 hover:bg-[var(--ox-surface)]"
>
<X aria-hidden="true" className="size-3.5" />
</button>
)}
</div>
{actions && <div className="flex flex-wrap gap-2 pl-6">{actions}</div>}
{/* The reason is the measurement. Without it there is no way to tell a
rule that is working from one that is being clicked through. */}
{dismissing && (
<form
className="flex flex-wrap items-end gap-2 border-t border-current/20 pt-2 pl-6"
onSubmit={(event) => {
event.preventDefault();
if (!reason) return;
dismiss();
}}
>
<span className="flex flex-col gap-1">
<label
htmlFor="ox-alert-reason"
className="text-[length:var(--ox-text-2xs)] font-semibold uppercase tracking-wider"
>
Reason for dismissing
</label>
<select
id="ox-alert-reason"
required
value={reason}
onChange={(event) => setReason(event.target.value)}
className="rounded-[var(--ox-radius-sm)] border border-current bg-[var(--ox-surface)] px-2 py-1 text-[length:var(--ox-text-xs)] text-[var(--ox-text)]"
>
<option value="">Select a reason…</option>
{dismissReasons?.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</span>
<button
type="submit"
disabled={!reason}
className="rounded-[var(--ox-radius-sm)] bg-[var(--ox-status-critical)] px-2.5 py-1.5 text-[length:var(--ox-text-xs)] font-semibold text-white disabled:opacity-50"
>
Dismiss
</button>
<button
type="button"
onClick={() => setDismissing(false)}
className="px-2 py-1.5 text-[length:var(--ox-text-xs)] font-semibold"
>
Cancel
</button>
</form>
)}
</div>
);
}