Lets build a HTML- and CSS-only Popup Modal

Mate Marschalko
10 min readDec 2, 2024

--

Last week I posted a detailed introduction about the basic building blocks we will need for this project:

Custom Styled CSS Checkbox and Radio Buttons — In-depth intro

The basic idea of the trick we used there was that we allowed hidden checkboxes and radio buttons to be switched and toggled by custom styled labels.

So if all this is possible, can we launch and dismiss a popup modal with just a checkbox and a pair of buttons? Let’s find out! At the end of the day a modal is just a two-state style difference controlled by a button very similar to what we did with the checkbox labels but with a slightly more sophisticated styling.

Her’s the demo:

To get started, we need a page with some random content to add the modal to:

You can download the template and example project from my Github:

The HTML content for this simple page will look something like this:

<main>
<input type="checkbox" id="modal-toggle" />
<label for="modal-toggle" class="open-button">
Open Modal
</label>
<p>Lorem ipsum dolor sit amet…</p>
</main>

The checkbox input element with the modal-toggle ID will be the hidden checkbox the label controls. The overlay modal will open and close when this checkbox is toggled.

The label element with the “Open Modal” text is linked to this hidden checkbox with the for attribute by referencing the id of the input.

Let’s now add some CSS to hide the input field and style the label element to look like a rectangular button:

main {
padding: 50px;
}
[type="checkbox"] {
display: none;
}
.open-button {
width: 200px;
height: 30px;
background-color: var( - blue);
padding: 10px;
cursor: pointer;
}

The checkbox is controlled by the label which should now look like a button and have a blue colour. All we have left to to do is create the modal box that will appear when the checkbox is checked:

Let’s add the HTML and CSS for this modal first, then we can worry about the hide and show logic later:

<main>
<input type="checkbox" id="modal-toggle" />
<label for="modal-toggle" class="open-button">
Open Modal
</label>
<div class="modal">
<div class="modal-box">
<div class="modal-content">Welcome to our site!</div>
<label for="modal-toggle" class="close-button">
Close
</label>
</div>
</div>
<p>Lorem ipsum dolor sit amet…</p>
</main>

The outermost element with the modal class is the full-width and full-height transparent dark backdrop between the page and the white modal-box element. The CSS for these elements include changes to width and height, positioning, background colour and we’ve also included some flexbox to centre things:

.modal {
width: 100%;
height: 100%;
position: fixed;
left: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.7);
}
.modal-box {
width: 40%;
min-width: 300px;
height: 300px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
background-color: white;
}

Inside the modal-box element we have two things: the content and the close button. The content is good as is but the close button will need some styling:

.close-button {
position: absolute;
right: 15px;
top: 15px;
padding: 2px 8px;
border: 4px solid currentColor;
cursor: pointer;
}

This close button is where the magic happens!

The close button is just another label for the same checkbox!

The ID reference was modal-toggle inside the for attribute of both <label> elements.

The hidden checkbox is now ticked by the open button and unticked by the close button. To finish this off we need to add a bit more CSS responsible for hiding the modal by default and revealing it when the checkbox is checked. Let’s hide the modal first when the page is loaded:

#modal-toggle ~ .modal {
opacity: 0;
pointer-events: none;
}

This selector selects the modal element that comes after the checkbox with the modal-toggle ID and then makes it fully transparent. However, making something transparent with opacity doesn’t make it invisible to mouse events: the modal still covers the elements under it in the stacking order rendering our open modal button not clickable. To fix this we added the pointer-events: none declaration to make the element invisible for the mouse too.

The final step is to show the the modal when the checkbox is checked:

.modal {
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
position: fixed;
left: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
transition: opacity 300ms;
}
#modal-toggle:checked ~ .modal {
opacity: 1;
pointer-events: all;
}

The new selector here selects the modal element appearing after a checked checkbox and the block of CSS code inside makes it fully visible and enables all mouse events. We also added a transition to the opacity property to let the modal fade in with a subtle animation.

The modal window is now complete! We can open it and close it by clicking on the buttons. All achieved with a checkbox and two labels.

Cookie policy footer

Another very common interface element on websites is the slide-in privacy and cookie policy acceptance banner:

A banner like this would usually slide in from the top or bottom of the page notifying you of something or asking you to make a choice by clicking on some buttons. Clicking the button would then dismiss the banner. To make this a little different to the modal logic and to learn something new, let’s delay the slide-in animation by a few seconds. We could add a delay setting to the transition, but this would not prevent the banner from sliding in immediately after the page is loaded. This setting would only take effect when the banner is dismissed and at that point it would slide out of the screen after the pre-defined delay which is not what we want.

To have a lot more flexibility with the sliding transition we will use a CSS animation instead.

Before all this, let’s add some HTML to support our welcome banner. You can add is right after the paragraph of dummy text:

<input type="checkbox" id="banner-toggle" checked />
<div class="banner">
Welcome to our site!
<label for="banner-toggle" class="close-banner">
Close
</label>
</div>

Just like before, the slide-in and -out behaviour will rely on a hidden checkbox. This time, though, the checkbox is checked by default as we want the banner to be opened after the page loads. This means the banner is visible by default and you can dismiss it by clicking the close button.

The styling of the banner and the close button inside is quite straightforward:

