Skip to content

Lightbox

component

An image enlarged in a modal dialog: its thumbnail in a button that opens the native dialog by the invokers of HTML, command="show-modal" and commandfor; Escape, its close button and a click on its backdrop, closedby="any", close it, and the browser gives the focus back to the thumbnail. Its Stimulus controller, lightbox, stands in only where the browser runs neither the invokers nor closedby.

Preview

Light

A bridge at dusk
Code
<twig:Lightbox id="bridge" src="/images/bridge-480.jpg" alt="A bridge at dusk" :width="480" :height="360" :full="{src: '/images/bridge.jpg', width: 1600, height: 1200}" openLabel="Enlarge" />

Dark

A bridge at dusk
Code
<twig:Lightbox id="bridge" src="/images/bridge-480.jpg" alt="A bridge at dusk" :width="480" :height="360" :full="{src: '/images/bridge.jpg', width: 1600, height: 1200}" openLabel="Enlarge" />

Usage

Usage

A bridge at dusk
Code
<twig:Lightbox
    id="bridge"
    src="{{ asset('images/bridge-480.jpg') }}"
    alt="A bridge at dusk"
    :width="480"
    :height="360"
    :full="{src: asset('images/bridge.jpg'), width: 1600, height: 1200}"
    openLabel="{{ 'image.enlarge'|trans }}"
    closeLabel="{{ 'dialog.close'|trans }}"
/>

Installation

php bin/console ux:install lightbox --kit=github.com/parler-haut-interagir-librement/ux-kit

It brings the recipes it needs: image

Props

PropTypeDefaultDescription
srcset string|array<int|string,string>|null null The candidates of the thumbnail as HTML writes them, or a map of densities, or of widths with sizes, to URLs.
sizes string|null null The sizes of the thumbnail's candidates of widths.
sources — [] list<array{type?:string,srcset:string|array<int|string,string>,sizes?:string,media?:string,width?:int,height?:int}> The sources of the thumbnail, as for Picture.
aspect string|null null The ratio of the thumbnail's box, as for Image.
fit string|null null How the thumbnail fills its box, "cover" or "contain".
position string|null null Where the thumbnail is cropped in its box.
full — null array{src:string,width:int,height:int,srcset?:string|array<int|string,string>,sizes?:string,sources?:list<array<string,mixed>>}|null The full image shown in the dialog: the thumbnail itself when null; its candidates sized to the width of the screen unless it has sizes.
caption string|null null A caption under the thumbnail and under the full image.
credit string|null null Who made the image, as the site writes it, under the caption.
license string|null null The name of the license of the image, under the caption.
licenseHref string|null null The address of the license, which its name then links to with rel="license".
creation string|BackedEnum|null null How the image was made, a code of the IPTC Digital Source Type vocabulary, or an enum whose value is one.
creationLabel string|null null The words that say how the image was made; the name the vocabulary gives the code when null.
openLabel string|null null Words screen readers read after the text alternative in the button, such as "Enlarge".
closeLabel string 'Close' What screen readers read for the close button.

Examples

With a caption

The caption, the credit, the license and the creation show under the thumbnail and under the full image.

<twig:Lightbox id="map" src="/images/map-480.png" alt="The map of the city" :width="480" :height="360" caption="Where we meet" credit="Drawing: Ana Lima" />

Accessibility

The button is named by the image's text alternative, then by openLabel, read by screen readers alone; the dialog is named by the text alternative, or by the caption when the image is decorative. The full image loads lazily, when the dialog opens. The close button reads closeLabel. Every text is a prop, for the application to translate.

Files to copy

assets/controllers/lightbox_controller.js
import { Controller } from '@hotwired/stimulus';

/** Whether a click fell outside a box, on the backdrop of a modal dialog. */
export function isOutside({ clientX, clientY }, { top, right, bottom, left }) {
    return clientX < left || clientX > right || clientY < top || clientY > bottom;
}

/** Whether the browser runs the `command` and `commandfor` of a button itself: the invokers of HTML. */
export function hasInvokers() {
    return typeof HTMLButtonElement !== 'undefined' && 'commandForElement' in HTMLButtonElement.prototype;
}

/** Whether the browser closes a dialog on a click on its backdrop itself, as `closedby="any"` asks. */
export function hasLightDismiss() {
    return typeof HTMLDialogElement !== 'undefined' && 'closedBy' in HTMLDialogElement.prototype;
}

/*
 * The fallback of a lightbox. Its buttons carry `command` and `commandfor` and its dialog `closedby="any"`: where the
 * browser runs them, the controller does nothing. Where it does not, the controller opens the dialog as a modal from
 * the thumbnail, closes it from its button, and on a click on its backdrop.
 */
export default class extends Controller {
    static targets = ['dialog'];

    open() {
        if (!hasInvokers() && !this.dialogTarget.open) {
            this.dialogTarget.showModal();
        }
    }

    close() {
        if (!hasInvokers() && this.dialogTarget.open) {
            this.dialogTarget.close();
        }
    }

