Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Toast Notification

A slide-in notification using :target and CSS animations to appear, dismiss itself after three seconds, and slide in from six different positions.

Level
Intermediate
Published
Updated
Reading time
8 min

Try it

3 live demos

Your browser can't run this demo

It needs :has(), which this browser doesn't support yet, so you're seeing a plainer version. It works in Chrome 105+, Edge 105+, Firefox 121+ and Safari 15.4+.

Needs :has()

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

Three toasts, driven by the URL hash

Other ways to build it

✓ Saved to your workspace.

A checkbox toast, with the URL left alone

See the code

Build it

6 snippets to copy

CSS Toast Notification

HTML

<div class="toast-demo">
  <fieldset class="toast-place">
    <legend>Position</legend>
    <label class="toast-pos"><input class="tp-tl" type="radio" name="toast-place"> Top left</label>
    <label class="toast-pos"><input class="tp-tc" type="radio" name="toast-place"> Top center</label>
    <label class="toast-pos"><input class="tp-tr" type="radio" name="toast-place"> Top right</label>
    <label class="toast-pos"><input class="tp-bl" type="radio" name="toast-place"> Bottom left</label>
    <label class="toast-pos"><input class="tp-bc" type="radio" name="toast-place"> Bottom center</label>
    <label class="toast-pos"><input class="tp-br" type="radio" name="toast-place" checked> Bottom right</label>
  </fieldset>
  <div class="toast-screen">
    <!-- each link targets an empty hidden span, never the toast: the span
         has no box, so the page doesn't scroll; close targets one more -->
    <span id="toast-closed" class="toast-anchor" hidden></span>
    <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>

    <span id="toast-success" class="toast-anchor" hidden></span>
    <div class="toast toast-success" role="status">
      <span class="toast-icon">✓</span>
      <span class="toast-msg">Changes saved successfully!</span>
      <a href="#toast-closed" class="toast-close">✕</a>
    </div>

    <span id="toast-error" class="toast-anchor" hidden></span>
    <div class="toast toast-error" role="status">
      <span class="toast-icon">✕</span>
      <span class="toast-msg">Something went wrong. Try again.</span>
      <a href="#toast-closed" class="toast-close">✕</a>
    </div>

    <span id="toast-info" class="toast-anchor" hidden></span>
    <div class="toast toast-info" role="status">
      <span class="toast-icon">ℹ</span>
      <span class="toast-msg">Your session expires in 5 minutes.</span>
      <a href="#toast-closed" class="toast-close">✕</a>
    </div>
  </div>
</div>

CSS

.toast-demo {
  display: grid;
  gap: 1rem;
  max-width: 36rem;
  font-family: system-ui, sans-serif;
}

/* the position picker: two rows of three, laid out like the
   window; radios clipped, not hidden, so they stay in the tab order */
.toast-place {
  display: grid;
  grid-template-columns: repeat(3, auto);
  justify-content: start;
  gap: .4rem;
  margin: 0;
  padding: 0;
  border: 0;
}

.toast-place legend {
  margin-bottom: .45rem;
  color: #88888f;
  font: 500 .72rem/2 monospace;
  text-transform: uppercase;
}

.toast-pos {
  position: relative;
  padding: .4rem .75rem;
  text-align: center;
  border: 1px solid #2a2a2d;
  border-radius: 999px;
  color: #f0f0f0;
  font-size: .78rem;
  font-weight: 600;
  cursor: pointer;
}

.toast-pos input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.toast-pos:has(:checked) {
  border-color: #b8ff57;
  background: #b8ff57;
  color: #0c0c0d;
}

.toast-pos:has(:focus-visible) {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

/* the window the toasts appear in */
.toast-screen {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 15rem;
  overflow: hidden;
  border: 1px solid #2a2a2d;
  border-radius: 12px;
  background: #141415;
}

.toast-triggers {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: .75rem;
}

.toast-btn {
  padding: .55rem 1.1rem;
  border-radius: 8px;
  font-size: .875rem;
  font-weight: 600;
  text-decoration: none;
}

.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; }

/* bottom right unless a position is picked;
   --toast-from is the edge it slides in from */
.toast {
  --toast-from: translateX(120%);
  display: none;
  position: absolute;
  right: 1rem;
  bottom: 1rem;
  z-index: 2;
  width: min(20rem, calc(100% - 2rem));
  padding: .85rem 1.1rem;
  align-items: center;
  gap: .75rem;
  background: #1c1c1e;
  border: 2px solid #2a2a2d;
  border-radius: 12px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, .3);
  color: #f0f0f0;
  font-size: .9rem;
  font-weight: 500;
}

/* the anchor before the toast is the :target */
.toast-anchor:target + .toast {
  display: flex;
  animation:
    toast-in .3s ease forwards,
    toast-out .3s ease 3s forwards;
}

/* the six places. translate centers, so it never
   fights the animation, which moves transform */
.toast-demo:has(.tp-tl:checked) .toast {
  --toast-from: translateX(-120%);
  top: 1rem; right: auto; bottom: auto; left: 1rem;
}

.toast-demo:has(.tp-tc:checked) .toast {
  --toast-from: translateY(-150%);
  top: 1rem; right: auto; bottom: auto; left: 50%;
  translate: -50% 0;
}

.toast-demo:has(.tp-tr:checked) .toast {
  top: 1rem; bottom: auto;
}