.banner {
width: 100%;
height: 50px;
background-color: var( - dark-blue);
color: white;
position: fixed;
bottom: 0;
left: 0;
padding: 12px;
text-align: center;
}
.close-banner {
padding: 6px 8px;
margin-left: 20px;
background-color: var( - yellow);
color: black;
}

We said that we would use a CSS animation to have better control over the sliding and the delay. Every CSS animation needs a pre-defined set of keyframes to play. In this case we only need two keyframes: moving the element into the viewport from the bottom of the page:

@keyframes slide-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}

The percentage value used with the translateY (and also translateX) property is a percentage relative to the dimensions of the element itself. So moving the element 100% on the Y axis will move the element down as much as the height of the element is.

Setting translateY to 0 will move it back to its original position which we set to be the very bottom of the browser viewport. We did this after setting the display property to fixed which means that the left, top, right and bottom positional coordinates will always be relative to the edges of the viewport and not the document which also means the position of the element will be independent from the scroll position.

Diving deeper into CSS animations

We have already defined a slide-in animation for the banner but we will want to move the banner out of the viewport eventually with a slide out animation.

Instead of defining this separately, we should try and recycle our existing animation somehow. At the end of the day it’s the same animation just played backwards.

It turns out there is an animation-direction property that we can set to reverse which is supposed to play the animation keyframes backwards. Unfortunately, setting the direction to reverse will not restart the animation. It only changes the play direction of an animation that’s already playing but we can’t restart an animation that has finished.

Let’s get started by first adding the slide-in animation with a 3 second delay to the banner:

.banner {
width: 100%;
height: 50px;
background-color: var( - dark-blue);
color: white;
position: fixed;
bottom: 0;
left: 0;
padding: 12px;
text-align: center;
animation-name: slide-in;
animation-delay: 3s;
animation-fill-mode: backwards;
animation-duration: 300ms;
}

Let’s analyse what we have here. Firstly, we referenced the name of the keyframe definition, slide-in, to assign the animation to the element with the animation-name property.

Then we set the animation-delay to 3 seconds which means the animation will only start after 3 seconds.

The animation-fill-mode setting defines what should happen before the animation starts and after it finished playing. Setting this to backwards tells the browser to apply the CSS values from the first keyframe before the animation starts, in our case during the 3-second delay. Without this setting the browser would not even look at the animation for 3 seconds so the banner would be visible and not wait outside of the viewport. This is because we didn’t set the translateY property to 100% outside the animation keyframes which is where the animation starts. But that’s fine as we can control this with animation-fill-mode. We could have also set this to forwards to keep the values from the last keyframe after the animation finished. And we also have the both setting to fill the values in both before and after the animation.

Finally, we set the animation to take 300 milliseconds to play with the animation-duration property.

With these settings the banner slides in after 3 seconds and stays like that forever so we need to make sure it slides out when the close button is clicked.

You might think we could simply do something like this to play the animation backwards, but this won’t work:

/* This doesn't work */
#banner-toggle:checked ~ .banner {
animation-direction: reverse;
animation-play-state: playing;
}

This won’t play our animation backwards the slide the footer back out because CSS animations are not reset automatically: when you change the animation-direction to reverse and animation-play-state to playing, it will only affect an ongoing animation. CSS animations only play once by default unless the animation-iteration-count is set to a value greater than 1 or to infinite. We could reset the animation by creating a second animation keyframe definition for sliding out but this is what we wanted to avoid.

So for now, we will just use a simple transition:

.banner {
/* Existing styles */
transform: translateY(100%);
transition: transform 300ms;
}
#banner-toggle:checked ~ .banner {
transform: translateY(0);
}

The two transform declarations will set the banner to be inside or outside the viewport depending on the checkbox’s state, all with a smooth, 300-millisecond transition.

The interactive modal and the dismissible banner are both fully done now.

Congratulations!

Final thoughts

The principle behind both the modal window and the dismissible banner is very simple: we only have two hidden checkboxes and three labels to control them. Regardless of the simplicity, this idea gives us so many possibilities!

Especially if we remember that the label doesn’t have to be a simple piece of text or a simple button. It can be changed freely to a complex component as it works as a container for other HTML elements. Not to mention that you can add more than one label to switch the same checkbox or the ability to animate these elements.

We’ve also mentioned radio buttons previously and even though they work very similarly to checkboxes but it’s worth demonstrating their unique capability to control each other when they are grouped together.

Ultimately, a checkbox with a label gives you the ability to toggle anything on the page back and forth from one visual state to another. For example:

  • Custom on/off toggles in forms
  • Expand/collapse content button
  • Slide-in/-out burger menu

On the other hand, radio buttons can switch many connected elements at the same time. You always have one element with an active/selected state and all other elements in the group are inactive/unselected. You can then change the active element by clicking on any of the inactive elements. For example:

  • Tabbed content
  • Star rating component
  • Content accordion

In the upcoming CSS posts, I’m going to build all three of these!

All you need is HTML and CSS

FYI, this is an example project from my book, All you need is HTML and CSS:

There are many other examples in the book pushing the limits of HTML and CSS including interactive carousels, accordions, calculating, counting, advanced input validation, state management, dismissible modal windows, and reacting to mouse and keyboard inputs. There’s even an animated pure CSS clock (analog and digital) and a fully working star rating widget!

--

--

Mate Marschalko
Mate Marschalko

Written by Mate Marschalko

Senior Creative Developer, Generative AI, Electronics with over 15 years experience | JavaScript, HTML, CSS

Responses (1)