CSS Toast Notification

A slide-in notification using :target to trigger and a CSS animation to auto-dismiss.

Published July 19, 2026 Intermediate 8 min read

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

Changes saved successfully!
Something went wrong. Try again.
Your session expires in 5 minutes.

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.

Saved to your workspace.

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.

Show timed toast
Export started. This closes on its own.

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 id equals the current URL fragment. The only pseudo-class in CSS that reads the address bar.
position
fixed pins 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-mode
forwards holds each animation's final value. Without it the toast snaps back to full opacity the instant the fade completes.
transform
translateX(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 none to flex by the :target rule. Because it is not transitionable, the entrance has to come from an animation, which restarts each time the element is rendered.

Browser support

FeatureChromeFirefoxSafariEdge
CSS3 selectors43.53.212
CSS animation455.112
CSS transitions455.112
2D transforms43.53.112
box-shadow43.5512

: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 :target never 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: flex at opacity: 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?
Showing and hiding it does not. :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?
The close link is 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?
Not with :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.
Should a toast dismiss itself automatically?
Only when the message is safe to miss. A save confirmation qualifies. Anything the reader has to read, act on, or undo does not, because three seconds is below a slow reading speed and there is no way to ask for it back.