DEV Community

loading...

How to make a dismissible alert banner with just CSS

ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
・6 min read

I've written several posts on using web components to build new and useful components for your own component libraries but did you know you can make many types of components with no JS at all? Here we'll explore some techniques for making a CSS-only dismissible banner.

But why though?

Part of it is just to be cool and say you did, but there are real advantages to it, namely "progressive enhancement." This term means that the better the user's environment, the better the component will be. By building for the minimum, we essentially ensure that the most possible users have a good experience and that usually comes with performance benefits. For example, your component will render and be interactive much faster if it doesn't need JS. When I say "CSS-only," that doesn't means "never JS," we can take components built in a CSS-only paradigm and further enhance them with JS. However, even without it, we should have a functionally interactive component. You might think that lack of JS is not a common scenario and it isn't, but think about what happens when JS is slow to load on a phone, or maybe it breaks because it's an old browser, we can still have a good non-static fallback.

The markup

<nojs-alert>
            <label class="alert-container">
                <input type="checkbox" class="alert-flag">
                <div class="alert-content">
                    <!--put content here-->
                    <div role="img" id="stop-icon">🛑</div>
                    <div>
                        <h1>You have encountered an error of great importance!</h1>
                    <!--/put content here-->
                </div>
                <svg width="24" height="24" role="button" class="alert-close">
                    <line x1="0" x2="24" y1="0" y2="24" stroke="black" stroke-width="5" />
                    <line x1="24" x2="0" y1="0" y2="24" stroke="black" stroke-width="5" />
                </svg>
            </label>
</nojs-alert>
Enter fullscreen mode Exit fullscreen mode
nojs-alert {
    display: block;
}
nojs-alert .alert-flag {
    display: none;
}
nojs-alert .alert-container {
    pointer-events: none;
    position: relative;
}
nojs-alert .alert-content {
    border: 1px solid black;
    background: lightpink;
    padding: 1rem;
    display: flex;
    align-items: center;
}

nojs-alert .alert-flag:checked ~ .alert-close,
nojs-alert .alert-flag:checked ~ .alert-content {
    display: none;
}
nojs-alert .alert-close {
    pointer-events: all;
    position: absolute;
    top: 1rem;
    right: 1rem;
    font-size: 32px;
    cursor: pointer;
}

#stop-icon {
    font-size: 40px;
    margin-right: 2rem;
}
Enter fullscreen mode Exit fullscreen mode

So right off the bat I'm enclosing everything a custom element. You don't have to but this will make it easier when transitioning to a native custom element, including the hyphenated name.

Next, everything is inserted into a label. This is the magic and also where assistive technology might have some issues. When we "dismiss" an alert we aren't going to remove it from the DOM, we're just going to hide it. To do this we're going to use adjacency selectors to toggle visibility when the checkbox is checked.

There are some things to consider here. The markup I'm using is for modularity. I'm making it easy to copy/paste without having to define ids. If you are okay adding unique ids per component then a better way would be to pull the .alert-content out of the label and have the label just around the svg (close button) with an for attribute tying it back to the checkbox. This is better because otherwise we need to disable pointer-events to make it on the content to make it unclickable which may not always be desirable.

The svg is an "×" symbol to indicate it's a close button, you could also use a font with × (multiplication). You might think it would be better to use a button and you'd be right in most cases. However, a button absorbs the click event which we need to propagate to the label in order to check the checkbox. We can use ARIA to repair the button role but I found that screen readers don't really care.

Another downside to CSS is that we can't animate it. Since it goes to display: none we can't do fancy transitions. If we chose not to use display: none we run the risk of taking up layout space since we're never actually removing it.

Enhancing with JS

With JS we can properly remove it from the DOM. We can also deal with fancier animation, pretty much anything we want.

export class NoJsAlert extends HTMLElement {
    constructor(){
        super();
        const closeCheck = this.querySelector("input[type='checkbox']");
        if(closeCheck.checked){
            this.remove();
        } else {
            closeCheck.addEventListener("change", () => {
                this.remove();
            });
        }
    }
}

customElements.define("nojs-alert", NoJsAlert);
Enter fullscreen mode Exit fullscreen mode

Here's a small enhancement that will remove the element. If the script loads late, it checks the checkbox and removes the element if checked. If not, it adds an event listener to remove it. Note that this is entirely optional and the user does not see any difference.

