CSS Lightbox

Full-size image overlay using the :target pseudo-class, with no JavaScript.

Published June 13, 2026 Intermediate 8 min read

A CSS lightbox opens a full size image over the page using nothing but the URL fragment. Each thumbnail is a link to the id of its own overlay, and the overlay is hidden until :target matches it. Because the state lives in the address bar, a lightbox is bookmarkable and shareable: send someone /gallery#photo-4 and the fourth image is already open when the page loads.

The same mechanism drives the CSS modal dialog, and it carries the same trade off. Every open and close is a history entry, so the back button steps back through the images rather than leaving the gallery. In a photo viewer that is usually what a reader wants. The variants below add previous and next links inside the overlay, and lock the page behind it so scrolling the lightbox does not scroll the gallery underneath.

Click a thumbnail

HTML

<!-- Each thumbnail links to the id of its overlay -->
<div class="lb-gallery">
  <a href="#lb-img1" class="lb-thumb"><div class="lb-thumb-img lb-img-1"></div></a>
  <a href="#lb-img2" class="lb-thumb"><div class="lb-thumb-img lb-img-2"></div></a>
  <a href="#lb-img3" class="lb-thumb"><div class="lb-thumb-img lb-img-3"></div></a>
  <a href="#lb-img4" class="lb-thumb"><div class="lb-thumb-img lb-img-4"></div></a>
  <a href="#lb-img5" class="lb-thumb"><div class="lb-thumb-img lb-img-5"></div></a>
  <a href="#lb-img6" class="lb-thumb"><div class="lb-thumb-img lb-img-6"></div></a>
</div>

<!-- One overlay per image. Swap the divs for <img> in a real gallery -->
<div id="lb-img1" class="lb-overlay">
  <a href="#" class="lb-close"></a>
  <div class="lb-overlay-img lb-img-1" style="width:400px;height:280px"></div>
</div>
<div id="lb-img2" class="lb-overlay">
  <a href="#" class="lb-close"></a>
  <div class="lb-overlay-img lb-img-2" style="width:400px;height:280px"></div>
</div>
<div id="lb-img3" class="lb-overlay">
  <a href="#" class="lb-close"></a>
  <div class="lb-overlay-img lb-img-3" style="width:400px;height:280px"></div>
</div>
<div id="lb-img4" class="lb-overlay">
  <a href="#" class="lb-close"></a>
  <div class="lb-overlay-img lb-img-4" style="width:400px;height:280px"></div>
</div>
<div id="lb-img5" class="lb-overlay">
  <a href="#" class="lb-close"></a>
  <div class="lb-overlay-img lb-img-5" style="width:400px;height:280px"></div>
</div>
<div id="lb-img6" class="lb-overlay">
  <a href="#" class="lb-close"></a>
  <div class="lb-overlay-img lb-img-6" style="width:400px;height:280px"></div>
</div>

CSS

.lb-gallery {
  width: 100%;
  max-width: 480px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: .75rem;
}

/* aspect-ratio gives each cell a size of its own */
.lb-thumb {
  display: block;
  position: relative;
  aspect-ratio: 1;
  border-radius: 8px;
  overflow: hidden;
  border: 2px solid transparent;
  cursor: zoom-in;
  transition: border-color .2s, transform .2s;
}

.lb-thumb:hover {
  border-color: #b8ff57;
  transform: scale(1.02);
}

.lb-thumb-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background-size: cover;
  background-position: center;
}

.lb-overlay {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 999;
  padding: 2rem;
  background: rgba(0, 0, 0, .85);
  align-items: center;
  justify-content: center;
}

/* :target matches the element whose id is in the URL hash */
.lb-overlay:target {
  display: flex;
}

.lb-overlay-img {
  max-width: 90vw;
  max-height: 80vh;
  border-radius: 12px;
  object-fit: contain;
  background-size: cover;
  background-position: center;
}

.lb-close {
  position: fixed;
  top: 1.5rem;
  right: 1.5rem;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #141415;
  border: 1px solid #2a2a2d;
  border-radius: 50%;
  color: #f0f0f0;
  text-decoration: none;
  font-size: 1.2rem;
  line-height: 1;
  transition: background .15s;
}

.lb-close:hover {
  background: #1c1c1e;
}

