Somewhere in production, a modal is currently blocking a checkout button, trapping a keyboard user, or turning the back button into a roulette wheel. And the worst part: it “worked on my machine” because your machine didn’t have the same font scaling, zoom level, iOS quirks, hash routing, or an extra stacking context created by a harmless-looking transform.
CSS-only modals are absolutely viable in specific situations: marketing pages, docs sites, low-interactivity UIs, or “I need a dialog but legal won’t let us ship JS on this path” weirdness. But if you treat them like a free lunch, they’ll invoice you later—usually in accessibility debt and navigation edge cases.
When to use CSS-only modals (and when to stop being brave)
CSS-only modals are best when the modal content is shallow, the state model is simple, and the page doesn’t have a client-side router fighting you. Think “cookie policy details,” “image lightbox,” “newsletter signup,” “terms snippet,” “table row details” on a mostly static page.
They are a bad fit when any of the following are true:
- You need real focus management (trap focus, restore focus) and you care about keyboard users beyond a checkbox on a compliance spreadsheet.
- You have nested modals, multistep flows, or multiple overlays.
- You need to close on Escape reliably.
- You’re inside a SPA router that owns the hash, history state, and scroll restoration.
- You need to prevent background interaction in a robust way, including screen readers.
If you’re building an application modal that changes user data, I’m going to be opinionated: ship a JS modal with proper dialog semantics and focus management. The CSS-only patterns below are for when constraints are real, not when you’re trying to win a purity contest.
One quote that holds up in operations and UI reliability: “paraphrased idea” from John Allspaw: make the system easy to do the right thing and hard to do the wrong thing. Your modal should be hard to misuse—especially by users who didn’t sign up for your cleverness.
Facts and context: why these patterns exist
- The
:targetselector dates back to early CSS2-era URL fragment behavior: it was designed for in-page navigation, not UI state machines. We repurposed it because it was already everywhere. - CSS-only overlays became popular during the “no-JS” and “progressive enhancement” waves when bandwidth and script blockers were more common. The patterns stuck.
- The HTML
<dialog>element is relatively recent in mainstream usage and wasn’t consistently supported for years, so teams built their own modals (in JS or CSS). backdrop-filter(blurred backgrounds) arrived late and remains performance-sensitive, especially on mobile GPUs and on complex pages. It’s not “free pretty.”- Stacking context bugs exploded when
position: sticky,transform, andfilterbecame common. Any of these can create new stacking contexts that invalidate your “just set z-index: 9999” strategy. - Hash fragments affect browser history. Each open/close via
:targetcan create a history entry, which means the back button becomes part of your UI whether you like it or not. - Early lightbox scripts inspired CSS clones: the visual language (dimmed backdrop + centered box) became “standard” before accessibility practices caught up.
- Mobile Safari has been a recurring source of overlay pain due to viewport resizing, address bar behavior, and scroll chaining. If your modal “only fails on iPhones,” that’s not a coincidence; it’s tradition.
Pattern 1: the :target modal (hash-driven)
How it works
The :target selector applies styles to an element whose id matches the URL fragment. Clicking a link to #modal “activates” the modal by changing the fragment; clicking a link to # or another fragment “deactivates” it.
That sounds too simple because it is. But it’s also robust in one important way: it doesn’t depend on hidden inputs and label associations. It relies on navigation, which browsers are good at.
Minimal, serviceable implementation
cr0x@server:~$ cat modal-target.html
<!-- Trigger -->
<a href="#modal-about" class="btn">About pricing</a>
<!-- Modal container -->
<div id="modal-about" class="modal" aria-hidden="true">
<a href="#close" class="modal__backdrop" aria-label="Close"></a>
<div class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="modal-about-title">
<header class="modal__header">
<h2 id="modal-about-title">Pricing details</h2>
<a class="modal__close" href="#close" aria-label="Close dialog">×</a>
</header>
<div class="modal__body">
<p>Short copy. No form submission. No wizard.</p>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
inset: 0;
display: none;
z-index: 1000;
}
.modal:target {
display: block;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
}
.modal__dialog {
position: relative;
max-width: min(680px, calc(100vw - 2rem));
margin: 10vh auto;
background: white;
color: black;
border-radius: 12px;
padding: 1rem 1.25rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.modal__close {
float: right;
text-decoration: none;
font-size: 1.5rem;
line-height: 1;
}
</style>
What’s good about :target
- No hidden inputs. Less DOM trickery.
- Linkable state. You can share a URL that opens the modal. This is sometimes a feature, sometimes a lawsuit.
- Works without scripting. Obvious, but still useful on high-restriction pages.
What will bite you
- History pollution. Every open/close can create history entries. Users hit Back and the modal reopens; they hit Back again and the page scrolls; now they hate you.
- Router hash collisions. If your app uses hash routing,
#modal-aboutmight be a route, not a fragment state. Enjoy your incident. - Scroll-to-target behavior. Some browsers may scroll to the targeted element. With
position: fixedandinset: 0, it usually won’t move visibly, but don’t bet your uptime on “usually.”
Design choice: use :target for content-only modals on multi-page sites. Avoid it inside SPAs unless you own the router and explicitly integrate.
Pattern 2: the checkbox (:checked) modal
How it works
You create a hidden checkbox. A label toggles it on; another label (or backdrop label) toggles it off. CSS watches input:checked and reveals the modal. It’s a state machine disguised as a form control.
Implementation with fewer footguns
cr0x@server:~$ cat modal-checkbox.html
<input id="m1" class="modal-toggle" type="checkbox" />
<label for="m1" class="btn">Open details</label>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="m1-title">
<label class="modal__backdrop" for="m1" aria-label="Close"></label>
<div class="modal__dialog">
<header class="modal__header">
<h2 id="m1-title">Details</h2>
<label class="modal__close" for="m1" aria-label="Close dialog">×</label>
</header>
<div class="modal__body">
<p>Checkbox pattern: no hash, no history entries.</p>
</div>
</div>
</div>
<style>
.modal-toggle {
position: absolute;
left: -9999px;
}
.modal {
position: fixed;
inset: 0;
display: none;
z-index: 1000;
}
.modal-toggle:checked ~ .modal {
display: block;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
cursor: pointer;
}
.modal__dialog {
position: relative;
max-width: min(680px, calc(100vw - 2rem));
margin: 10vh auto;
background: white;
border-radius: 12px;
padding: 1rem 1.25rem;
}
.modal__close {
cursor: pointer;
float: right;
font-size: 1.5rem;
line-height: 1;
}
</style>
What’s good about it
- No hash changes. Your back button remains a back button.
- Composes with routers. It’s just DOM state.
- Multiple modals possible if you keep IDs unique and structure clean.
What’s bad (and it is bad)
- It’s a semantic lie. A checkbox is not a dialog. Screen readers may announce it oddly; you’re patching semantics with ARIA.
- DOM structure constraints. The general sibling selector (
~) means your modal must follow the checkbox in DOM order. Congratulations, your markup architecture now depends on a CSS trick. - Focus is still unmanaged. Without JS, you can’t reliably trap focus inside or restore focus on close.
Opinionated call: if you must do CSS-only in an environment where hashes are toxic (SPA routing), the checkbox technique is usually the lesser evil. But document the structural constraint like it’s an API.
Backdrop styling that behaves
The backdrop is not decoration; it’s a control surface
A modal backdrop has three jobs:
- Signal modality. “You’re in a dialog now.”
- Prevent interaction. Block clicks to underlying UI.
- Offer an escape hatch. Click outside to close (when appropriate).
In CSS-only modals, the backdrop is your primary interaction boundary. If it’s too small, wrongly positioned, or not actually intercepting pointer events, users will click through and trigger the page behind. That’s not a “minor bug.” That’s a broken control plane.
Backdrop patterns that don’t click-through
- Use a full-screen positioned element:
position: absolute; inset: 0;inside a fixed modal container. - Ensure it sits below the dialog but above the page: backdrop and dialog should be in the same stacking context; don’t fight global z-index wars.
- Prefer a real element, not a pseudo-element when you need it clickable (close-on-click). Pseudo-elements can be clickable, but it’s far easier to reason about an actual element.
Blurred backgrounds: backdrop-filter is expensive
If you apply backdrop-filter: blur(10px), you are asking the browser to sample and blur everything behind the overlay. On a complex page, that can tank frame rates. Test it on a low-end phone, not your 32-core laptop.
cr0x@server:~$ cat backdrop.css
.modal__backdrop {
background: rgba(0,0,0,0.45);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
Use blur sparingly. If performance is a concern, a plain semi-transparent backdrop is the boring, correct answer. Boring is good. Boring ships.
Close affordances: make it obvious, make it safe
Close must be redundant
Users should be able to close a modal via:
- A visible close button in the top-right (or top-left in some RTL contexts).
- Clicking the backdrop (for non-destructive, non-critical dialogs).
- Keyboard navigation to a close control (tab to it, activate it).
In CSS-only patterns, you do not reliably get Escape-to-close. Don’t pretend you do. If the modal is critical enough that users will instinctively press Escape, it is critical enough to justify JS.
Close button design notes
- Hit area matters. Make it at least 40×40 CSS pixels. “×” at 12px is a dare, not a control.
- Use an explicit label.
aria-label="Close dialog"is not optional. - Place it early in the DOM inside the dialog so keyboard users reach it quickly.
Joke #1: The only thing more persistent than a modal that won’t close is the person who insists it’s “a quick CSS tweak.”
Backdrop close: when to disable it
Backdrop click-to-close is nice for lightboxes and info panels. It’s risky for forms, confirmations, or anything where a misclick can drop user input. For those, keep the backdrop inert (no close on click) and make close explicit.
In CSS-only land, disabling backdrop close usually means: backdrop is a <div> instead of a link/label; it blocks clicks but doesn’t toggle state.
Accessibility reality check (no hand-waving)
You can’t fully implement an accessible modal in pure CSS
Let’s be direct. A real modal needs:
- Focus moved into the dialog on open.
- Focus trapped within the dialog while open.
- Focus restored to the opener on close.
- Screen readers prevented from navigating underlying content.
- Escape closes the dialog (expected behavior).
CSS alone can’t manage focus state transitions. You can approximate with autofocus in limited contexts, but you cannot reliably trap focus without JS. So: if your modal is a core UI element, stop trying to do it CSS-only.
What you can still do (and should)
- Use semantic structure:
role="dialog",aria-modal="true", andaria-labelledby. - Hide the modal when closed:
display: noneremoves it from the accessibility tree. Good. - Ensure close controls are keyboard-activatable: links (
<a>) or buttons (<button>). Labels can be activated but are less obvious for assistive tech. - Don’t use
aria-hidden="true"as a toggle in CSS-only patterns. You can’t flip it without JS; leaving it lying is worse than omitting it.
Better alternative if you can use minimal JS: <dialog>
If JS is allowed at all, use <dialog> and call showModal(). It gives you a real backdrop and better semantics. You’ll still need focus and close policies, but you’re starting from a browser primitive, not a party trick.
Z-index and stacking contexts: the silent modal killer
Why “z-index: 9999” doesn’t work
Z-index only compares elements within the same stacking context. And stacking contexts are created by things you add for unrelated reasons:
transform(eventranslateZ(0))filter,backdrop-filteropacity < 1position+z-indexin certain combinationsisolation: isolatecontain: paintand friends
So your modal can have z-index 1000000 and still render under a sticky header that lives in a different stacking context with a lower local z-index. This is the part where people start adding random z-index values until the CSS looks like an overclocked BIOS.
How to avoid the fight
- Mount the modal near the end of
<body>. Keep it out of component nesting that applies transforms. - Keep the modal container itself as the stacking root:
position: fixedplus a clearz-index. - Avoid putting
transformonbodyor large layout wrappers if you also rely on fixed overlays. That’s a known footgun.
Scroll locking and mobile viewport traps
CSS-only scroll lock is partial at best
In a JS modal, you’d typically set body { overflow: hidden; } while open. CSS-only patterns can’t toggle that globally without advanced selectors and careful structure.
You can sometimes use :has() (where supported) to lock scroll when a modal is targeted/checked. But relying on :has() for core behavior still has compatibility risk in some enterprise environments.
cr0x@server:~$ cat scroll-lock.css
/* Only if you can rely on :has() support */
html:has(.modal:target),
html:has(.modal-toggle:checked) {
overflow: hidden;
}
Without :has(), the practical compromise is:
- Make the modal container
position: fixed; inset: 0; - Make the dialog body scrollable with
max-heightandoverflow: auto - Accept that the page behind may still scroll in some circumstances (especially iOS rubber-banding)
Prevent scroll chaining and overscroll glow
When the dialog body hits scroll boundaries, browsers may “chain” scroll to the page behind. You can reduce this with:
cr0x@server:~$ cat overscroll.css
.modal__dialog {
max-height: 80vh;
overflow: auto;
overscroll-behavior: contain;
}
It won’t solve everything on every mobile browser, but it reduces the worst “scroll the page behind the modal” sensation.
Fast diagnosis playbook
You’re on call for a UI incident. A modal is stuck open, won’t open, or blocks clicks. Here’s what you check first, second, third—because wandering around DevTools is not a strategy.
1) First check: is the modal actually being activated?
- For
:target: does the URL fragment match the modalid? If not, the selector will never apply. - For checkbox: is the checkbox
checkedin the DOM? If not, the CSS can’t reveal anything.
2) Second check: is it visible but behind something?
- Inspect the modal container’s computed
z-indexand whether it’s in the expected stacking context. - Look for parent transforms/filters that create stacking contexts.
3) Third check: is it visible but non-interactive?
- Check if the backdrop is intercepting clicks or letting them pass through (pointer-events, sizing, positioning).
- Check if the dialog is off-screen due to margins and viewport changes (mobile address bar, zoom).
4) Fourth check: navigation and history behavior
- If users report “Back button reopens modal,” you’re dealing with
:targethistory entries. - If the SPA route changes unexpectedly when opening, the hash is owned by the router.
Common mistakes: symptoms → root cause → fix
Clicks go through the backdrop and trigger buttons behind
Symptoms: user clicks outside the dialog; underlying page elements activate. Sometimes the modal also closes, sometimes not.
Root cause: backdrop isn’t covering the full viewport (inset missing), or backdrop has pointer-events: none, or it’s behind content due to stacking context.
Fix: ensure backdrop is positioned and sized correctly, and ensure modal container creates a predictable stacking context.
Modal opens but is partially off-screen on mobile
Symptoms: top of dialog hidden under address bar; close button unreachable; content scrolls weirdly.
Root cause: margin: 10vh auto plus dynamic viewport units behaving differently; also common when font size is bumped up.
Fix: use max-height and internal scrolling; consider margin: 2rem auto and align-items centering with flex.
Modal refuses to appear in production but works locally
Symptoms: “Open” link changes URL or checkbox toggles, but nothing appears.
Root cause: CSS bundling changed selector specificity/order; a later rule overrides display: block; or the modal container is missing due to templating differences.
Fix: verify computed styles in production build; reduce reliance on specificity games; place modal styles near component scope with explicit selectors.
Back button behavior feels broken
Symptoms: Back closes modal, then Back reopens it; or Back jumps to strange scroll positions.
Root cause: :target adds history entries and triggers scroll-to-fragment semantics.
Fix: use checkbox pattern or JS-managed history; if you must use :target, accept that Back is part of the UX and design for it.
Sticky header overlaps the modal
Symptoms: header is visible above modal/backdrop; users can still click header items.
Root cause: header is in a higher stacking context; modal is trapped in a lower one due to a transformed ancestor.
Fix: move modal to end of body; remove transforms from ancestors; explicitly create stacking context on modal root.
Keyboard users get lost or stuck
Symptoms: tabbing reaches page behind modal; focus starts behind modal; closing drops focus at page start.
Root cause: no focus management (CSS-only limitation).
Fix: if accessibility matters (it does), use JS or <dialog>. If forced into CSS-only, keep content minimal and ensure close control is early and visible.
Practical tasks with commands, outputs, and decisions
These are the kinds of “do this now” checks I run when a CSS-only modal misbehaves across environments. They’re intentionally concrete. Each task includes a command, sample output, what it means, and the decision you make.
Task 1: Confirm :target is actually matching
cr0x@server:~$ python3 - <<'PY'
from urllib.parse import urlparse
u="https://example.test/page.html#modal-about"
p=urlparse(u)
print("fragment:", p.fragment)
PY
fragment: modal-about
What it means: the browser will target id="modal-about".
Decision: if fragment doesn’t match the modal id exactly (case-sensitive), stop debugging CSS and fix markup/links.
Task 2: Verify the modal id exists in the built HTML
cr0x@server:~$ grep -R --line-number 'id="modal-about"' dist/
dist/page.html:214:<div id="modal-about" class="modal">
What it means: the element exists in production output.
Decision: if grep finds nothing, your build pipeline removed or renamed it (templating, CMS blocks, partials). Fix the build, not the CSS.
Task 3: Check that modal CSS survived minification/bundling
cr0x@server:~$ grep -R --line-number '\.modal:target' dist/assets/
dist/assets/app.min.css:1:.modal:target{display:block}
What it means: the critical selector exists.
Decision: if missing, your CSS tree-shaker purged it (common with utility-first setups). Add safelist rules or restructure CSS.
Task 4: Detect purge/tooling removing “unused” selectors
cr0x@server:~$ rg "content:" tailwind.config.js
18: content: ["./src/**/*.html","./src/**/*.js"],
What it means: purge scans only those files.
Decision: if your modal HTML is injected from a CMS or markdown not covered, purge may remove modal styles. Add paths or safelist modal selectors.
Task 5: Confirm checkbox and modal DOM ordering for ~ selector
cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("modal-checkbox.html").read()
s=BeautifulSoup(html,"html.parser")
toggle=s.select_one("input.modal-toggle")
modal=s.select_one("div.modal")
print("toggle before modal:", toggle.sourceline < modal.sourceline)
PY
toggle before modal: True
What it means: the general sibling selector can apply.
Decision: if toggle comes after the modal, the CSS won’t ever match. Move the toggle earlier or switch to a different pattern.
Task 6: Spot stacking context triggers in CSS
cr0x@server:~$ rg -n "transform:|filter:|backdrop-filter:|opacity:" src/styles/
src/styles/layout.css:44:transform: translateZ(0);
src/styles/header.css:12:position: sticky;
src/styles/header.css:13:z-index: 2000;
What it means: transforms and filters are present; sticky headers likely create stacking layers.
Decision: if the modal is nested under a transformed container, move modal mount point or remove the transform hack.
Task 7: Validate that backdrop covers the viewport
cr0x@server:~$ node - <<'NODE'
const css = `
.modal__backdrop { position:absolute; inset:0; }
`;
console.log(css.includes("inset:0") ? "OK: inset present" : "BAD: missing inset");
NODE
OK: inset present
What it means: the simplest and most robust sizing is used.
Decision: if inset is missing, add it; don’t try to do “width:100%; height:100%” in nested contexts and then wonder why it fails.
Task 8: Check for accidental pointer-events: none on overlay/backdrop
cr0x@server:~$ rg -n "pointer-events:\s*none" src/styles/
src/styles/utilities.css:88:.no-pointer{pointer-events:none}
What it means: there’s a utility that could be applied accidentally.
Decision: if your backdrop inherits a “no-pointer” class via component composition, fix class composition; don’t patch by adding more z-index.
Task 9: Reproduce the history issue with :target
cr0x@server:~$ cat <<'TXT'
Repro steps:
1) Load page.html
2) Click "About pricing" (URL becomes #modal-about)
3) Click close (URL becomes #close)
4) Press Back
Expected: return to previous page state
Actual: modal reopens (#modal-about)
TXT
Repro steps:
1) Load page.html
2) Click "About pricing" (URL becomes #modal-about)
3) Click close (URL becomes #close)
4) Press Back
Expected: return to previous page state
Actual: modal reopens (#modal-about)
What it means: this is not a bug; it’s the design of fragment navigation.
Decision: if that UX is unacceptable, stop using :target here.
Task 10: Confirm that production CSP isn’t blocking inline styles (if you used them)
cr0x@server:~$ curl -I https://example.test/page.html | sed -n '1,20p'
HTTP/2 200
content-type: text/html; charset=utf-8
content-security-policy: default-src 'self'; style-src 'self'
What it means: inline <style> blocks may be blocked if 'unsafe-inline' isn’t allowed.
Decision: move modal CSS into a served stylesheet or update CSP. Don’t ship “works in dev” inline CSS into a strict CSP environment.
Task 11: Detect layout shifts caused by font scaling
cr0x@server:~$ python3 - <<'PY'
base=16
scaled=20
close_btn=24
print("close button px at base:", close_btn)
print("close button px relative to font scaling:", close_btn*(scaled/base))
PY
close button px at base: 24
close button px relative to font scaling: 30.0
What it means: font scaling changes hit targets and layout. If you sized with vh margins, the dialog can drift.
Decision: size dialog with max-width/max-height and internal scroll, not fragile viewport margins.
Task 12: Check for duplicate IDs (a silent :target killer)
cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("dist/page.html").read()
s=BeautifulSoup(html,"html.parser")
ids={}
dups=[]
for el in s.select("[id]"):
i=el["id"]
ids[i]=ids.get(i,0)+1
for i,c in ids.items():
if c>1:
dups.append((i,c))
print("duplicates:", dups[:10])
PY
duplicates: []
What it means: no duplicate IDs detected.
Decision: if duplicates exist, :target may hit the wrong element or behave inconsistently. Fix IDs first; don’t touch CSS until IDs are unique.
Task 13: Ensure the modal is mounted at the end of body (to avoid transformed parents)
cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html=open("dist/page.html").read()
s=BeautifulSoup(html,"html.parser")
body=s.body
last_tags=[t.name for t in body.find_all(recursive=False)][-5:]
print("last top-level body children:", last_tags)
PY
last top-level body children: ['footer', 'div', 'script', 'script', 'script']
What it means: there is a div near the end of body that could be your modal root.
Decision: mount modals as body-level siblings, not deep inside transformed layout wrappers.
Task 14: Measure CSS file size impact of fancy backdrops
cr0x@server:~$ ls -lh dist/assets/app.min.css
-rw-r--r-- 1 www-data www-data 182K Dec 12 09:14 dist/assets/app.min.css
What it means: stylesheet size is moderate; but this doesn’t measure runtime paint cost.
Decision: if you’re adding multiple blur variants and animations, consider a single backdrop style. Don’t pay performance tax for decoration on critical flows.
Task 15: Sanity check modal open/close selector specificity
cr0x@server:~$ rg -n "\.modal\s*\{|\:target|\:checked" dist/assets/app.min.css | sed -n '1,20p'
1:.modal{position:fixed;inset:0;display:none;z-index:1000}
1:.modal:target{display:block}
1:.modal-toggle:checked~.modal{display:block}
What it means: open rules are present and straightforward.
Decision: if you also see later rules like .modal{display:none!important}, remove them or scope them. Don’t fight with !important unless you enjoy debugging in the dark.
Three corporate mini-stories from the modal mines
Incident: the wrong assumption about the hash
A team shipped a CSS-only :target modal for “quick product details” on a storefront. No JS allowed on that path due to a performance initiative and a security review backlog. The modal looked clean and it passed basic QA.
Two weeks later, support tickets started piling up: users were ending up on weird “blank states” and the back button felt broken. On mobile, it was worse. The incident wasn’t “site down,” but it was a conversion bleed, which in corporate terms is the kind of outage that gets noticed by people who don’t know what CSS is.
The wrong assumption: they believed hash fragments were “local UI state” that didn’t touch navigation. In reality, each open and close mutated the URL fragment, creating history entries. Users who opened details, closed them, then pressed Back didn’t go back to the category page. They reopened the modal. Press Back again, and sometimes the browser scrolled to an anchor near the top where the modal container lived in the DOM. Now the user is lost and annoyed.
The fix was not clever. They replaced :target with the checkbox pattern for that page, and they removed “#close” links entirely. It eliminated history churn. They also updated the design so the modal content was shorter, reducing the urge to navigate back and forth.
Postmortem takeaway: if your UI state changes the URL, it is navigation. Treat it like navigation. Review it like navigation.
Optimization that backfired: the blur that melted phones
A different org wanted the “premium feel” of a frosted glass modal. Design delivered a spec with a strong blur effect on the backdrop and a subtle animation on open. It looked fantastic in the desktop browser used for approvals. It also looked fantastic in the slide deck where the decision was made.
The team implemented backdrop-filter: blur(14px) with an opacity overlay and a transition. In early testing, everything was fine. Then it hit a real page: lots of images, a sticky header, and a carousel doing its own thing. On mid-range Android devices and older iPhones, opening the modal caused jank. Sometimes the page would freeze for a moment. Sometimes taps didn’t register. Support called it “intermittent unresponsiveness,” which is the most annoying class of bug because you can’t reproduce it on demand.
The “optimization” was to push more work to the GPU: adding transforms to promote layers, forcing compositing. That created extra stacking contexts and made the modal occasionally render under the sticky header. Now the close button was partially covered in some layouts. Great.
They backed out the blur and used a plain rgba overlay. They kept a very subtle fade-in transition on opacity only. Performance stabilized. The premium feel was replaced by “it works,” which is the most premium feature a modal can have.
Joke #2: Every time you add a blur filter to a modal, a mobile GPU files a formal complaint.
Boring but correct practice that saved the day: one modal root, one contract
A third team ran a content-heavy site with dozens of components contributed by multiple squads. They had been burned by z-index wars and stacking contexts before, so they established a boring rule: any overlay must mount into a single top-level “overlay root” located at the end of <body>.
It sounded bureaucratic. People grumbled because it meant you couldn’t just drop a modal component anywhere and call it done. But it came with a payoff: predictable stacking, predictable positioning, and fewer “why is this under the header” surprises.
When they needed a CSS-only modal for a docs microsite (no JS allowed due to platform constraints), they used the same overlay root. It avoided transformed ancestors because the overlay root was outside the layout wrappers. Their modal backdrop always covered the viewport. Their z-index was stable because it was defined once.
Later, when they introduced a sticky promo banner with a transform-based animation (a classic stacking context generator), it didn’t break the modal. The overlay root was still above it. No emergency patch, no Friday deploy, no blame ping-pong.
The practice wasn’t clever. It was a contract. Contracts prevent incidents.
Checklists / step-by-step plan
Checklist: picking the right CSS-only pattern
- If hash routing exists (SPA, analytics, in-page anchors heavily used): prefer checkbox (
:checked). - If the modal should be deep-linkable and history behavior is acceptable:
:targetis fine. - If the modal contains a form or critical user action: do not use CSS-only. Use JS or
<dialog>. - If you need Escape-to-close: do not use CSS-only.
Checklist: a CSS-only modal that won’t embarrass you
- Modal root uses
position: fixed; inset: 0;. - Backdrop is a full-viewport element and intercepts pointer events.
- Dialog uses
max-widthandmax-height; content scrolls internally. - Close button has a large hit target and an ARIA label.
- Backdrop click-to-close is enabled only for non-destructive content.
- Modal is mounted at end of body or otherwise outside transformed ancestors.
- No reliance on
z-indexarms race. One stacking context, one number. - Test at 200% zoom and with increased font size.
Step-by-step: building a :target modal with sane navigation
- Create a trigger link pointing to
#modal-id. - Create a modal container
<div id="modal-id" class="modal">near end of body. - Add a backdrop link pointing to a neutral fragment (commonly
#close), plus a close link inside the dialog. - Style
.modalas hidden by default; reveal with.modal:target. - Decide explicitly whether history behavior is acceptable. If not, stop and pick checkbox or JS.
Step-by-step: building a checkbox modal that survives refactors
- Place
<input type="checkbox" class="modal-toggle">directly before the modal in DOM. - Open control is
<label for="... ">; close control is another label. - Use
.modal-toggle:checked ~ .modalto show it. - Document the DOM ordering requirement in the component README and code comments.
- Add a unit test or static check that fails if the structure changes (yes, even for CSS-only components).
FAQ
Can a CSS-only modal be fully accessible?
No. Without JS you can’t reliably trap focus, restore focus, or implement Escape-to-close. You can still make it less harmful with proper roles, labels, and visible controls.
Should I use :target or :checked?
If you want deep links and can tolerate history effects, :target. If you’re in a router-heavy app or care about back button behavior, :checked.
Why does my modal appear behind a sticky header even with a huge z-index?
Because z-index doesn’t cross stacking context boundaries. A parent transform or filter can trap your modal in a lower stacking context.
How do I prevent background scrolling without JS?
You can sometimes use :has() to toggle overflow: hidden on html. Without :has(), your best option is making the dialog scroll internally and reducing scroll chaining.
Is click-outside-to-close always a good idea?
No. It’s fine for informational modals and images. It’s risky for forms or confirmations because a misclick can discard user work.
Why does opening a :target modal sometimes scroll the page?
Fragment navigation is allowed to scroll to the target element. Fixed positioning often masks it, but DOM placement and browser behavior can still cause jumps.
Can I stack multiple CSS-only modals?
You can, but it’s fragile. :target only targets one fragment at a time. Checkbox modals can stack, but focus and z-index management becomes messy fast.
What about using <dialog> instead?
If JS is allowed at all, <dialog> is a better primitive than CSS hacks. It still requires careful UX decisions, but you start closer to correct semantics.
Do I need ARIA attributes if it’s “just CSS”?
If you’re presenting something as a dialog, yes: role="dialog", aria-modal="true", and aria-labelledby are baseline. They don’t solve focus, but they reduce confusion.
Next steps you can actually do
- Decide if you’re building a real application modal or a content overlay. If it’s real application UI, stop and budget minimal JS.
- Pick one CSS-only pattern and standardize it. Mixed patterns across a site multiply failure modes.
- Establish an overlay root near the end of body. It’s boring. It prevents z-index melodrama.
- Write down the constraints. If you use the checkbox pattern, document the DOM ordering requirement as a contract.
- Test like a pessimist: 200% zoom, large fonts, mobile Safari, and a page with sticky headers and transforms. If it survives that, it’ll survive your users.
If you remember one thing: CSS-only modals are acceptable when they’re small, predictable, and honest about their limitations. The moment you need keyboard-first correctness, reach for the right tool. Your future incident channel will thank you.