EmpireUI
Get Pro
← Blog8 min read#tailwind#alert#callout

Alert and Callout Components in Tailwind: Info, Warning, Error, Success

Build production-ready alert and callout components in Tailwind CSS — info, warning, error, and success variants with icons, dismiss logic, and accessibility.

Developer building alert and callout UI components with Tailwind CSS

Why Alert Components Are Harder Than They Look

Alerts look trivial. Slap a colored div on the page, write some text inside it, ship it. Done. Except three months later you've got five different teams implementing five slightly different alert styles, none of them accessible, and the design system document everyone ignored is now wildly out of sync with prod.

In practice, the tricky part isn't the CSS — Tailwind makes the visual side straightforward. The hard part is deciding what your alert component's API should look like, what variants you actually need, and how it interacts with keyboard users and screen readers. Get those decisions wrong early and you'll be refactoring the thing every quarter.

This article builds four production-grade alert variants — info, warning, error, and success — using Tailwind CSS and React. We'll cover the base component, dismissible behavior with useState, icon slot patterns, and the ARIA attributes you need to not get yelled at in an accessibility audit. No magic, no overcomplicated abstraction. Just useful code.

Quick aside: if you're also building notification toasts that pop up on user actions, you might want to read the react toast notifications article alongside this one — the patterns overlap a lot.

Setting Up Your Color Palette for All Four Variants

Before writing a single component, lock down your colors. Tailwind 3.4+ ships decent semantic-ish colors out of the box — blue-50, yellow-50, red-50, green-50 for backgrounds, with 500-series borders and 800-series text. That's actually enough to get you pretty far without touching tailwind.config.js at all.

That said, you'll want consistency across light and dark modes. Here's the palette mapping I'd recommend for each variant: `` info → bg-blue-50 border-blue-300 text-blue-800 icon-blue-500 warning → bg-yellow-50 border-yellow-300 text-yellow-800 icon-yellow-500 error → bg-red-50 border-red-300 text-red-800 icon-red-500 success → bg-green-50 border-green-300 text-green-800 icon-green-500 ` For dark mode, flip to 900-series backgrounds (bg-blue-950) and 200`-series text. The contrast ratios all pass WCAG AA at those values — I've checked them manually against a 4.5:1 minimum.

Honestly, resist the urge to customize this too early. I've seen teams spend two days bikeshedding over whether the error red should be rose-50 or red-50, while the actual accessibility properties — sufficient contrast, focus rings, ARIA roles — were completely missing. Get the structure right first.

Worth noting: if your design system uses custom brand colors, you can extend Tailwind's config to add alert-info-bg, alert-info-border, etc. as semantic aliases. That makes refactoring a color change from a find-and-replace nightmare into a single line in tailwind.config.ts. Check the tailwind component patterns article for a full walkthrough of that token approach.

Building the Base Alert Component

Here's the core component. No frills — just the structure that all four variants extend from: ``tsx // Alert.tsx import { ReactNode } from 'react' type AlertVariant = 'info' | 'warning' | 'error' | 'success' interface AlertProps { variant?: AlertVariant title?: string children: ReactNode icon?: ReactNode onDismiss?: () => void } const variantStyles: Record<AlertVariant, string> = { info: 'bg-blue-50 border-blue-300 text-blue-800', warning: 'bg-yellow-50 border-yellow-300 text-yellow-800', error: 'bg-red-50 border-red-300 text-red-800', success: 'bg-green-50 border-green-300 text-green-800', } export function Alert({ variant = 'info', title, children, icon, onDismiss, }: AlertProps) { return ( <div role="alert" className={flex gap-3 rounded-lg border p-4 ${variantStyles[variant]}} > {icon && ( <span className="mt-0.5 shrink-0" aria-hidden="true"> {icon} </span> )} <div className="flex-1 min-w-0"> {title && ( <p className="font-semibold text-sm mb-1">{title}</p> )} <p className="text-sm">{children}</p> </div> {onDismiss && ( <button onClick={onDismiss} className="shrink-0 rounded p-0.5 opacity-60 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-current" aria-label="Dismiss alert" > <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> </svg> </button> )} </div> ) } ``

A few things worth calling out. The role="alert" attribute tells screen readers to announce the content immediately — which is exactly what you want for dynamic alerts injected into the DOM. If it's a static callout that's already on the page when it loads, role="note" is technically more correct, but alert works fine in practice for both.