I'll leave it as an exercise to add a transition but it's basically adding your code to the close event click listener.

Demo

Trade-offs

This is not without some tradeoffs. The markup is fairly ridged to make the selectors work and this means it won't be as pretty or compact as an ideal component. It also means there could be slight weirdness with assistive technology because we are stretching the limits of HTML. Screen-readers might respond awkwardly and you might need to resort to using more ARIA to get around it.

For example Chrome + NVDA simply doesn't read content inside a custom element, no matter what ARIA attributes are added. It will also not read buttons inside of a label so this just won't work at all.

I was able to fix it up though but it take somes different approaches. The downside is I can no longer wrap it in an custom element, it has to be a div with a class. Also the checkbox can't be hidden or it will not be read (even with ARIA attributes) so we need to visually "hide" it and the label needs to be separate from the content. It's a little weird for the close button to be read as a checkbox but it's still understandable which as about all we can hope for.

Attempt 2 Fixed for Screen Readers

<div class="nojs-alert">
  <input type="checkbox" class="alert-flag" id="alert-id-0" aria-label="toggle alert alert">
  <div class="alert-content">
    <div role="img" id="stop-icon">🛑</div>
    <div>
      <h1>You have encountered an error of great importance!</h1>

      <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla lacus magna, maximus sit amet elementum
        ac, venenatis et nisi. Aenean cursus erat eu odio bibendum fringilla. Nulla nec faucibus ligula, egestas
        malesuada urna. Proin non efficitur arcu. Proin quis dapibus lectus. Vivamus elit eros, accumsan ac
        varius eu, pharetra nec magna. Aenean porttitor velit risus, ut mattis purus elementum gravida. Sed
        efficitur, orci eget eleifend molestie, arcu leo eleifend dui, et consectetur arcu felis non velit.
        Proin et lobortis orci. In ipsum justo, convallis eget mollis at, sagittis eu purus. Aliquam
        pellentesque sagittis risus, scelerisque scelerisque justo tristique et.</p>
    </div>
  </div>
  <label for="alert-id-0" role="button" class="alert-close" aria-label="Close Alert">
    <svg width="24" height="24" role="button" arial-label="close alert">
      <line x1="0" x2="24" y1="0" y2="24" stroke="black" stroke-width="5" />
      <line x1="24" x2="0" y1="0" y2="24" stroke="black" stroke-width="5" />
    </svg>
  </label>
</div>
Enter fullscreen mode Exit fullscreen mode
.nojs-alert {
    display: block;
}
.nojs-alert .alert-flag {
    position: absolute;
    clip: rect(1px, 1px, 1px, 1px);
    padding:0;
    border:0;
    height: 1px; 
    width: 1px; 
    overflow: hidden;
}
.nojs-alert .alert-container {
    display: block;
    pointer-events: none;
    position: relative;
}
.nojs-alert .alert-content {
    border: 1px solid black;
    background: lightpink;
    padding: 1rem;
    display: flex;
    align-items: center;
}

.nojs-alert .alert-flag:checked ~ .alert-close,
.nojs-alert .alert-flag:checked ~ .alert-content {
    display: none;
}
.nojs-alert .alert-close {
    pointer-events: all;
    position: absolute;
    top: 1rem;
    right: 1rem;
    font-size: 32px;
    cursor: pointer;
}
.nojs-alert .alert-close:focus {
    outline: 1px solid black;
}

#stop-icon {
    font-size: 40px;
    margin-right: 2rem;
}
Enter fullscreen mode Exit fullscreen mode

And if we still want to progressively enhance it:

const alert = document.querySelector(".nojs-alert");
const closeBtn = alert.querySelector(".alert-flag");
if(closeBtn.checked){
  alert.remove();
} else {
    closeBtn.addEventListener("change", () => {
            alert.remove();
        });
}
Enter fullscreen mode Exit fullscreen mode

The CSS for visually hidden elements (.nojs-alert .alert-flag) is a bit gnarly. It's basically clipping the element down to nothing so you don't see it but it's not understood as "hidden" for the purposes of screen readers.

And finally the demo:

It's ever uglier but that's the price to pay to work with screen readers. It's a real shame but it seems like we can't have our cake and eat it. Hopefully it gives you a little bit of inspiration.

Discussion (0)