CSS Alert Banner
Info, success, warning, and error alert variants styled with CSS custom 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 left 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 amber 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.
Four severities and a dismiss
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-left: 3px 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;
}
Other ways to build it
A dismiss that actually 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 simply 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-left: 3px 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"; }
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-left 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-left: 3px 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> 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 actually animates.
Color alone is not a severity system. Roughly one in twelve men has some form of red and green color deficiency, and an amber 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.
CSS properties used
displayflexon the base class puts the icon, the message and the close control on one row.noneon the checked state is what removes a dismissed banner.align-itemsflex-startkeeps the icon level with the first line of the message instead of centered against a block that might wrap.border-left- The severity stripe. Written without a color so it falls back to
currentColor, then overridden per modifier. :checked- Reads the dismiss checkbox. With
~it reaches the banner that follows the input, which is why the input has to come first in the markup. grid-template-rows- Animatable between
1frand0fr, which is how the first variant below collapses a banner of unknown height without hardcoding one. overflowhiddenon the inner wrapper of a collapsing banner. Without it the content spills out of the shrinking row and the collapse looks like nothing is happening.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
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 just goes instantly rather than sliding shut, which is a fallback you get for free rather than one you have to write.
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 a screen reader reads it in document order like any other content. role="alert" only announces content inserted after the page has rendered, and putting it on static markup either does nothing or produces a duplicate announcement 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 ⚠ are announced by some screen readers as their Unicode names, which lands as information source or warning sign 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.
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.
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. Amber 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 announce changes, so on static content the role either does nothing or fires twice, and it never does what the author expected.
Frequently asked questions
How do I make a CSS alert dismissible without JavaScript?
<label for> 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, so it announces changes, and on markup that was there from the start it does nothing useful. Static banners need no role. Non-urgent messages that appear later want role="status".