Tailwind Alert Component: Info, Success, Warning, Error States
Build info, success, warning, and error alert components with Tailwind CSS v4. Real code, four semantic states, accessible markup, dark mode — no third-party libs needed.
Why Alert Components Are Harder Than They Look
Honestly, alerts are one of those components developers underestimate until they're dealing with four different semantic states, dark mode, icon alignment, dismissibility, and screen reader announcements all at once. You prototype something in five minutes and then spend two hours fixing the edge cases.
The four standard states — info, success, warning, error — aren't just color swaps. Each one carries a different meaning to users and needs different ARIA semantics. An error alert should get role="alert" so it's announced immediately. An info banner might be lower urgency and better served with role="status". These distinctions matter.
This article covers how to build a proper Tailwind alert component from scratch in Tailwind v4.0.2, wire it into React, handle dark mode, and avoid the most common pitfalls. No UI library dependency. Just utility classes and clean markup.
The Four Semantic States: Info, Success, Warning, Error
Each alert state maps to a color family and an intent. Info is typically blue — neutral, informational, non-urgent. Success is green — action completed, no user intervention needed. Warning is amber or orange — something needs attention but isn't broken yet. Error is red — something failed, user may need to act.
In Tailwind v4 you get access to the full OKLCH-based color palette, which means bg-blue-50, bg-green-50, bg-amber-50, and bg-red-50 for light backgrounds feel distinctly different in perceptual lightness — they're not muddy approximations. If you want to understand how OKLCH changes color mixing, check out the Tailwind OKLCH colors deep dive.
Don't just think in backgrounds. A well-built alert component layers three things: a tinted background, a left border in the saturated version of that color, and an icon. The border gives the state signal even when someone has a display calibration that makes pastels look similar. The icon gives it to colorblind users.
Building the Alert Component in React + Tailwind
Here's a TypeScript implementation that handles all four states through a variant prop. It uses Tailwind utility classes directly rather than CSS variables, which makes it easier to scan and override.
import { ReactNode } from 'react';
type AlertVariant = 'info' | 'success' | 'warning' | 'error';
interface AlertProps {
variant: AlertVariant;
title?: string;
children: ReactNode;
onDismiss?: () => void;
}
const variantStyles: Record<AlertVariant, {
wrapper: string;
icon: string;
iconPath: string;
}> = {
info: {
wrapper: 'bg-blue-50 border-l-4 border-blue-500 text-blue-900 dark:bg-blue-950/40 dark:border-blue-400 dark:text-blue-100',
icon: 'text-blue-500 dark:text-blue-400',
iconPath: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
},
success: {
wrapper: 'bg-green-50 border-l-4 border-green-500 text-green-900 dark:bg-green-950/40 dark:border-green-400 dark:text-green-100',
icon: 'text-green-500 dark:text-green-400',
iconPath: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
},
warning: {
wrapper: 'bg-amber-50 border-l-4 border-amber-500 text-amber-900 dark:bg-amber-950/40 dark:border-amber-400 dark:text-amber-100',
icon: 'text-amber-500 dark:text-amber-400',
iconPath: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
},
error: {
wrapper: 'bg-red-50 border-l-4 border-red-500 text-red-900 dark:bg-red-950/40 dark:border-red-400 dark:text-red-100',
icon: 'text-red-500 dark:text-red-400',
iconPath: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
},
};
export function Alert({ variant, title, children, onDismiss }: AlertProps) {
const { wrapper, icon, iconPath } = variantStyles[variant];
const role = variant === 'error' ? 'alert' : 'status';
return (
<div
role={role}
aria-live={variant === 'error' ? 'assertive' : 'polite'}
className={`flex items-start gap-3 rounded-lg p-4 ${wrapper}`}
>
<svg
className={`mt-0.5 h-5 w-5 shrink-0 ${icon}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d={iconPath} />
</svg>
<div className="flex-1 text-sm">
{title && <p className="mb-1 font-semibold">{title}</p>}
<div>{children}</div>
</div>
{onDismiss && (
<button
onClick={onDismiss}
aria-label="Dismiss"
className="ml-auto shrink-0 opacity-60 hover:opacity-100 transition-opacity"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}A few things worth noting in this implementation. The gap-3 (12px) between the icon and content feels right — 8px is too tight for icon-to-text, 16px starts to look disconnected. The icon gets shrink-0 so it doesn't compress when the text wraps. And mt-0.5 nudges it into optical alignment with the first line of text.
Dark Mode Without Losing Contrast
The dark mode classes in the snippet above use the /40 opacity modifier — dark:bg-blue-950/40 — which creates a semi-transparent deep blue tint rather than a fully opaque background. On dark surfaces this reads as a subtle color wash while keeping enough contrast against your dark background color.
Why not just use dark:bg-blue-900? Because fully opaque dark-mode backgrounds on alerts can look heavy and block-like when you have multiple alerts stacked, or when an alert sits inside a dark card. The opacity approach plays nicer with layered UIs. That said, if you're using backdrop-blur or glass panels anywhere on the page — which glassmorphism patterns might introduce — you'll want to test that the transparency stacks don't create unreadable combinations.
Text contrast is where most alert dark modes fail. text-blue-100 on bg-blue-950/40 gives you roughly 7:1 contrast ratio on a typical dark background — that clears WCAG AA and AA Large. Always test with a contrast checker before shipping, especially for warning states where amber-on-dark is notoriously tricky.
Dismissible Alerts and Animation with Tailwind
Adding dismiss functionality sounds simple — toggle a boolean, re-render. But the user experience suffers if the alert just blinks out. A height-collapse animation makes the layout shift feel intentional rather than jarring.
import { useState } from 'react';
export function DismissibleAlert({ variant, title, children }: Omit<AlertProps, 'onDismiss'>) {
const [visible, setVisible] = useState(true);
const [hiding, setHiding] = useState(false);
const dismiss = () => {
setHiding(true);
setTimeout(() => setVisible(false), 300);
};
if (!visible) return null;
return (
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
hiding ? 'max-h-0 opacity-0' : 'max-h-40 opacity-100'
}`}
>
<Alert variant={variant} title={title} onDismiss={dismiss}>
{children}
</Alert>
</div>
)
}The max-h-40 upper bound assumes your alert won't exceed 160px. For multi-line alerts with long content, bump this to max-h-64 or use a JS-measured height with an inline style. Tailwind can't animate to max-h-auto — that's a CSS limitation, not a Tailwind one. The overflow-hidden wrapper is what makes the collapse work; without it the content just fades while the space stays.
ARIA and Accessibility Patterns for Alerts
Here's the thing: most alert implementations break screen readers in subtle ways. The most common mistake is slapping role="alert" on everything. That triggers aria-live="assertive" behavior, which means the screen reader interrupts whatever it's currently reading to announce your component. For an info banner that loads with the page, this is extremely annoying. Reserve assertive for genuine errors requiring immediate user action.
For page-level status messages that appear after async operations — form submission success, API timeout warnings — use role="status" with aria-live="polite". The polite setting waits for the current speech to finish before announcing. You might also want aria-atomic="true" so the entire alert text is read together rather than incremental updates being announced piecemeal.
One more thing: don't put interactive elements (links, buttons) inside an aria-live region if you can avoid it. Screen readers handle dynamic live regions differently from static content, and buttons inside them can confuse focus management. If your alert needs a call-to-action link, consider placing it just outside the live region and managing focus manually after the alert appears.
Integrating with Tailwind v4 and Component Patterns
Tailwind v4.0.2 changes how you configure custom colors and utilities. If you're extending the palette for custom alert states — say a purple "tip" variant — you define it in your CSS file with @theme rather than in a JavaScript config. This is a big mental shift if you're coming from v3.
/* In your global CSS file with Tailwind v4 */
@theme {
--color-tip-50: oklch(97% 0.02 300);
--color-tip-500: oklch(60% 0.18 300);
--color-tip-900: oklch(25% 0.10 300);
--color-tip-950: oklch(15% 0.08 300);
}With these custom tokens registered, bg-tip-50 and border-tip-500 become valid Tailwind utilities automatically. No plugin, no extend key. The Tailwind v4 features overview covers this in more depth if you're mid-migration. For alert components specifically, the token approach is cleaner than hardcoding color values because it keeps your variants consistent across light and dark mode without duplicating rgba values in multiple places. And when you're building a component library with many alert-adjacent patterns, see how Tailwind component patterns handles the variant map strategy at scale.
Usage Patterns and When to Actually Use Each State
Which alert state should you reach for in which scenario? Error alerts belong to failed form submissions, network failures, validation errors that block progress. Don't soften a real error into a warning because it feels less alarming — users need accurate signal about what requires their attention.
Warning is for degraded states. Rate limits approaching, deprecated API usage, missing optional configuration. Something the user should know about but can ignore for now. Info covers everything else informational — feature announcements, tips, onboarding hints. Success appears after completed actions: saved settings, sent messages, uploaded files. Keep success alerts transient — they should auto-dismiss after 4-5 seconds or after the user moves focus.
One pattern worth considering: don't mix alert stacking with page-level notifications. If you have a toast system for ephemeral feedback and inline alerts for form validation, treat them as separate concerns. Trying to unify them in one component often leads to awkward compromises on positioning, timing, and dismissal behavior. Keep toasts as toasts and inline alerts as inline alerts.
FAQ
In Tailwind v4.0.2, the content detection scans your source files for class strings. If you're building variant class names dynamically (e.g., bg-${variant}-50), Tailwind can't detect those — it only sees the template string, not the result. The fix is to write out the full class names in a static lookup object (like the variantStyles map in the code above) rather than constructing them programmatically. This ensures every class string appears literally in your source.
Both create live regions, but role='alert' implies aria-live='assertive' — the screen reader interrupts immediately. role='status' implies aria-live='polite' — it waits until the current announcement finishes. Use alert only for genuine errors or critical time-sensitive information. Use status for success messages, info banners, and non-urgent warnings. Getting this wrong makes your app actively unpleasant for screen reader users.
Tailwind's built-in animate- utilities cover keyframe animations like spin, ping, pulse, and bounce — not enter/exit transitions. For a dismiss collapse, you need CSS transitions on max-height and opacity. You can write custom keyframes with @keyframes in your CSS and register them via @theme in v4, but for a simple collapse the max-height transition approach is usually enough without the extra setup.
Show an error alert when formState.errors has entries, and dismiss it when the form is submitted successfully or when the user starts correcting input. A common pattern is to watch formState.isSubmitSuccessful to toggle a success alert, and formState.errors object to show an error summary alert above the form. Avoid rendering per-field errors inside the alert — use inline field errors for that. The alert is for form-level issues (like a network failure on submit) not individual field validation.
Ideally outside, or at least not announced as part of the live content. Place the dismiss button in the DOM inside the alert visually, but use aria-label='Dismiss' so it has a clear accessible name independent of the alert text. Screen readers generally handle buttons inside live regions fine, but some older implementations can re-announce the entire region (including the button) every time any content inside it changes. If you see that issue in testing, move the button outside the live region wrapper.
Wrap your alert list in a container with a fixed or min-height, or use layout animations if you're on Framer Motion. The max-height collapse trick works for single alerts but when multiple alerts are in a stack, dismissing one causes sibling alerts to jump up. The cleanest solution without a motion library is to animate only opacity (fade out) and then remove from DOM after the transition, accepting a small jump. For pixel-perfect stacking, Framer Motion's AnimatePresence with layout prop on each alert is the right tool.