Close menu

How To Create CSS Only Modals

We love CSS. Over the decades, it’s made leaps and bounds from where it started as a styling language for the web.

Although It’s still not technically a programming language, it’s fair to say it’s not that far off!

CSS already implements variables and can implement/simulate conditional statements with certain selectors and media queries.

In the world of web performance, JavaScript, which adds interactivity to web pages, manipulates the DOM on the main thread, and large amounts of main thread work is a leading cause of performance issues.

CSS however, is abstracted enough to performantly handle styling and rendering of the UI - in fact it has it’s own object model; the CSSOM. It also uses a compositor and can tap into the GPU.

One commonplace use of JavaScript for UI is the humble, if not often overused, modal window.

It’s a simple concept, click a link, button or other UI widget and instead of a new page being loaded, a floating window pops up to display some content.

The interactive element of reacting to a click means JavaScript has been the only way to handle the showing and hiding of such modal interfaces.

But, CSS has a nifty little selector that let’s us do modals without JavaScript.

The :target selector

This pseudo selector allows targeting an element that has an id matching the window's current URL fragment.

The fragment of a URL is denoted by the pound sign: # (yes that’s what it’s called! It’s also called an octothorpe and of course, a hash sign).

So in the example example.com/path/#foo, the fragment is #foo.

An element on the page with an id value of foo can then be used as a trigger for a set of styles using :target.

So let’s say we have the following snippet of markup in a page:

<div>
	<p>Foo bar baz</p>
	<p id="foo">widget doodads gadget</p>
</div>

That second p tag with id foo is selected only while the URL contains the fragment #foo.

This means that elements on the page can be conditionally displayed based on URL changes.

Because URL fragments don’t trigger a page load, we can use standard links to add interactivity which changes the display of a page’s content.

So now let’s write some markup which includes a link trigger and an element to use as a modal.

<div>
	<p>We can make a CSS only <a href="#css-modal">modal</a>
	<aside class="modal" id="css-modal">
		<h1>Look Ma! No JS</h1>
		<p>This modal is toggled open using nothing but CSS</p>
	</aside>
<div>

Our trigger link's href attribute is set to the fragment #css-modal.

Our aside will be our modal; we give it an id of css-modal, corresponding to the fragment our trigger links to.

We’ll add some styles for our aside element to look like a modal and crucially, set the initial unopened state of the modal. This state is simple, it's not displayed:

.modal {
    /* Modal window appearance */
    width: 50vw;
    padding: 34px;
    position: fixed;
    z-index: 100;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    background-color: #fff;
    box-shadow: 0 21px 55px 21px #000;
    /* Initial unopened state */
    display: none;
}

Now to show the modal, we use the :target selector on the modal's id as follows:

.modal {
    /* Modal window appearance */
    width: 50vw;
    padding: 34px;
    position: fixed;
    z-index: 100;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    background-color: #fff;
    box-shadow: 0 21px 55px 21px #000;
    /* Initial unopened state */
	display: none;
}

/* opened modal state */
#css-modal:target {
    display: block;
}

And so now when we click the trigger link, the URL fragment is added to current URL, the :target psuedo-selector applies and we'll see the open modal.

Lastly, we need to add the ability to close the modal while opened. To do this, we simply need another link which changes the fragment.

Unfortunately, we can't remove the URL fragment altogether without triggering a page load (unless we do it through JavaScript), so we simply need a link to change the fragment to something different, so that it no longer applies the #css-modal:target selector's styles.

If we use an empty fragment (simply the # symbol with no value), we'll trigger the browser anchoring feature and the page will anchor to the top. To prevent this, we need a fragment value that is unsused, i.e. a value that does not correspond to any element's id value. A good option might be something like an _.

<div>
	<p>We can make a CSS only <a href="#css-modal">modal</a>
	<aside class="modal" id="css-modal">
		<h1>Look Ma! No JS</h1>
		<p>This modal is toggled open using nothing but CSS</p>
        <a href="#_">close</a>
	</aside>
<div>

Now let's see it all in action:

We can make a CSS only modal

There we go! A CSS only modal!

But..

I know what you're thinking; there's no transition. It's a boring, plain appearance and dissappearance.

Let's remedy that.

Visual Effects

CSS display is not animatable, and when styles change this property, other transitions often get skipped.

We need to use a style property that will not just make the modal invisible, but rather prevent the element from rendering on the DOM (as display: none would do). Otherwise, it will overlay and prevent interaction with other rendered DOM elements beneath it.

transform with the scale function is a recommended property for this, as setting it to 0 effectively removes the element from the DOM (and it just so happens transform benefits from all sorts of performance improvements).

.modal {
    /* Modal window appearance */
    width: 50vw;
    padding: 34px;
    position: fixed;
    z-index: 100;
    left: 50%;
    top: 50%;
    background-color: #fff;
    box-shadow: 0 21px 55px 21px #000;
    /* Initial unopened state */
	transform: 
        translate(-50%, -50%)
        scale(0);
}

/* opened modal state */
#css-modal:target {
    transform:
        translate(-50%, -50%)
        scale(1);
}

We can make a CSS only modal

Now, we can apply CSS transitions between the open and closed states. Let's add a simple fade in/out using opacity, as well as transition the transform itself. This will give us an effect of the modal growing to size while fading in. On close it will do the opposite; shrink away while fading out.

.modal {
    /* Modal window appearance */
    width: 50vw;
    padding: 34px;
    position: fixed;
    z-index: 100;
    left: 50%;
    top: 50%;
    background-color: #fff;
    box-shadow: 0 21px 55px 21px #000;
    /* Initial unopened state */
	transform: 
        translate(-50%, -50%)
        scale(0);
    /* visual effects - settings */
    transition:
        transform 250ms ease-out,
        opacity 250ms ease-out;
    /* visual effects - initial state */
    opacity: 0;
}

/* opened modal state */
#css-modal:target {
    transform:
        translate(-50%, -50%)
        scale(1);
    /* visual effects - opened state */
    opacity: 1;
}

We can make a CSS only modal

That's better! Our CSS only modal now transitions nicely.

And that's it - a complete CSS only modal solution.

But...

There is one rather major side effect to doing the modals this way... and you might find that for the best experience... we must use a little bit of JavaScript.

History entry

URL fragments add an entry to the browser history, so as modals are opened and closed with this technique, the browser back and forward buttons will cycle through the changes to the URL, and by extension, the open and closed modals those fragments control. That's not a great experience and unfortunately we can't do anything about it without a little bit of JavaScript.

The solution requires us to intercept clicks on links, and for those that trigger modals, replace the history state entry. For the best performance, we'll use event bubbling to spot the clicks.

const modalIds = /#(css-modal|_)/i;
const historyStateObj = {};

document.addEventListener(
    'click',
    (event) => {
        const { target } = event;
        const href = target.href;

        if (
            target.nodeName === 'A'
            && modalIds.test(href)
        ) {
            history.replaceState(
                historyStateObj,
                '',
                href
            );
        }
    }
);

It could be argued that the above script is needed to complete the experience, and that this disqualifies it as a CSS only solution - however, this is an optional, albeit highly desirable, improvement, the entire feature of the modal itself however is indeed, CSS only.

So if we must use some JavaScript, why not just do the whole thing in JavaScript? Simple - this little bit of script is miniscule in comparison to the DOM lookups and manipulations that would be done with a scripted modal solution.

First published: 11/11/2022