Navigation

v0.0.4
Overlays

Lightbox

A picture opened large over the page, in a native dialog.

On this page

A lightbox is a picture opened large over the page. Use .lightbox on a native <dialog>: the thumbnail's own button opens it with commandfor and command="show-modal", so the browser supplies the top layer, the focus trap and Esc, and no script is involved. The dialog has no sheet of its own. It is exactly as large as the picture, which stands on a page dimmed and blurred well out of focus.

Lightbox

<button class="card card-interactive w(240)" type="button" commandfor="moonstone" command="show-modal">  <img class="card-media" src="/demo/moonstone.svg" alt>  <span class="card-header">    <span class="card-title">Moonstone</span>    <span class="card-description">View the picture</span>  </span></button><dialog class="lightbox" id="moonstone" closedby="any" aria-label="Moonstone" style="--lightbox-ratio: 16 / 9">  <img class="lightbox-media" src="/demo/moonstone.svg" alt="A pale stone over grey hills">  <p class="lightbox-caption">Moonstone, the stone the light theme is named for.</p>  <button class="btn btn-sm btn-square lightbox-close" type="button" commandfor="moonstone" command="close" aria-label="Close">    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">      <path d="m6 6 12 12M18 6 6 18" />    </svg>  </button></dialog>
Press the card. Click outside the picture, press the close button or Esc to close it.

#Opening and closing

  • commandfor="id" with command="show-modal" on the thumbnail's button opens the lightbox. command="close" on the close button closes it and returns focus to the thumbnail.
  • closedby="any" adds light dismiss. Because the dialog is exactly as large as the picture, a click anywhere else is a click outside it, and closes it. A browser that does not know the attribute keeps Esc and the close button.
  • In a browser without invoker commands, call dialog.showModal() and dialog.close() from a click handler. The stylesheet is the same.

The picture fades in from a slightly smaller size and leaves faster than it arrived, through @starting-style and discrete transitions; without them it appears and leaves at once.

#Size

lightbox-media goes on an <img>, a <video> or an <iframe>. Its size does not come from the file. It is 64rem wide, or as wide as the viewport's width or height allows with a 24px gutter on every side, at the proportions set in --lightbox-ratio: 3 / 2 unless you say otherwise. So an image, a video and a frame are sized the same way, and nothing moves when one loads.

Set --lightbox-ratio on the dialog to the picture's own proportions, as the example does with 16 / 9; a picture of other proportions is cropped to fit. --lightbox-width sets the widest it gets, and --lightbox-gutter the room kept at the edges.

#Caption and close

lightbox-caption is a floating bar under the picture, as wide as it is; the picture leaves room for it. Keep it to a line or two. lightbox-close goes on a small square button and rests it 8px inside the picture's top corner, where it is found on any picture because it is a raised control with its own edge.

A lightbox holds one picture and nothing to decide. An overlay with a title, text or a form is a dialog. A picture that stays in the page with its caption is a figure.

#Accessibility

  • Name the dialog with aria-label, or aria-labelledby pointing at its caption.
  • Give the large picture a real alt. The thumbnail inside the button may then have an empty one, when the button's own words say what opens.
  • Always include a close button with an accessible name: light dismiss and Esc are not found by everyone.
  • Focus moves into the lightbox when it opens and returns to the thumbnail when it closes. With reduced motion the picture fades without changing size.

#Classes

ClassDescription
lightboxThe dialog, with no sheet of its own: it is exactly as large as the picture, over a dimmed and blurred page
lightbox-mediaThe picture, a video or a frame: 64rem wide or as large as the viewport allows with a 24px gutter, at the proportions of --lightbox-ratio
lightbox-captionA floating bar under the picture, as wide as it is
lightbox-closeOn the close button: it rests 8px inside the top corner of the picture

#Custom properties

PropertyDefaultDescription
--lightbox-ratio3 / 2The picture's proportions, width over height; the picture is cropped to them
--lightbox-width64remThe widest the picture gets
--lightbox-gutter1.5remThe room kept between the picture and each edge of the viewport

#Source