    closeOnClickOutside(event) {
        if (!hasLightDismiss() && event.target === this.dialogTarget && isOutside(event, this.dialogTarget.getBoundingClientRect())) {
            this.dialogTarget.close();
        }
    }
}
templates/components/Lightbox.html.twig
{#-
 # An image enlarged in a modal dialog: its thumbnail in a button that opens the native dialog by the invokers of HTML,
 # command="show-modal" and commandfor, the dialog closed by Escape, by its close button or by a click on its backdrop
 # with closedby="any"; the lightbox controller stands in only where the browser runs neither. Both pictures go through
 # the Picture of the image recipe, the full one shown whole; the caption, the credit, the license and the creation
 # show under both. A refused image draws nothing.
 #}
{%- props
    ## string The identifier of the lightbox, unique in the page, from which that of its dialog is made.
    id,
    ## string The URL of the thumbnail.
    src,
    ## string The text alternative, which also names the dialog; an empty string names it by the caption.
    alt,
    ## int The width of the thumbnail, so that its space is reserved at its ratio before it loads.
    width,
    ## int The height of the thumbnail, so that its space is reserved at its ratio before it loads.
    height,
    ## string|array<int|string,string>|null The candidates of the thumbnail as HTML writes them, or a map of densities, or of widths with sizes, to URLs.
    srcset = null,
    ## string|null The sizes of the thumbnail's candidates of widths.
    sizes = null,
    ## list<array{type?:string,srcset:string|array<int|string,string>,sizes?:string,media?:string,width?:int,height?:int}> The sources of the thumbnail, as for Picture.
    sources = [],
    ## string|null The ratio of the thumbnail's box, as for Image.
    aspect = null,
    ## string|null How the thumbnail fills its box, "cover" or "contain".
    fit = null,
    ## string|null Where the thumbnail is cropped in its box.
    position = null,
    ## array{src:string,width:int,height:int,srcset?:string|array<int|string,string>,sizes?:string,sources?:list<array<string,mixed>>}|null The full image shown in the dialog: the thumbnail itself when null; its candidates sized to the width of the screen unless it has sizes.
    full = null,
    ## string|null A caption under the thumbnail and under the full image.
    caption = null,
    ## string|null Who made the image, as the site writes it, under the caption.
    credit = null,
    ## string|null The name of the license of the image, under the caption.
    license = null,
    ## string|null The address of the license, which its name then links to with rel="license".
    licenseHref = null,
    ## string|BackedEnum|null How the image was made, a code of the IPTC Digital Source Type vocabulary, or an enum whose value is one.
    creation = null,
    ## string|null The words that say how the image was made; the name the vocabulary gives the code when null.
    creationLabel = null,
    ## string|null Words screen readers read after the text alternative in the button, such as "Enlarge".
    openLabel = null,
    ## string What screen readers read for the close button.
    closeLabel = 'Close'
-%}
{%- if image_origin(src) is not same as('refused') and not (src|replace({"\t": '', "\n": '', "\r": ''}) matches '#^[[:cntrl:] ]*(javascript:|vbscript:|data:(?!image/))#i') -%}
    {%- set _dialog = 'lightbox-' ~ id -%}
    {%- set _full = full ?? {src: src, srcset: srcset, sources: sources, width: width, height: height} -%}
    <div {{ html_attr({class: ['isLightbox', attributes.all.class ?? null]|filter(name => name is not empty)|join(' ')}) }} data-controller="lightbox">
        <twig:Image:Figure :caption="caption" :credit="credit" :license="license" :licenseHref="licenseHref" :creation="creation" :creationLabel="creationLabel">
            <button type="button" class="isLightboxTrigger" command="show-modal" commandfor="{{ _dialog }}" aria-haspopup="dialog" data-action="click->lightbox#open">
                <twig:Picture :src="src" :alt="alt" :width="width" :height="height" :srcset="srcset" :sizes="sizes" :sources="sources" :aspect="aspect" :fit="fit" :position="position" />
                {%- if openLabel is not empty %}<span class="isVisuallyHidden">{{ openLabel }}</span>{% endif -%}
            </button>
        </twig:Image:Figure>
        <dialog id="{{ _dialog }}" class="isLightboxDialog" closedby="any" aria-label="{{ alt is not empty ? alt : caption }}" data-lightbox-target="dialog" data-action="click->lightbox#closeOnClickOutside">
            <twig:Picture :src="_full.src ?? src" :alt="alt" :width="_full.width ?? width" :height="_full.height ?? height" :srcset="_full.srcset ?? null" :sizes="_full.sizes ?? '100vw'" :sources="_full.sources ?? []" fit="contain" :caption="caption" :credit="credit" :license="license" :licenseHref="licenseHref" :creation="creation" :creationLabel="creationLabel" />
            <button type="button" class="isLightboxClose" command="close" commandfor="{{ _dialog }}" data-action="click->lightbox#close"><span aria-hidden="true">&times;</span><span class="isVisuallyHidden">{{ closeLabel }}</span></button>
        </dialog>
    </div>
{%- endif -%}