The aria-hidden="true" on the icon wrapper is intentional. Icons are decorative here — the text carries the meaning. If you have an icon-only alert (which you shouldn't, but people do it), remove the aria-hidden and add an aria-label to the icon's wrapping element instead.

The focus:ring-2 focus:ring-current on the dismiss button is doing real work. currentColor means the ring color automatically matches the variant's text color — you get a blue ring on info alerts, red on error alerts, without a single extra class. That's 2026 Tailwind doing what it's good at.

Adding Dismissible Behavior with useState

The dismiss button in the base component accepts an onDismiss callback, so you can handle state wherever makes sense for your app. Here's the simplest self-contained dismissible pattern: ``tsx import { useState } from 'react' import { Alert } from './Alert' export function DismissibleAlert() { const [visible, setVisible] = useState(true) if (!visible) return null return ( <Alert variant="warning" title="Your session expires in 10 minutes" onDismiss={() => setVisible(false)} > Save your work to avoid losing progress. </Alert> ) } ``

That's fine for one-off alerts. For a global alert system — say, API error messages from a form submission — you'd push this up into a Zustand store or React context. The component itself stays dumb. State lives wherever your app needs it to live. Don't bake global state into your UI primitives.

One more thing — if you want a smoother disappearing animation instead of an abrupt return null, wrap the alert in a Tailwind transition with opacity-0 toggling: ``tsx <div className={transition-all duration-200 ${ visible ? 'opacity-100 max-h-40' : 'opacity-0 max-h-0 overflow-hidden' }} > <Alert variant="success" onDismiss={() => setVisible(false)}> Changes saved successfully. </Alert> </div> ` The max-h-40max-h-0 transition collapses the element vertically without a layout jump. It's not perfect — CSS height: auto` transitions never are — but for most alert use cases it looks clean enough without pulling in Framer Motion.

Look, the fully animated version using Framer Motion's AnimatePresence takes about 15 extra lines and handles all the edge cases properly. If dismissal animations matter to your product, do it properly. Half-measures with max-h tricks get weird on content that's longer than the fixed cap.

Callout Blocks vs Inline Alerts: When to Use Each

These two patterns get conflated constantly. An alert is reactive — it appears in response to something the user did or something that happened in the system. A callout is editorial — it's part of the content, static on the page, used in docs, blog posts, and dashboards to highlight something worth reading carefully.

The Tailwind styles are nearly identical, but the semantic difference matters for your component API. Callouts don't need role="alert" (because they're not reacting to anything — they were there when the page loaded). They also rarely need a dismiss button. A callout for docs might look like this: ``tsx type CalloutVariant = 'info' | 'warning' | 'tip' | 'danger' const calloutIcons: Record<CalloutVariant, string> = { info: 'ℹ️', warning: '⚠️', tip: '💡', danger: '🚨', } export function Callout({ variant = 'info', children, }: { variant?: CalloutVariant children: ReactNode }) { return ( <aside className={my-6 flex gap-3 rounded-lg border-l-4 px-4 py-3 ${ calloutVariantStyles[variant] }} > <span aria-hidden="true" className="text-lg">{calloutIcons[variant]}</span> <div className="text-sm">{children}</div> </aside> ) } ``

The border-l-4 pattern — a thick left border instead of a full border — is the classic callout visual. You've seen it in every documentation site since at least 2019. It works because it's instantly recognizable. Whether you prefer that or a full border with a background tint is mostly a style call — for a glassmorphism or dark UI you might want glassmorphism components instead of the flat block approach.

That said, using <aside> for callouts in editorial content is semantically correct. <aside> means "tangentially related to the surrounding content" — which is exactly what a tip or warning in a doc page is. For alerts that appear in the main UI flow, <div role="alert"> is still the right call.

Also worth noting: if you're building a tailwind dashboard layout, you'll likely need both patterns. Callouts for inline documentation or help text, alerts for form validation feedback and system status messages. Don't use the same component for both — the API and accessibility semantics genuinely differ.

Icon Sets and the Best Way to Wire Them In

Don't hardcode icons inside your alert component. You'll regret it the moment your design system switches from Heroicons to Phosphor, or the PM decides the warning icon should be a triangle instead of a circle. Pass icons in as slots — either as ReactNode props (as in the base component above) or via a mapping object you define outside the component and inject at the call site.

