CSS Toast Notification
A slide-in notification using :target to trigger and a CSS animation to auto-dismiss.
A CSS toast notification slides a short message into a corner of the screen and takes itself away again, and the version at the top of this page does it with :target. Each trigger is a link to a fragment. Clicking it puts that fragment in the URL, the matching element starts matching :target, and two animations run in sequence: one to slide it in, and a second on a three second delay to slide it back out.
:target reads the URL, which means using it has consequences beyond the element you are styling. Every click writes a history entry, the browser scrolls in response to the fragment, and only one element in the document can be the target at a time. Those are covered in detail below, along with two versions that avoid them: one built on a checkbox, and one that keeps :target but fixes the parts of it that are fixable.
Three toasts, driven by the URL hash
HTML
<div class="toast-triggers">
<a href="#toast-success" class="toast-btn toast-btn-success">✓ Show success</a>
<a href="#toast-error" class="toast-btn toast-btn-error">✕ Show error</a>
<a href="#toast-info" class="toast-btn toast-btn-info">ℹ Show info</a>
</div>
<div id="toast-success" class="toast">
<span class="toast-icon">✓</span>
<span class="toast-msg">Changes saved successfully!</span>
<a href="#" class="toast-close">✕</a>
</div>
<div id="toast-error" class="toast">
<span class="toast-icon">✕</span>
<span class="toast-msg">Something went wrong. Try again.</span>
<a href="#" class="toast-close">✕</a>
</div>
<div id="toast-info" class="toast">
<span class="toast-icon">ℹ</span>
<span class="toast-msg">Your session expires in 5 minutes.</span>
<a href="#" class="toast-close">✕</a>
</div>
CSS
.toast-triggers {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: .75rem;
margin-bottom: .5rem;
}
.toast-btn {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .55rem 1.1rem;
border: none;
border-radius: 8px;
font-family: system-ui, sans-serif;
font-size: .875rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: opacity .15s;
}
.toast-btn:hover { opacity: .85; }
.toast-btn-success { background: rgba(87, 217, 163, .15); color: #57d9a3; }
.toast-btn-error { background: rgba(255, 107, 107, .12); color: #ff6b6b; }
.toast-btn-info { background: rgba(56, 189, 248, .12); color: #38bdf8; }
.toast {
display: none;
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
min-width: 260px;
max-width: 340px;
padding: .85rem 1.1rem;
align-items: center;
gap: .75rem;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
font-size: .9rem;
font-weight: 500;
color: #f0f0f0;
}
.toast:target {
display: flex;
animation:
toast-in .3s ease forwards,
toast-out .3s ease 3s forwards;
}
.toast-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.toast-msg { flex: 1; }
.toast-close {
flex-shrink: 0;
color: #88888f;
text-decoration: none;
font-size: 1.1rem;
line-height: 1;
transition: color .15s;
}
.toast-close:hover { color: #f0f0f0; }
#toast-success { border-left: 3px solid #57d9a3; }
#toast-error { border-left: 3px solid #ff6b6b; }
#toast-info { border-left: 3px solid #38bdf8; }
@keyframes toast-in {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(120%); opacity: 0; }
}
Other ways to build it
A checkbox toast, with the URL left alone
Swapping :target for a checkbox costs nothing in markup and removes every URL side effect at once. No history entries, no scroll, no shareable link that pops a toast at a stranger, and the same trigger works as many times as you press it because a checkbox toggles rather than latching. The trade is that the toast no longer dismisses itself: state and display stay in agreement precisely because nothing changes them but the reader. The close control is a second label pointing at the same input, and both are keyboard operable.
HTML
<div class="tn-local">
<input type="checkbox" id="saved" class="tn-hidden-input">
<label for="saved" class="toast-btn">Save changes</label>
<div class="tn-local-toast" role="status">
<span class="toast-icon">✓</span>
<span class="toast-msg">Saved to your workspace.</span>
<label for="saved" class="tn-close">✕</label>
</div>
</div>
CSS
/* uses @keyframes toast-in from the main example above; the rules here are
what changes, not the whole file */
/* Rendered, so it stays in the tab order. An absolutely positioned child
of a centering flex container takes its static position from the
alignment properties, which lands this 1px box on top of the label;
without pointer-events: none it swallows the label's clicks. */
.tn-hidden-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.tn-local-toast {
display: none;
/* anchored to the panel this sits in, not the viewport */
position: absolute;
bottom: 1rem;
right: 1rem;
align-items: center;
gap: .75rem;
padding: .85rem 1.1rem;
background: #141415;
border: 1px solid #2a2a2d;
border-left: 3px solid #57d9a3;
border-radius: 12px;
}
.tn-hidden-input:checked ~ .tn-local-toast {
display: flex;
/* no delayed exit, so the state and the display cannot disagree */
animation: toast-in .3s ease forwards;
}
.tn-hidden-input:focus-visible + .toast-btn {
outline: 2px solid #b8ff57;
outline-offset: 3px;
}
Keeping :target, with a timer bar and a harmless close
Two repairs to the original. The close link points at an empty anchor beside the trigger rather than at #, so dismissing it no longer throws the reader to the top of the document, and because that leaves a different fragment in the URL the trigger works again afterwards. The bar along the bottom drains over the same three seconds as the dismissal delay, using scaleX from a left origin so the browser can run it on the compositor. Showing the reader that a clock is running is the least a self dismissing message can do.
HTML
<!-- the close link targets this, not "#" -->
<span id="closed"></span>
<a href="#timed" class="toast-btn">Show timed toast</a>
<div id="timed" class="tn-timed-toast" role="status">
<span class="toast-icon">ℹ</span>
<span class="toast-msg">Export started. This closes on its own.</span>
<a href="#closed" class="tn-close">✕</a>
<span class="tn-timed-bar"></span>
</div>
CSS
/* uses @keyframes toast-in, @keyframes toast-out from the main example above; the rules here are
what changes, not the whole file */
@keyframes tn-drain {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
.tn-timed-toast {
display: none;
position: absolute;
bottom: 1rem;
right: 1rem;
align-items: center;
gap: .75rem;
padding: .85rem 1.1rem;
background: #141415;
border: 1px solid #2a2a2d;
border-left: 3px solid #38bdf8;
border-radius: 12px;
/* clips the drain bar to the rounded corners */
overflow: hidden;
}
.tn-timed-toast:target {
display: flex;
animation:
toast-in .3s ease forwards,
toast-out .3s ease 3s forwards;
}
.tn-timed-bar {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background: #38bdf8;
/* scaleX from the left edge, so it runs on the compositor */
transform-origin: left;
transform: scaleX(0);
}
.tn-timed-toast:target .tn-timed-bar {
animation: tn-drain 3s linear forwards;
}
How it works
Trigger links use href="#toast-success" to set the URL hash. The matching <div id="toast-success"> element receives the :target pseudo-class and switches from display: none to display: flex. A CSS animation slides it in from the right, moving from translateX(120%) to translateX(0), and a second animation with animation-delay: 3s fades it out again. Clicking the close <a href="#"> removes the hash target, hiding the toast immediately.
:target matches the one element whose id equals the current URL fragment. That gives CSS something almost unique: a state a click can write, without a form control and without script. It also means the state lives in the address bar, so it survives a reload, can be linked to directly, and is subject to everything the browser does with fragments. A toast that appears because someone pasted a URL is a strange thing to ship, and it is the default behavior here.
The dismissal is a second animation rather than a second state. animation: toast-in .3s ease forwards, toast-out .3s ease 3s forwards runs two named animations off one declaration, and the delay on the second is what creates the pause. Both use forwards, so each holds its end value. The important detail is that nothing about the element's state has changed when the fade finishes. It is still matching :target, still display: flex, and still in the layout at opacity: 0, parked 342 pixels to the right of where it started. It is invisible rather than gone.
That distinction has a real cost. An element at opacity: 0 is still in the accessibility tree and its links are still in the tab order, so after the toast has visually disappeared, tabbing through the page still lands on its close button. If a toast is genuinely finished with, it needs to leave the layout, and an animation alone will never do that because animations do not change state.
Only one element can match :target, because a URL has one fragment. Two toasts cannot be on screen together, and showing a second one hides the first. For a queue of stacked notifications, which is what most real toast systems are, :target is the wrong foundation. It suits a single confirmation that replaces whatever came before it.
The href="#" on the close link is the last piece, and it is the sloppiest. It clears the fragment, which does remove :target and hide the toast, but a bare hash also means the top of the document, so the browser scrolls the page up. Point the close link at an empty anchor next to the trigger instead. The fragment still changes, the toast still closes, and the scroll lands somewhere the reader was already looking. The second variant below does exactly that.
CSS properties used
:target- Matches the element whose
idequals the current URL fragment. The only pseudo-class in CSS that reads the address bar. positionfixedpins the toast to the viewport corner regardless of scroll. It is also why the browser cannot usefully scroll the toast into view when the fragment changes.animation- Two named animations in one declaration, the second delayed by three seconds, produce the entrance and the automatic exit without a second state.
animation-fill-modeforwardsholds each animation's final value. Without it the toast snaps back to full opacity the instant the fade completes.transformtranslateX(120%)parks the toast just outside the viewport edge. Percentages here resolve against the element's own width, so it works at any size.display- Switched from
nonetoflexby the:targetrule. Because it is not transitionable, the entrance has to come from an animation, which restarts each time the element is rendered.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
CSS3 selectors | 4 | 3.5 | 3.2 | 12 |
CSS animation | 4 | 5 | 5.1 | 12 |
CSS transitions | 4 | 5 | 5.1 | 12 |
2D transforms | 4 | 3.5 | 3.1 | 12 |
box-shadow | 4 | 3.5 | 5 | 12 |
:target is part of the CSS3 selectors module and has been supported everywhere for as long as the rest of that module, so the table figure covers it. Nothing on this page needs a prefix or a fallback. The constraints that matter here are behavioural rather than about support: how the browser treats fragments, how history entries accumulate, and the fact that a document has only one target at a time. Those behave identically in every engine, which is the point. They are not bugs to work around, they are what fragments do.
Accessibility notes
A toast that appears without moving focus is announced by nothing. Screen reader users get silence where sighted users get a message. Wrap the toast in role="status", which is an implicit polite live region, so its text is read out when it appears without stealing focus. role="alert" is the assertive version and interrupts whatever is being read, which is right for an error and wrong for a confirmation.
Three seconds is not enough time. WCAG's timing guidance expects content that disappears on its own to be dismissible, adjustable, or long enough to read at a slow reading speed, and an auto-dismissing toast with no way to pause it fails that. Pausing on hover or focus would help, and animation-play-state can do it, but a reader who has not moved their pointer near the toast gets no benefit. A toast carrying anything the reader must act on should not dismiss itself at all.
The invisible toast left behind after the fade is a keyboard trap of the quiet kind: its close link is still focusable, so tab order runs through a control nobody can see. There is no CSS fix, because removing it from the layout means changing state and the state is the URL fragment. Either accept manual dismissal, as the first variant below does, or drive the removal from script.
What you can build with it
- Save confirmations. The single most common toast. Short, non-blocking, and safe to miss, which is what makes an automatic dismissal acceptable here and nowhere else.
- Copy to clipboard feedback. A brief acknowledgement after a copy button, where the action already succeeded and the message is a courtesy.
- Connection state. Back online or working offline, shown once when the state flips rather than persistently.
- Undo prompts. Deleted, with an undo link. Note that this is the case where auto-dismissal is most dangerous, because the reader loses the only way back.
- Form validation summaries. A pointer to what went wrong, better served by an alert banner that stays put, since an error the reader has to fix should not walk away on a timer.
Mistakes worth avoiding
- Using
href="#"to close it. A bare fragment means the top of the document, so the page scrolls up when the reader dismisses the toast. Target an empty anchor near the trigger instead. - Ignoring the history entries. Every trigger click pushes one, so a reader who views three toasts has to press Back four times to leave the page. This is the cost that catches people out, and there is no way to opt out of it while using
:target. - Expecting the same trigger to work twice. Once the fragment is set, clicking the same link again changes nothing, so
:targetnever re-evaluates and the animation does not replay. The toast can be shown exactly once until something else changes the URL. - Assuming the toast is gone once it has faded. It is still
display: flexatopacity: 0, still in the layout, and its close link is still in the tab order. - Trying to stack two of them. A document has one fragment, so a second toast replaces the first rather than joining it. Queued notifications need a different mechanism.
Frequently asked questions
Does a CSS toast notification need JavaScript?
:target or a checkbox can hold the state, and animations handle the entrance and the timed exit. What CSS cannot do is remove the element afterwards, queue several of them, or react to something that happened on the server. Those are the parts that need script.Why does my page jump to the top when I close the toast?
href="#". A bare hash refers to the top of the document, so the browser scrolls there as well as clearing the fragment. Give it a real fragment pointing at an empty element next to the trigger and the scroll goes nowhere noticeable.Why will my toast only show once?
:target depends on the fragment changing. Once the URL already ends in #toast-success, clicking that same link is not a navigation, nothing re-evaluates, and the animation does not restart. Closing the toast by pointing at a different fragment first is what makes the trigger work again.Can I show more than one toast at a time?
:target. There is one fragment per URL and therefore one target per document. Independent checkboxes can each hold their own state, which is the usual way to get several open at once, though positioning a real stack still needs a container that knows how many are showing.