CSS Alert Banner
Info, success, warning, and error alert variants. Each modifier tints the background and colors the border with CSS custom properties.
- Level
- Beginner
- Published
- Updated
- Reading time
- 8 min
Try it
3 live demos
Four severities and a dismiss
Other ways to build it
A dismiss that animates
See the codeOne attribute picks the colors and the icon
See the codeBuild it
6 snippets to copy
CSS Alert Banner
HTML
<div class="alerts">
<div class="alert alert-info">
<span class="alert-icon">ℹ</span>
<div class="alert-body">
<strong>Info</strong>
Your password will expire in 7 days.
</div>
</div>
<div class="alert alert-success">
<span class="alert-icon">✓</span>
<div class="alert-body">
<strong>Success</strong>
Your profile was updated successfully.
</div>
</div>
<div class="alert alert-warning">
<span class="alert-icon">⚠</span>
<div class="alert-body">
<strong>Warning</strong>
You have unsaved changes on this page.
</div>
</div>
<!-- Dismissible: the checkbox has to come before the alert it hides -->
<input type="checkbox" id="dismiss-1" class="alert-dismiss-input">
<div class="alert alert-error alert-dismissible">
<span class="alert-icon">✕</span>
<div class="alert-body">
<strong>Error</strong>
Failed to connect. Check your network.
</div>
<label for="dismiss-1" class="alert-close">✕</label>
</div>
</div>
CSS
.alerts {
display: flex;
flex-direction: column;
gap: .65rem;
}
.alert {
display: flex;
align-items: flex-start;
gap: .75rem;
padding: .85rem 1rem;
border-radius: 8px;
border: 2px solid;
font-size: .9rem;
line-height: 1.5;
}
.alert-icon {
font-size: 1rem;
flex-shrink: 0;
margin-top: .05rem;
}
.alert-body {
flex: 1;
}
.alert-body strong {
display: block;
font-size: .875rem;
font-weight: 600;
margin-bottom: .1rem;
}
/* One modifier per variant: tinted background, matching border and text */
.alert-info {
background: rgba(56, 189, 248, .12);
border-color: #38bdf8;
color: #38bdf8;
}
.alert-success {
background: rgba(87, 217, 163, .1);
border-color: #57d9a3;
color: #57d9a3;
}
.alert-warning {
background: rgba(255, 212, 68, .12);
border-color: #ffd444;
color: #ffd444;
}
.alert-error {
background: rgba(255, 107, 107, .12);
border-color: #ff6b6b;
color: #ff6b6b;
}
/* Dismissible variant */
.alert-dismiss-input {
display: none;
}
.alert-dismissible {
position: relative;
transition: opacity .2s, max-height .3s;
}
.alert-close {
margin-left: auto;
cursor: pointer;
opacity: .6;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
user-select: none;
}
.alert-close:hover {
opacity: 1;
}
.alert-dismiss-input:checked ~ .alert-dismissible {
display: none;
}
A dismiss that animates
Setting display: none cannot be transitioned, so the banner above vanishes on the frame it is dismissed. Animating grid-template-rows from 1fr to 0fr collapses the row instead, and it does so without anyone having to know how tall the banner is. Two things are required: the wrapper has to be a grid, and the child has to carry overflow: hidden with min-height: 0, or the content refuses to shrink and nothing appears to happen.
The checkbox is visually hidden rather than removed, so the close control is reachable by keyboard. One more line finishes the job: a clipped row is still in the accessibility tree, so visibility is transitioned with a delay matching the collapse, which takes the dismissed banner and its close button out of the tab order once they are off screen.
HTML
<input type="checkbox" id="collapse-1" class="ab-hidden-input">
<div class="ab-collapse">
<div class="ab-collapse-inner">
<div class="alert alert-warning">
<span class="alert-icon" aria-hidden="true">⚠</span>
<div class="alert-body">
<strong>Warning</strong>
Your trial ends in three days.
</div>
<label for="collapse-1" class="alert-close">✕</label>
</div>
</div>
</div>
CSS
/* rendered, so it keeps its place in the tab order */
.ab-hidden-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.ab-collapse {
display: grid;
grid-template-rows: 1fr;
visibility: visible;
transition: grid-template-rows .3s ease, opacity .25s ease, visibility 0s;
}
/* both of these are required, or the row shrinks and the
content overflows it */
.ab-collapse-inner {
overflow: hidden;
min-height: 0;
}
/* A clipped row is not a removed row, so its close button would stay in
the tab order. Delaying visibility to the end of the collapse takes the
banner out of the accessibility tree once it is off screen. */
.ab-hidden-input:checked ~ .ab-collapse {
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
transition: grid-template-rows .3s ease, opacity .25s ease, visibility 0s .3s;
}
.ab-hidden-input:focus-visible ~ .ab-collapse .alert-close {
outline: 2px solid #b8ff57;
outline-offset: 3px;
}
One attribute picks the colors and the icon
Instead of a modifier class plus an icon element in every banner, the severity goes in a data attribute and CSS reads it twice: once to set two custom properties that everything else derives from, and once to supply the glyph through ::before. Adding a severity becomes two short rules with no markup change at all. The icon being generated content is a bonus rather than a compromise, because assistive technology treats it as presentational, which is what an icon duplicating the word beside it should be.
HTML
<div class="ab-auto" data-severity="info">
<strong>Info</strong> Scheduled maintenance on Sunday at 6am UTC.
</div>
<div class="ab-auto" data-severity="error">
<strong>Error</strong> The import stopped after 412 rows.
</div>
CSS
.ab-auto {
/* neutral defaults, so a missing or unknown severity still reads */
--ab-accent: #88888f;
--ab-tint: rgba(136, 136, 143, .12);
display: flex;
align-items: flex-start;
gap: .75rem;
padding: .85rem 1rem;
border-radius: 8px;
border: 2px solid var(--ab-accent);
background: var(--ab-tint);
color: var(--ab-accent);
font-size: .9rem;
line-height: 1.5;
}
/* generated content, so screen readers treat it as presentational */
.ab-auto::before {
content: "•";
flex-shrink: 0;
}
.ab-auto[data-severity="info"] {
--ab-accent: #38bdf8;
--ab-tint: rgba(56, 189, 248, .12);
}
.ab-auto[data-severity="info"]::before { content: "\2139"; }
.ab-auto[data-severity="warning"] {
--ab-accent: #ffd444;
--ab-tint: rgba(255, 212, 68, .12);
}
.ab-auto[data-severity="warning"]::before { content: "\26A0"; }
.ab-auto[data-severity="error"] {
--ab-accent: #ff6b6b;
--ab-tint: rgba(255, 107, 107, .12);
}
.ab-auto[data-severity="error"]::before { content: "\2715"; }
Understand it
How it works and 6 properties
A CSS alert banner is a base class plus one modifier per severity, and the modifier does three things: it tints the background, it colors the border, and it sets the text color. Everything else about the layout comes from the shared .alert rule, so a new severity is a single block of CSS rather than a new component. The dismiss on the last banner is a checkbox and a label, which is how CSS remembers that someone closed something.
Severity is carried by the word in the markup, not by the color. Error sits in a <strong> inside every banner, which means the message still reads correctly in a screenshot, in high contrast mode, and to anyone who cannot tell the red one from the yellow one. Two more versions follow the code below: a dismiss that animates the banner away instead of deleting it outright, and a version where one attribute picks the colors and the icon together.
How it works
Alert variants share a base .alert class and extend it with modifier classes like .alert-success. Each modifier applies a background using color palette variables like var(--teal-dim) and a border in the matching accent color, which keeps the set visually consistent. The dismissible variant uses a hidden checkbox: checking it sets display: none on the alert via the ~ sibling combinator.
The base rule carries every structural decision. display: flex with align-items: flex-start keeps the icon aligned to the first line of text rather than to the vertical center of a message that might wrap to three lines. .alert-body { flex: 1 } lets the text take whatever space is left after the icon and the close button, and flex-shrink: 0 on both of those stops them being squeezed when the message is long. Four declarations decide the whole layout, and none of them are repeated in the modifiers.
border: 2px solid with no color named is deliberate. The shorthand sets the color to currentColor, so a modifier that only sets color would still get a matching border. The modifiers here set border-color explicitly as well, which is the safer version, because it survives a later rule changing the text color for contrast reasons without silently changing the border to match.
The dismiss is the checkbox pattern in its simplest form. An <input type="checkbox"> sits immediately before the banner, a <label for="dismiss-1"> inside the banner points back at it, and .alert-dismiss-input:checked ~ .alert-dismissible { display: none } removes it. As with every use of the general sibling combinator, the input must come first in the source and share a parent with what it controls. The comment in the HTML above says so for a reason: this is the part people move and then cannot work out why nothing happens.
There is a transition in that stylesheet that never runs. .alert-dismissible declares transition: opacity .2s, max-height .3s, but the dismiss rule changes display, and display is not what that transition is watching. Neither opacity nor max-height is ever changed by anything, so the banner vanishes instantly and the declaration does nothing at all. It is a common way to end up with dead CSS: the transition was written first, the state change was written second, and they were never checked against each other. The first variant below is the version that animates.
Color alone is not a severity system. Roughly one in twelve men has some form of red and green color deficiency, and a yellow warning next to a red error is exactly the pair that collapses. The banners here work because each one names itself in text. If you drop those labels to save space, the icon has to carry the meaning instead, and a decorative glyph in a <span> is not reliable for that. Keep the word.
Accessibility notes
Pick the right role for when the banner arrives. A banner that is in the HTML when the page loads needs no role at all, because it is already in the document flow. role="alert" is a live region role meant for content inserted after the page has rendered, and putting it on static markup either does nothing or triggers twice depending on the engine. Use role="status" for a message that appears later and is not urgent, and role="alert" for one that is.
The icons are decoration and should say so. ℹ and ⚠ have Unicode names (information source, warning sign) that assistive technology may expose in the middle of a sentence. Mark them aria-hidden="true", and rely on the visible Error or Warning text to carry the severity. The second variant below moves the glyph into a pseudo-element, which achieves the same thing, since generated content is treated as presentational.
The dismiss checkbox is hidden with display: none, which takes it out of the tab order and leaves the close control unreachable from a keyboard. Position it absolutely at one pixel with opacity: 0 instead, so it still receives focus, then draw the ring on the label with :focus-visible. The same fix applies to the confirm button pattern, which hides its checkbox the same way.
Mistakes worth avoiding
- Declaring a transition for a property nothing changes.
transition: opacity .2snext to a rule that setsdisplay: noneis dead code. Nothing errors, nothing animates, and the declaration survives every review because it looks correct. - Putting the checkbox after the banner.
~matches forward only, so the dismiss rule never matches and the close button does nothing at all. - Hiding the checkbox with
display: none. It leaves the tab order, and with it goes any way to dismiss the banner without a mouse. - Letting color carry the severity on its own. Yellow and red are the pair most commonly confused, and they are the pair that matters most. Keep a word in the markup.
- Putting
role="alert"on a banner that was in the initial HTML. Live region roles are meant for content that changes, so on static content the role either does nothing or triggers twice, and it never does what the author expected.
Ship it
5 features in 4 browsers, 5 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
CSS3 selectors | 4 | 3.5 | 3.2 | 12 |
flexbox | 21 | 28 | 6.1 | 12 |
custom properties | 49 | 31 | 10 | 16 |
CSS transitions | 4 | 5 | 5.1 | 12 |
CSS grid animation | 107 | 66 | 16 | 107 |
The banners themselves work anywhere. The one modern piece is the collapsing dismiss in the first variant, which relies on animating grid-template-rows between 1fr and 0fr. Can I Use tracks that as CSS Grid animation, and the figures in the last row are the ones that matter for it. Where it is unsupported the banner still disappears when dismissed, it goes instantly rather than sliding shut, which is a fallback you get for free rather than one you have to write.
What you can build with it
- Form submission results. A success or error summary at the top of a form, present in the HTML the server returned rather than injected afterwards.
- Account state. Expiring passwords, unverified email addresses, and payment methods about to lapse. These persist across pages, so they should not be dismissible without something remembering the dismissal.
- Scheduled maintenance. A site wide banner with a date and a link. Dismissible, because the reader only needs to see it once.
- Unsaved changes. A warning tied to the state of the page rather than to an event, which is why it stays put rather than behaving like a toast notification.
- Deprecation notices. In documentation and admin panels, where the message needs to sit next to the thing it describes rather than float over the page.
Frequently asked questions
How do I make a CSS alert dismissible without JavaScript?
<label for="dismiss-1"> inside the banner as the close control, and hide the banner with .input:checked ~ .alert { display: none }. The catch is that nothing remembers the dismissal. Reload the page and the banner is back, because the state lives in the checkbox and the checkbox is recreated with the page.Can a dismissed alert stay dismissed?
Why does my alert disappear instantly instead of animating?
display: none, and display cannot be transitioned in the usual way. Animate grid-template-rows from 1fr to 0fr with an overflow: hidden wrapper instead, which collapses a banner of unknown height smoothly. The first variant below is built that way.Should an alert banner use role=alert?
role="alert" is a live region role meant for content that changes, and on markup that was there from the start it either does nothing or triggers twice. Static banners need no role. Non-urgent messages that appear later want role="status".