Here's a pattern using Heroicons v2 (the 24px solid set) that maps automatically based on variant: ``tsx import { InformationCircleIcon, ExclamationTriangleIcon, XCircleIcon, CheckCircleIcon, } from '@heroicons/react/24/solid' const defaultIcons: Record<AlertVariant, ReactNode> = { info: <InformationCircleIcon className="h-5 w-5 text-blue-500" />, warning: <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />, error: <XCircleIcon className="h-5 w-5 text-red-500" />, success: <CheckCircleIcon className="h-5 w-5 text-green-500" />, } // Then in your Alert component, add a defaultIcon fallback: const resolvedIcon = icon ?? defaultIcons[variant] ``

At h-5 w-5 that's 20px icons, which pairs nicely with text-sm body text. Use h-4 w-4 (16px) if your alert padding is tighter. Either way, keep icons at even pixel values — odd sizes cause sub-pixel rendering artifacts that'll drive your designers crazy when they zoom in.

Honestly, the icon-as-prop pattern might feel like over-engineering for a simple alert. It's not. It gives you the flexibility to use a spinner icon for a loading state alert, a custom SVG for a branded notice, or no icon at all for a minimal callout — all without touching the component internals. That's the whole point of a slot API.

Dark Mode, Focus Rings, and the Accessibility Details That Actually Matter

Dark mode for alerts is straightforward in Tailwind — prefix every color class with dark: and you're halfway there: ``tsx const variantStyles: Record<AlertVariant, string> = { info: 'bg-blue-50 border-blue-300 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200', warning: 'bg-yellow-50 border-yellow-300 text-yellow-800 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-200', error: 'bg-red-50 border-red-300 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200', success: 'bg-green-50 border-green-300 text-green-800 dark:bg-green-950 dark:border-green-800 dark:text-green-200', } ``

The 950 backgrounds are a Tailwind 3.3+ addition — they're essentially black with a color tint, which gives you the dark variant without the washed-out look you get from using 900 backgrounds with 100-series text.

For focus management: if your alert is injected dynamically — say, after form submission — move focus to it. A screen reader sitting in the middle of a form won't know an alert appeared at the top of the page otherwise. You can do this with a useEffect and a ref: ``tsx import { useEffect, useRef } from 'react' export function FocusedAlert({ variant, children }: AlertProps) { const ref = useRef<HTMLDivElement>(null) useEffect(() => { ref.current?.focus() }, []) return ( <div ref={ref} role="alert" tabIndex={-1} className={rounded-lg border p-4 outline-none ${variantStyles[variant ?? 'info']}} > {children} </div> ) } ``

tabIndex={-1} makes the div programmatically focusable without putting it in the tab order. outline-none removes the focus ring on the container (since you're not putting it there for visual reasons). The user's screen reader gets the announcement via role="alert" AND focus lands on the element so they can navigate from there. Both mechanisms working together.

One more thing — the tailwind forms guide has a section on validation error states that pairs well with error-variant alerts. The combination of inline field errors and a top-level error summary alert is the pattern the WCAG working group actually recommends for complex forms. Worth reading if you're building anything form-heavy.

FAQ

What's the difference between role="alert" and role="status" in Tailwind alert components?

role="alert" is assertive — screen readers announce it immediately, interrupting whatever they're currently reading. Use it for errors and urgent warnings. role="status" is polite — it waits for the current announcement to finish. Use it for success messages and non-critical info.

Should I use a single Alert component with a variant prop, or separate InfoAlert, WarningAlert, ErrorAlert components?

Single component with a variant prop. Separate components share 95% of their logic and CSS, so you're just duplicating work. The variant prop pattern makes the API predictable and refactoring trivially easy.

How do I animate alert entrance and exit in Tailwind without Framer Motion?

Use a combination of transition-all, opacity-0/100, and max-h-0/max-h-40 classes toggled via state. It covers most cases. For complex animations or AnimatePresence-style unmounting, Framer Motion is the cleaner solution.

Can I use these alert components with Tailwind v4?

Yes — all the class names used here work in Tailwind v4. The dark: prefix behavior and arbitrary value support are unchanged. Just note that v4 moves config to CSS-first @theme blocks instead of tailwind.config.ts.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Alert Component: Info, Success, Warning, Error States10 Tailwind Component Patterns Every Developer Should KnowGlassmorphism Toast / Notification in React: Frosted Alert DesignWhat Is Glassmorphism? A Free React + Tailwind Guide