.toast-demo:has(.tp-bl:checked) .toast {
  --toast-from: translateX(-120%);
  right: auto; left: 1rem;
}

.toast-demo:has(.tp-bc:checked) .toast {
  --toast-from: translateY(150%);
  right: auto; left: 50%;
  translate: -50% 0;
}

.toast-icon {
  flex-shrink: 0;
  font-size: 1.1rem;
}

.toast-msg { flex: 1; }

.toast-close {
  flex-shrink: 0;
  color: #88888f;
  font-size: 1.1rem;
  line-height: 1;
  text-decoration: none;
}

.toast-success { border-color: #57d9a3; }
.toast-error   { border-color: #ff6b6b; }
.toast-info    { border-color: #38bdf8; }

@keyframes toast-in {
  from { transform: var(--toast-from); opacity: 0; }
  to   { transform: none; opacity: 1; }
}

@keyframes toast-out {
  from { transform: none; opacity: 1; }
  to   { transform: var(--toast-from); opacity: 0; }
}

@keyframes toast-fade-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes toast-fade-out { from { opacity: 1; } to { opacity: 0; } }

/* reduced motion: the slide goes, the timing stays */
@media (prefers-reduced-motion: reduce) {
  .toast-anchor:target + .toast {
    animation:
      toast-fade-in .3s ease forwards,
      toast-fade-out .3s ease 3s forwards;
  }
}

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 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;
}

/* a small window: the trigger near the top, the toast along
   the bottom, so the toast never covers the button */
.tn-local {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  height: 11rem;
  padding-top: 1.75rem;
  overflow: hidden;
  border: 1px solid #2a2a2d;
  border-radius: 12px;
}

.tn-local-toast {
  --toast-from: translateX(120%);
  display: none;
  position: absolute;
  right: 1rem;
  bottom: 1rem;
  left: 1rem;
  align-items: center;
  gap: .75rem;
  padding: .85rem 1.1rem;
  background: #141415;
  border: 2px solid #2a2a2d;
  border-color: #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

The trigger points at an empty hidden anchor before the toast rather than at the toast itself, and both the trigger and the close link target empty anchors with the hidden attribute. Because the close 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

<div class="tn-timed">
  <!-- the close link targets this, not "#" -->
  <span id="closed" hidden></span>
  <a href="#timed" class="toast-btn">Show timed toast</a>

  <!-- the trigger targets this, not the toast -->
  <span id="timed" class="tn-anchor" hidden></span>
  <div 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>
</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); }
}

/* the same small window as the checkbox version above */
.tn-timed {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  height: 11rem;
  padding-top: 1.75rem;
  overflow: hidden;
  border: 1px solid #2a2a2d;
  border-radius: 12px;
}

.tn-timed-toast {
  --toast-from: translateX(120%);
  display: none;
  position: absolute;
  right: 1rem;
  bottom: 1rem;
  left: 1rem;
  align-items: center;
  gap: .75rem;
  padding: .85rem 1.1rem;
  background: #141415;
  border: 2px solid #2a2a2d;
  border-color: #38bdf8;
  border-radius: 12px;
  /* clips the drain bar to the rounded corners */
  overflow: hidden;
}

.tn-anchor:target + .tn-timed-toast {
  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-anchor:target + .tn-timed-toast .tn-timed-bar {
  animation: tn-drain 3s linear forwards;
}

Understand it

How it works and 6 properties

A toast notification appears inside a small app window and dismisses itself after three seconds, driven by :target and two CSS animations. One animation slides it in, and a second on a delay slides it back out. A position picker controls where the toast appears: any of six places, from the top left to the bottom right.

:target reads the URL, which means using it has consequences beyond the element you are styling. Every click writes a history entry, and only one element in the document can be the target at a time. Those constraints are covered in detail below, along with two versions: one built on a checkbox, and one that keeps :target but adds a timer bar and fixes the close behavior.

How it works

Trigger links use href="#toast-success" to set the URL hash. An empty <span id="toast-success" class="toast-anchor" hidden> receives the :target pseudo-class, and the .toast element right after it switches from display: none to display: flex through the adjacent sibling selector .toast-anchor:target + .toast. The toast is positioned absolutely inside a small app window, and a CSS animation slides it in from its chosen edge, moving from translateX(120%) or translateY(150%) to its final position. A second animation with animation-delay: 3s slides it back out. The position picker is a set of six radios that each set custom properties for the toast's placement and slide direction. Clicking the close link targets an empty hidden anchor, removing :target from the toast's anchor without scrolling the page.

: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. The hidden anchor is still matching :target, the toast is still display: flex, and still in the layout at opacity: 0. 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 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 close link points at an empty hidden anchor next to the trigger. The fragment changes, which removes :target from the toast's anchor, and the toast closes. Because the close link targets an element with the hidden attribute and no box, the page stays exactly where it was instead of scrolling.

Accessibility notes

A toast that appears without moving focus needs role="status", which marks it as an implicit polite live region. The toast's text is then exposed to assistive technology. role="alert" is the assertive version for errors, where interrupting is appropriate.

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 does, or drive the removal from script.

Mistakes worth avoiding

  • Closing with href="#". A bare fragment means the top of the document, so the page scrolls up when the reader dismisses the toast. Target an empty hidden anchor near the trigger instead, one with the hidden attribute and no box.
  • 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.

Ship it

5 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
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 behavioral 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.

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.

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 hidden element with no box next to the trigger and the page stays exactly where it was.
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.