CSS Modal Dialog
A modal using :target to show/hide without JavaScript.
A CSS modal dialog built with :target uses the URL fragment as its open and closed state. The trigger is an ordinary link pointing at the overlay's id, and the overlay is display: none until :target matches it. Because the state lives in the address bar rather than in a variable, it survives a page reload and it can be linked to directly.
That is also the part worth thinking hardest about. Opening the modal pushes an entry into browser history, so the back button closes it instead of leaving the page, and the URL a reader copies while it is open reopens it for whoever they send it to. Both behaviors can be right or wrong depending on what the dialog is for. The checkbox variant below keeps the same look and leaves the URL alone.
Open on click
HTML
<a class="modal-open-btn" href="#my-modal">Open Modal</a>
<div class="modal-overlay" id="my-modal">
<div class="modal-box">
<h3>Pure CSS Modal</h3>
<p>This modal is controlled entirely by the URL hash and :target, zero JavaScript needed.</p>
<a class="modal-close" href="#">Close modal</a>
</div>
</div>
CSS
.modal-open-btn {
display: inline-block;
padding: .6rem 1.25rem;
background: #b8ff57;
color: #0c0c0d;
border-radius: 8px;
font-size: .9rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
}
.modal-overlay {
display: none;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, .55);
z-index: 200;
}
/* :target matches once the URL hash points at this id */
.modal-overlay:target {
display: flex;
}
.modal-box {
position: relative;
width: 90%;
max-width: 400px;
padding: 2rem;
border-radius: 12px;
background: #141415;
box-shadow: 0 8px 32px rgba(0, 0, 0, .18);
}
.modal-box h3 {
margin: 0 0 .75rem;
font-size: 1.2rem;
color: #f0f0f0;
}
.modal-box p {
margin: 0 0 1.25rem;
font-size: .9rem;
color: #88888f;
}
.modal-close {
display: inline-block;
padding: .5rem 1rem;
background: #1c1c1e;
color: #f0f0f0;
border-radius: 6px;
font-size: .875rem;
text-decoration: none;
}
.modal-close:hover {
background: #2a2a2d;
}
Other ways to build it
Backdrop click to close, with an animated entrance
A full size anchor sits inside the overlay behind the box, so every click that is not on the dialog itself lands on that link. It points at #modal-dialog-closed, a fragment that matches nothing, which clears the target without scrolling back to the top the way href="#" does. The box needs a z-index above the backdrop or the backdrop swallows clicks meant for the buttons. The entrance is an @keyframes animation rather than a transition, since display jumps from none to flex in a single step with nothing in between to interpolate.
HTML
<a class="modal-open-btn" href="#confirm">Open with backdrop close</a>
<div class="modal-overlay modal-anim" id="confirm">
<a href="#closed" class="modal-backdrop" aria-label="Close the dialog"></a>
<div class="modal-box">
<h3>Click anywhere outside</h3>
<a class="modal-close" href="#closed">Close modal</a>
</div>
</div>
CSS
/* covers the whole overlay, sits under the box */
.modal-backdrop {
position: absolute;
inset: 0;
z-index: 0;
}
/* without this the backdrop link would intercept clicks on the buttons */
.modal-anim .modal-box {
position: relative;
z-index: 1;
}
/* an animation, not a transition: display jumps in one step */
.modal-anim:target {
animation: modal-fade .2s ease-out;
}
.modal-anim:target .modal-box {
animation: modal-pop .25s ease-out;
}
@keyframes modal-fade {
from { opacity: 0; }
}
@keyframes modal-pop {
from { opacity: 0; transform: translateY(12px) scale(.96); }
}
@media (prefers-reduced-motion: reduce) {
.modal-anim:target,
.modal-anim:target .modal-box { animation: none; }
}
Checkbox modal that leaves the URL alone
Swapping the fragment for a hidden checkbox removes every side effect the hash brings with it. No history entry is added, so the back button leaves the page rather than closing the dialog, and the address bar never changes, so nothing can be deep linked and nothing reopens on reload. The close control and the backdrop are both labels pointing at the same checkbox. Pick this version when the dialog is a transient interruption and the hash version when the panel is somewhere worth linking to.
HTML
<input type="checkbox" id="dlg" class="modal-check">
<label for="dlg" class="modal-open-btn">Open without touching the URL</label>
<div class="modal-check-overlay">
<label for="dlg" class="modal-backdrop" aria-label="Close the dialog"></label>
<div class="modal-box">
<h3>No hash, no history</h3>
<label for="dlg" class="modal-close">Close modal</label>
</div>
</div>
CSS
/* clipped rather than display:none, so the trigger stays keyboard
reachable. clip-path clips hit testing too, which stops the one pixel
box landing on top of its own label and swallowing the click. */
.modal-check {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.modal-check-overlay {
display: none;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, .55);
z-index: 200;
}
.modal-check:checked ~ .modal-check-overlay {
display: flex;
}
.modal-check:focus-visible + .modal-open-btn {
outline: 2px solid #b8ff57;
outline-offset: 2px;
}
How it works
The :target pseudo-class matches an element whose id matches the URL fragment. Clicking the open link sets the URL hash, which makes :target match and shows the overlay. Clicking the close link clears the hash (href="#"), hiding the modal. The overlay is display:none by default and display:flex when :target.
:target matches at most one element per page, because a URL has at most one fragment. Two modals can never be open at once, and opening a second one closes the first with no extra code. The same single value rule is why :target cannot express a nested dialog, a modal opened from inside another modal, without giving up one of the two.
Navigating to a fragment is a real navigation. The browser adds a history entry, so the back button undoes it. On Android that is close to ideal, since the hardware back gesture closes the dialog the way people expect. On a page where someone opens and dismisses four modals, it is worse: leaving the page now takes five presses of back rather than one.
Closing with href="#" clears the fragment and takes the reader to the top of the document, which is jarring if they had scrolled a long way down. Pointing the close link at a fragment that does not exist, such as href="#close", leaves :target unmatched without moving the scroll position. A fragment navigation also scrolls its target into view, which is invisible for an overlay set to position: fixed with inset: 0 and very visible for one left in normal flow. scroll-margin-top controls where that jump lands.
None of the focus behavior a real dialog needs comes for free. There is no focus trap, so tabbing from the last control inside the box moves into the page behind it. The Escape key does nothing. The content underneath stays reachable to a screen reader. The native <dialog> element with showModal() handles all of that, at the cost of one line of JavaScript, and it is the right choice whenever the dialog carries a real task rather than a decorative message.
CSS properties used
:target- Matches the element whose
idequals the current URL fragment. The entire open state of the dialog is this one pseudo-class. displaynonetoflexis what shows the overlay.flexalso centers the box, since the overlay is a full size flex container.positionfixedwithinset: 0covers the viewport regardless of scroll position.absoluteconfines the overlay to its nearest positioned ancestor, which is how the demo stays inside its own frame.inset- Shorthand for
top,right,bottomandleftset to the same value.inset: 0stretches the overlay to fill its containing block. z-index- Lifts the overlay above the page. It only has an effect on a positioned element, which the overlay already is.
scroll-margin-top- Offsets where a fragment navigation scrolls to, so an overlay in normal flow does not land underneath a sticky header.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
:target | 4 | 3.5 | 3.2 | 12 |
position: fixed | 4 | 2 | 3.1 | 12 |
flexbox | 21 | 28 | 6.1 | 12 |
animation | 4 | 5 | 5.1 | 12 |
<dialog> | 37 | 98 | 15.4 | 79 |
:target is tracked on Can I Use as part of CSS3 selectors, which is where the figures above come from, and it has been available for longer than most of the properties it is used with. The <dialog> row is there for comparison rather than as a dependency: nothing on this page needs it, but its support is now broad enough that a dialog carrying a real task should probably use it instead. inset is newer than the four longhand properties it replaces and Can I Use has no separate entry for it, so write top: 0; right: 0; bottom: 0; left: 0 if you need to reach older browsers.
Accessibility notes
A :target overlay is not a dialog as far as assistive technology is concerned. It is a <div> that became visible. Adding role="dialog" and aria-modal="true" describes the intent, but aria-modal only tells a screen reader to ignore the rest of the page, and it does nothing about keyboard focus, which can still tab straight out of the box and into the links behind it.
Escape does not close it, because no key handler exists. Readers who expect that will try it, find nothing happens, and go looking for the close control, so make that control large and put it first inside the box. Navigating to a fragment does set the sequential focus navigation starting point at the targeted element in modern browsers, so the next Tab press generally lands inside the overlay. That is helpful, and it is not the same thing as moving focus. Nothing announces that a dialog opened.
When any of the above matters, and for a confirmation or a form it usually does, use <dialog> with showModal(). It traps focus, closes on Escape, makes the rest of the page inert, and announces itself correctly. The pure CSS version is a good fit for a decorative panel, an image overlay, or a message with no action attached.
What you can build with it
- Image and media overlays. The same mechanism drives the CSS lightbox, where each thumbnail links to the
idof its own full size overlay. - Deep linkable panels. A pricing page where
/pricing#enterpriseopens the enterprise details on load, so the link can be shared and it lands somewhere useful. - Cookie and privacy notices. A short static message with one dismiss control, where nothing is lost if the reader reloads and sees it again.
- Help and glossary popups. A term links to a fragment carrying its definition, and the back button returns the reader to exactly where they were reading.
- Static site interstitials. A message on a page with no build step and no bundler, where adding a script for one overlay is more machinery than the job needs.
Mistakes worth avoiding
- Giving the close link
href="#". The fragment clears and the browser scrolls to the top of the page, so a reader who was halfway down loses their place. - Leaving the overlay in normal document flow. The fragment navigation scrolls to it, and without
scroll-margin-topit lands under any sticky header on the page. - Setting
display: noneand expecting a fade.displaydoes not interpolate, so a transition on it is ignored. Run an@keyframesanimation on the:targetrule instead, which starts the moment the element is displayed. - Reusing an
idthat something else on the page already links to. The fragment is a single global value, so two features fighting over it produce a modal that opens at the wrong moment. - Treating it as a substitute for
<dialog>in a form or a confirmation flow. There is no focus trap and no Escape key, so a keyboard user can end up interacting with the page behind a modal that looks blocking.
Frequently asked questions
How do you make a modal with CSS only?
id, hide it with display: none, and show it with .overlay:target { display: flex }. The open control is a link to #that-id and the close control is a link to a fragment that matches nothing. The URL fragment is the entire state.Does a CSS modal work on mobile?
position: fixed with inset: 0 so it covers the viewport rather than the document.How do you close a CSS modal by clicking outside it?
z-index above the backdrop so clicks inside it are not intercepted.Can you animate a :target modal open?
display changes from none to flex in one step and cannot be interpolated, but an @keyframes animation declared on the :target rule begins as soon as the element is rendered, so fading and scaling the inner box works.Should I use :target or the dialog element?
<dialog> with showModal() for anything a person has to act on, because it traps focus, closes on Escape, and makes the rest of the page inert. :target for a decorative overlay, an image viewer, or a panel worth deep linking to, where its history behavior is a feature rather than a problem.