@master/ui/components/lightbox.css
/* * Lightbox: a picture opened large over the page. * A native <dialog>, opened as a modal by the thumbnail's own button with commandfor and command="show-modal". * The dialog has no sheet: it is exactly as large as the picture it holds, so a click anywhere else is a * click outside it and, with closedby="any", closes it. The page behind is dimmed and blurred well out of * focus, and the picture stands on it with the shadow of an overlay. * The picture is 64rem wide, or as wide as the viewport lets it be with a 24px gutter on every side, at the * proportions set in --lightbox-ratio, 3 by 2 unless told otherwise. Its size comes from that and never from * the file, so an image, a video and a frame are all sized the same way and nothing moves when one loads. * A caption is a floating bar under the picture, as wide as it is. A close button rests 8px inside its corner. */@layer components {  .lightbox {    --lightbox-width: 64rem;    --lightbox-ratio: 3 / 2;    --lightbox-radius: var(--radius-control-lg);    --lightbox-gutter: 1.5rem;    /* The room a caption takes under the picture. */    --lightbox-room: 0rem;    width: fit-content;    max-width: none;    height: fit-content;    max-height: none;    margin: auto;    padding: 0;    border: 0;    overflow: visible;    background: transparent;    color: var(--color-text-body);    /* Closed is where it leaves to: it fades and settles back a little, faster than it arrived. */    opacity: 0;    scale: .96;    transition-property: opacity, scale, overlay, display;    transition-duration: var(--duration-quick);    transition-timing-function: var(--easing-glide);    transition-behavior: allow-discrete;    &[open] {      display: grid;      gap: .5rem;      opacity: 1;      scale: 1;      transition-duration: var(--duration-base);      @starting-style {        opacity: 0;        scale: .96;      }    }    /* The page steps well back: the same scrim as a dialog, with a deeper blur. */    &::backdrop {      background: var(--color-surface-scrim);      backdrop-filter: blur(16px);      opacity: 0;      transition-property: opacity, overlay, display;      transition-duration: var(--duration-quick);      transition-timing-function: var(--easing-glide);      transition-behavior: allow-discrete;    }    &[open]::backdrop {      opacity: 1;      transition-duration: var(--duration-base);      @starting-style {        opacity: 0;      }    }    /* With a caption under it, the picture leaves room for the bar. */    &:has(.lightbox-caption) {      --lightbox-room: 3rem;    }    @media (prefers-reduced-motion: reduce) {      scale: none;      &[open] {        scale: none;        @starting-style {          scale: none;        }      }    }    @media (forced-colors: active) {      &::backdrop {        background: Canvas;        backdrop-filter: none;      }    }  }  /* The picture, a video or a frame: as wide as its limit, the viewport's width or the viewport's height allows. */  .lightbox-media {    display: block;    width: min(var(--lightbox-width), 100vw - var(--lightbox-gutter) * 2, (100dvh - var(--lightbox-gutter) * 2 - var(--lightbox-room)) * var(--lightbox-ratio));    height: auto;    aspect-ratio: var(--lightbox-ratio);    margin: 0;    border: 0;    border-radius: calc(var(--lightbox-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: var(--color-surface-inset);    box-shadow: var(--shadow-overlay);    outline: 1px solid var(--color-line-image);    outline-offset: -1px;    object-fit: cover;    @media (forced-colors: active) {      outline-color: CanvasText;    }  }  /* The caption: a floating bar as wide as the picture, which never makes the dialog wider than it. */  .lightbox-caption {    box-sizing: border-box;    width: 0;    min-width: 100%;    margin: 0;    padding: .5rem .875rem;    border-radius: calc(var(--lightbox-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: var(--color-surface-floating);    color: var(--color-text-body);    box-shadow: var(--shadow-floating);    font-size: .8125rem;    line-height: 1.45;    text-align: center;    text-wrap: balance;    overflow-wrap: anywhere;    @media (forced-colors: active) {      outline: 1px solid CanvasText;      outline-offset: -1px;    }  }  /* On the close button: it rests inside the picture's top corner. */  .lightbox-close {    position: absolute;    z-index: 1;    top: .5rem;    inset-inline-end: .5rem;  }}