/* Colored placeholders standing in for photos */
.lb-img-1 { background: linear-gradient(135deg, #c084fc, #38bdf8); }
.lb-img-2 { background: linear-gradient(135deg, #ff6b6b, #ff9040); }
.lb-img-3 { background: linear-gradient(135deg, #b8ff57, #57d9a3); }
.lb-img-4 { background: linear-gradient(135deg, #f472b6, #c084fc); }
.lb-img-5 { background: linear-gradient(135deg, #ff9040, #ffd444); }
.lb-img-6 { background: linear-gradient(135deg, #38bdf8, #57d9a3); }

Other ways to build it

Previous and next inside the overlay

Because only one fragment can match at a time, moving between images is a single navigation rather than two. The overlay for the second photograph holds a link to the first image's id and another to the third, and following either one closes this overlay and opens that one in the same step. The first image gets no previous link and the last gets no next link, which is the one part that has to be written by hand. Open a thumbnail below and use the arrows.

First image Next
Previous Last image

HTML

<!-- the middle image links both ways; the ends link one way -->
<div id="photo-2" class="lb-overlay">
  <a href="#closed" class="lb-close" aria-label="Close the image">&#10005;</a>
  <img src="two-large.jpg" alt="Harbour at low tide">
  <div class="lb-nav">
    <a href="#photo-1" class="lb-nav-btn">Previous</a>
    <a href="#photo-3" class="lb-nav-btn">Next</a>
  </div>
</div>

CSS

.lb-overlay {
  display: none;
  position: fixed;
  inset: 0;
  /* column, so the nav row sits under the image */
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  background: rgba(0, 0, 0, .85);
  z-index: 999;
}

.lb-overlay:target {
  display: flex;
}

.lb-nav {
  display: flex;
  gap: .75rem;
  align-items: center;
}

.lb-nav-btn {
  padding: .5rem 1rem;
  border-radius: 6px;
  background: #141415;
  border: 1px solid #2a2a2d;
  color: #f0f0f0;
  text-decoration: none;
  font-size: .85rem;
}

Locking the page behind the overlay

An open lightbox sits over a gallery that is still perfectly scrollable, so a wheel gesture over the image scrolls the page underneath and the reader loses their place. :has() fixes it from the other direction: a rule on <body> can now react to an element deep inside the page being targeted, and set overflow: hidden for exactly as long as an overlay is open. Nothing detects anything, and no class is added anywhere. Where :has() is unsupported the rule is skipped and the lightbox behaves as it always did.

HTML

<div class="lb-gallery lb-gallery-lock">
  <a href="#css-lightbox-lock-1" class="lb-thumb"><div class="lb-thumb-img lb-img-2"></div></a>
  <a href="#css-lightbox-lock-2" class="lb-thumb"><div class="lb-thumb-img lb-img-4"></div></a>
  <a href="#css-lightbox-lock-3" class="lb-thumb"><div class="lb-thumb-img lb-img-6"></div></a>
</div>
<div id="lock-1" class="lb-overlay lb-overlay-lock">
  <a href="#css-lightbox-closed" class="lb-close" aria-label="Close the image">&#10005;</a>
  <div class="lb-overlay-img lb-img-2" style="width:360px;height:250px"></div>
</div>
<div id="lock-2" class="lb-overlay lb-overlay-lock">
  <a href="#css-lightbox-closed" class="lb-close" aria-label="Close the image">&#10005;</a>
  <div class="lb-overlay-img lb-img-4" style="width:360px;height:250px"></div>
</div>
<div id="lock-3" class="lb-overlay lb-overlay-lock">
  <a href="#css-lightbox-closed" class="lb-close" aria-label="Close the image">&#10005;</a>
  <div class="lb-overlay-img lb-img-6" style="width:360px;height:250px"></div>
</div>

CSS

/* the page stops scrolling for exactly as long as an overlay is open */
body:has(.lb-overlay:target) {
  overflow: hidden;
}

/* the close control describes what it does, rather than being a
   bare symbol that announces as punctuation */
.lb-close {
  cursor: zoom-out;
  width: 40px;
  height: 40px;
}

How it works

Each thumbnail is a link pointing to an id on a lightbox overlay: <a href="#img1">. When clicked, the browser changes the URL hash and CSS applies :target styles to the matching overlay, showing it. Clicking the overlay's <a href="#"> close button removes the hash, hiding it again. The overlay uses position: fixed to cover the viewport.

Every image needs two elements: a thumbnail link in the grid and a full size overlay somewhere in the document. That is the main cost of the pattern. A gallery of forty photographs is forty overlays in the HTML, each with an id, and the browser lays all of them out even though thirty nine are display: none. It stays cheap because a hidden overlay paints nothing, and it stops being cheap when the images inside are real <img> elements that the browser decides to preload.

position: fixed with inset: 0 is what makes the overlay cover the viewport rather than the document. absolute would place it against the nearest positioned ancestor, which is why the demos on this page use a mixture: the page grid keeps overlays fixed, and anything scoped to a demo box positions against that box instead.

The thumbnails are a grid with repeat(3, 1fr) columns and aspect-ratio: 1 on each cell, so every tile is square whatever the column width works out to be. overflow: hidden on the tile plus object-fit: cover on the image inside crops a photograph of any proportion into that square. Without the object-fit the picture would be stretched to the square instead, which is obvious on anything with a horizon in it.

cursor: zoom-in on the thumbnail is a small detail that does real work. It tells a pointer user what the click is going to do before they make it, and it is the difference between a grid that reads as a gallery and one that reads as a set of unlabelled links. There is a matching zoom-out value for the close control.

Only one :target can match at a time, because a URL has one fragment. Two overlays can never both be open, and clicking a thumbnail while another image is showing swaps them with no extra rules. That single value constraint is also why previous and next links work so cleanly: each one simply points at the neighbouring id.

CSS properties used

:target
Matches the element whose id is the current URL fragment. The open state of the whole gallery is this one pseudo-class.
position
fixed with inset: 0 covers the viewport regardless of how far the page has been scrolled.
aspect-ratio
1 on each thumbnail keeps the grid square as the column width changes with the viewport.
object-fit
cover crops the photograph into the square tile. contain on the full size image letterboxes it instead of cropping, which is what a viewer wants.
cursor
zoom-in on the thumbnail and zoom-out on the close control, which describe the action before it happens.
:has()
Lets a rule on <body> react to an overlay deeper in the page being targeted, which is how the page behind the lightbox is stopped from scrolling.

Browser support

FeatureChromeFirefoxSafariEdge
:target43.53.212
position: fixed423.112
grid575210.116
object-fit32361079
:has()10512115.4105

:target is tracked on Can I Use inside the CSS3 selectors entry, which is where its figures come from, and it is older than everything else in the table. :has() is the newest by a wide margin and it is only used for the scroll lock in the second variant, which is an enhancement rather than a requirement: without it the page behind the overlay stays scrollable and the lightbox still works. aspect-ratio has no standalone Can I Use entry in this dataset, so it is described in the properties list above rather than given a version number here.

Accessibility notes

A :target overlay is a <div> that became visible, not a dialog. Nothing announces that it opened, focus is not moved into it, Escape does not close it, and Tab can leave it for the gallery behind. Adding role="dialog" and aria-modal="true" describes the intent but does not implement any of it. For a photo viewer that is usually an acceptable trade, and for anything the reader has to act on it is not.

The close control has to be reachable and obvious. Keep it as the first focusable element inside the overlay, give it an accessible name rather than only a symbol, and make the hit area at least 40 pixels square. A bare multiplication sign with no label announces as punctuation.

Thumbnails are links, so they already take focus in document order and activate with Enter. What they need is an alt value on the image that describes the photograph rather than the interaction, since "Open image" tells a screen reader user nothing about which image they are about to open.

Opening a lightbox on a long gallery leaves the page behind it scrollable, so a scroll gesture over the overlay moves the gallery instead of doing nothing. The :has() rule in the second variant fixes that in three lines, and where :has() is missing the behavior is merely untidy rather than broken.

What you can build with it

  • Photography portfolios. A grid of thumbnails where each one opens full size, with the fragment making individual images linkable.
  • Product image galleries. Alternative views of one item, opened at full size without a script and without a modal library.
  • Documentation screenshots. A small inline figure that expands to a readable size, which is the difference between a screenshot that helps and one nobody can read.
  • Team and press photos. Small square crops in the grid, uncropped portraits in the overlay, using object-fit: cover in one place and contain in the other.
  • Static site galleries. A build with no bundler and no runtime, where pulling in a lightbox library for one page is more machinery than the job needs.

Mistakes worth avoiding

  • Closing with href="#". The fragment clears and the browser jumps to the top of the document, so a reader who was deep in a long gallery loses their place. Point the close link at a fragment that matches nothing instead.
  • Using position: absolute on the overlay. It positions against the nearest positioned ancestor rather than the viewport, so on a scrolled page the overlay opens somewhere off screen.
  • Leaving object-fit off the thumbnail images. Every photograph is stretched to the square instead of cropped to it.
  • Loading full resolution files in the hidden overlays. Forty hidden overlays holding forty large images is forty downloads before anyone clicks anything. Give them loading="lazy" or generate the overlay markup only for what is on screen.
  • Reusing an id between a lightbox overlay and something else on the page. The fragment is one global value, and two features competing for it produce an overlay that opens at the wrong moment.

Frequently asked questions

How do you build a lightbox with CSS only?
Give each overlay an id and hide it with display: none. Make each thumbnail an anchor pointing at that id. Then write .overlay:target { display: flex }. Clicking the thumbnail sets the fragment, :target matches, and the overlay appears. Closing is a link to a fragment that matches nothing.
Can a CSS lightbox have previous and next buttons?
Yes, and they are just links. Inside the overlay for image three, put an anchor to #photo-2 and another to #photo-4. Following either one changes the fragment, which closes the current overlay and opens the neighbouring one in a single navigation. The first variant below does this.
Does a CSS lightbox work on mobile?
Yes. Taps activate links exactly like clicks, and because opening an image adds a history entry, the Android back gesture closes it rather than leaving the gallery. Use position: fixed with inset: 0 so the overlay covers the viewport rather than the document.
How do I stop the page scrolling behind the lightbox?
Write body:has(.lb-overlay:target) { overflow: hidden }. The :has() selector lets a rule on <body> react to a targeted element deeper in the page, which is the one piece this pattern was missing for years. Where :has() is unsupported the page simply stays scrollable.
Is a CSS lightbox better than a JavaScript one?
It is smaller, it needs no library, and it makes every image linkable for free. It cannot trap focus, close on Escape, or preload the neighbouring image, and it needs one overlay element per photograph in the markup. For a portfolio grid those trade offs are usually worth it.