- Fix resize handler memory leak — store
$.proxyref and reuse foroff()(Section 3) - Fix
.lb-prevLink/.lb-nextLinkdead selectors →.lb-prev/.lb-next(Section 3) - Add
preloader.onerrorhandler — hide loader, show error state (Section 3) - Fix SVG detection for URLs with query strings/fragments (Section 3)
- Cancel pending image loads on rapid navigation (Section 3)
- Cache repeated
this.$lightbox.find()calls inbuild()(Section 7)
- Add
role="dialog",aria-modal="true",aria-labelto#lightbox(Section 1) - Implement focus trap — constrain Tab to lightbox when open (Section 1)
- Restore focus to trigger element on close (Section 1)
- Add
aria-describedbylinking image to caption (Section 1) - Add
aria-live="polite"to.lb-number(Section 1) -
DefaultSKIPPED — breaking change (Section 2)sanitizeTitletotrue - Fix selector injection — use
.filter()instead of string concatenation (Section 2)
- Remove stale vendor prefixes (Section 5)
- Switch overlay to
position: fixed; inset: 0, remove JSsizeOverlay()(Section 5) - Add CSS custom properties for theming (Section 5)
- Expose public API:
open(),close(),next(),prev(),destroy()(Section 6) - Add jQuery custom events:
lightbox:open,lightbox:close,lightbox:change(Section 6) - Debounce window resize handler (Section 7)
- Replace
void 0withundefined(Section 10) - Fix
i = ++iloop increments toi++(Section 10) - Use
selfconsistently instead of mixingself/.bind()(Section 10) - Replace
return falsewithevent.preventDefault()where appropriate (Section 10)
- Replace Bower with npm for jQuery (Section 4)
- Replace JSHint + JSCS with ESLint (Section 4)
- Replace Grunt with npm scripts or esbuild/Rollup (Section 4)
- Rewrite to vanilla JS using native DOM APIs (Section 8)
- Replace jQuery animations with CSS transitions + class toggles (Section 7)
- Replace PNG/GIF icon assets with inline SVGs (Section 5)
- Replace float layout with flexbox (Section 5)
- Export constructor/class instead of singleton instance (Section 6)
- Implement EventTarget for event emission (Section 6)
- Set up test runner (Vitest + jsdom or Playwright) (Section 9)
- Tests: album building — single, grouped,
rel, mixed (Section 9) - Tests: navigation — forward, backward, wrap-around, keyboard, boundaries (Section 9)
- Tests: image sizing — viewport fit, maxWidth/maxHeight, SVG, aspect ratio (Section 9)
- Tests: open/close lifecycle — focus, scroll lock, listener cleanup (Section 9)
- Tests: error handling — 404 image, missing href (Section 9)
- Tests: edge cases — special chars in
data-lightbox, rapid clicking (Section 9)
Lightbox2 is a well-crafted, stable library that has served the community for years. This evaluation examines the codebase as it stands today and identifies concrete improvements, ordered by impact. The project is in maintenance mode for v2.x, so recommendations are split into v2.x maintenance fixes (safe, backwards-compatible) and v3.0 architectural changes (breaking).
The lightbox has basic keyboard support (Esc, arrows) and some ARIA labels on nav buttons. But it falls short of WCAG 2.1 guidelines in several ways.
- No focus trap. When the lightbox is open, Tab can move focus to elements behind the overlay. Screen reader users and keyboard users can interact with the page underneath.
- No
aria-modal="true". The lightbox overlay functions as a modal dialog but doesn't announce itself as one. Screen readers won't know the rest of the page is inert. - No
role="dialog"oraria-labelon the lightbox container. Screen readers have no context about what opened. - Focus not restored on close. After closing the lightbox, focus is lost instead of returning to the trigger element that opened it.
- Nav arrows use
<a href="">. Empty hrefs are semantically wrong for buttons. They should be<button>elements or at minimum haverole="button"(partially done) andhrefremoved. - Caption not linked to image via
aria-describedby. Screen readers don't associate the caption with the displayed image. - Image counter is visual-only. The "Image 2 of 5" label should be available to screen readers, ideally as a live region so it's announced on image change.
- Touch detection is unreliable.
document.createEvent('TouchEvent')inupdateNav()is a poor heuristic. Many laptops have touch screens but also have hover. Better to use@media (hover: none)in CSS ormatchMediain JS.
- Add
role="dialog",aria-modal="true", andaria-label="Image lightbox"to#lightbox. - Implement a focus trap: on open, constrain Tab to lightbox elements; on close, restore focus to the triggering
<a>. - Store
this.$triggerElementinstart()and callthis.$triggerElement.trigger('focus')inend(). - Add
aria-describedbylinking the image to the caption. - Add
aria-live="polite"to.lb-numberso image counter changes are announced.
sanitizeTitledefaults tofalse. The caption is injected as raw HTML via$caption.html(title). Ifdata-titlevalues come from a CMS, database, or URL parameters, this is an XSS vector. Any user-generated content scenario is vulnerable by default.- Selector injection in
start(). The album grouping builds a jQuery selector by string concatenation:If$($link.prop('tagName') + '[data-lightbox="' + dataLightboxValue + '"]')
data-lightboxcontains characters like"], this breaks or enables selector injection. Not exploitable for XSS in practice, but causes bugs with special characters in gallery names.
- Change
sanitizeTitledefault totrue. This is technically breaking for anyone relying on HTML in captions, but the security benefit is significant. Document the change prominently. - Use
.filter()instead of building selectors from attribute values:$links = $(tagName).filter(function() { return $(this).attr('data-lightbox') === dataLightboxValue; });
preloader.onerroris never handled. If an image fails to load (404, network error), the lightbox shows the loader indefinitely. No error state, no way to dismiss gracefully. Users are stuck.thisbinding bug inbuild()line 195. Inside the contextmenu handler:ThesetTimeout(function() { this.$nav.css('pointer-events', 'auto'); }.bind(self), 0);
.bind(self)is correct, but earlier on line 195 it saysthis.$nav— this works because of the bind, but the surrounding code (line 191) usesself.$nav. The inconsistency is confusing. Useselfconsistently.- Resize handler leak.
start()binds$(window).on('resize', ...)using$.proxy. Butend()tries to unbind with$(window).off('resize', this.sizeOverlay). Since$.proxycreates a new function, the reference doesn't match and the handler is never unbound. Each open/close cycle leaks a resize listener. - Race condition in
changeImage(). If a user rapidly clicks next/prev, multiplepreloader.onloadcallbacks can fire, causing layout glitches. There's no mechanism to cancel a pending image load. - SVG file type detection is naive.
filename.split('.').slice(-1)[0]fails for URLs with query strings (image.svg?v=2) or fragment identifiers. Also fails for URLs without extensions. .lb-prevLinkand.lb-nextLinkreferenced insizeContainer()but never exist in the DOM. Lines 412-413 query.lb-prevLinkand.lb-nextLink, but the actual classes are.lb-prevand.lb-next. This code silently does nothing.
- Add
preloader.onerrorhandler that hides the loader, shows an error message or the next image, and logs a console warning. - Fix the resize handler leak: store the proxied function and use the same reference for
off().this._sizeOverlayProxy = $.proxy(this.sizeOverlay, this); $window.on('resize', this._sizeOverlayProxy); // in end(): $(window).off('resize', this._sizeOverlayProxy);
- Cancel pending image loads in
changeImage()by nulling the previous preloader'sonload. - Fix SVG detection to strip query strings:
filename.split('?')[0].split('.').slice(-1)[0]. - Fix
.lb-prevLink/.lb-nextLinkto.lb-prev/.lb-nextinsizeContainer().
Grunt, Bower, JSHint, and JSCS. All four are effectively abandoned projects.
- Bower has been deprecated since 2017 and tells users to switch to npm/yarn.
- Grunt is rarely maintained. The ecosystem has moved to npm scripts, Rollup, esbuild, or Vite.
- JSHint is functional but superseded by ESLint which has better rules and plugin ecosystem.
- JSCS has been formally merged into ESLint and is unmaintained since 2016. Its npm page says "JSCS has merged with ESLint."
- No CSS autoprefixer. The CSS manually includes
-webkit-,-moz-, and-o-prefixed properties. An autoprefixer step would keep these accurate and remove unnecessary ones. - No tests.
grunt testonly runs linters. There are zero unit or integration tests.
- Replace Bower with npm for jQuery:
npm install jquery --save. - Replace JSHint + JSCS with ESLint (single config file).
- Add an autoprefixer step to the CSS build (postcss + autoprefixer).
- Consider replacing Grunt with npm scripts — the build is simple enough (copy, concat, uglify, cssmin) that a Gruntfile adds indirection without benefit.
- Drop the jQuery dependency entirely and use vanilla JS (see Section 8).
- Use a modern bundler (esbuild or Rollup) to produce UMD/ESM/CJS bundles from a modern source.
- Add a basic test suite (see Section 9).
- Vendor prefixes are stale.
-webkit-transition,-moz-transition,-o-transitionhaven't been needed since ~2013. They add dead bytes. position: absolutefor overlay instead ofposition: fixed. The overlay usesposition: absoluteand is sized to$(document).width()/height()via JS. Aposition: fixedoverlay withinset: 0would cover the viewport natively without JS sizing, and would handle scroll correctly.- Clearfix hack (
:after { content: ""; display: table; clear: both }) is used for float-based layout. Flexbox would be simpler and more robust. - Magic numbers. Hard-coded values like
z-index: 9999/10000, the 250px initial container size,top: 43%for the loader,34%/64%split for prev/next zones — none are documented or tokenized. - No CSS custom properties. Colors, durations, and spacing are hard-coded, making theming impossible without overriding many selectors.
- Image assets for UI icons. close.png, loading.gif, prev.png, next.png require HTTP requests and don't scale for high-DPI displays. Inline SVGs or CSS-only solutions would be sharper and eliminate the image path configuration issue that causes many bug reports.
- Remove vendor prefixes (or add autoprefixer to the build).
- Switch overlay to
position: fixed; inset: 0and remove the JSsizeOverlay()method. - Add CSS custom properties for the most commonly themed values:
:root { --lb-overlay-opacity: 0.8; --lb-border-radius: 3px; --lb-image-border: 4px solid white; --lb-transition-speed: 0.6s; }
- Replace PNG/GIF assets with inline SVGs.
- Replace float layout with flexbox.
- Singleton instantiation. The module returns
new Lightbox(), so there's only ever one instance. This makes it impossible to have two independently configured lightboxes on the same page. - No public API for programmatic control. There's no way to open the lightbox from JS (
lightbox.open(url)), go to a specific image, or close it programmatically beyond hacking internal state. - No events. The library doesn't emit events (open, close, imageChange). This makes integration with other components (analytics, routing, etc.) impossible without monkey-patching.
- No
destroy()method. Once initialized, the lightbox DOM and event listeners persist forever. This is a problem in SPA contexts where the component tree changes. - Options are set once at construction. There's no way to change options after initialization in a clean way (the
option()method exists but is internal and undocumented).
- Expose
open(imageUrl, options),close(),next(),prev(),destroy()as public methods. - Add event emission for
lightbox:open,lightbox:close,lightbox:changevia jQuery custom events on the lightbox element.
- Export the constructor/class rather than an instance. Let users create multiple instances.
- Implement a proper EventEmitter or use
EventTarget.
- Repeated jQuery lookups. Methods like
updateNav()andupdateDetails()repeatedly callthis.$lightbox.find(...)for the same elements. These should be cached inbuild()alongside the existing cached references. - jQuery animations instead of CSS transitions.
fadeIn(),fadeOut(), andanimate()use jQuery's JS-based animation engine, which is slower than CSS transitions/animations. CSS transitions get GPU acceleration and don't block the main thread. - No
will-changehints. The lightbox container animates width/height, but nowill-changeis set, so the browser can't optimize. sizeOverlay()runs on everyresizeevent. No debounce/throttle.
- Cache all repeated
find()results inbuild(). - Debounce the window resize handler (100-200ms).
- Replace jQuery animations with CSS transitions and class toggles.
jQuery was essential in 2012 for cross-browser compatibility. Today, all of Lightbox2's jQuery usage maps directly to native APIs:
| jQuery | Native equivalent |
|---|---|
$(selector) |
document.querySelectorAll() |
$.extend() |
Object.assign() / spread |
$el.on() |
el.addEventListener() |
$el.off() |
el.removeEventListener() |
$el.fadeIn() |
CSS transitions + class toggle |
$el.animate() |
CSS transitions or Web Animations API |
$el.css() |
el.style.property |
$el.width() |
el.offsetWidth / el.getBoundingClientRect() |
$(document).ready() |
DOMContentLoaded or defer script |
$.proxy() |
Function.prototype.bind() |
Dropping jQuery would:
- Eliminate the 30KB+ (minified) dependency
- Make the library usable without any dependency management
- Reduce the bundled file from ~120KB to ~5KB
Zero tests. grunt test only runs linters.
Given the library's surface area, a focused test suite covering these scenarios would catch most regressions:
- Album building: Single image, grouped set via
data-lightbox, grouped set viarel, mixed - Navigation: Forward, backward, wrap-around on/off, keyboard nav, first/last boundary
- Image sizing: Viewport fitting, maxWidth/maxHeight, SVG scaling, aspect ratio preservation
- Options: Each option behaves as documented
- Open/close lifecycle: Focus management, scroll locking, event listener cleanup
- Error handling: 404 image, missing
href - Edge cases: Special characters in
data-lightbox, single-image galleries, rapid clicking
- Use a lightweight DOM-capable test runner (e.g., Vitest with jsdom, or Playwright for real-browser tests).
- Start with the bugs identified in Section 3 as the first test cases — write a failing test, then fix the bug.
void 0forundefined(line 33). A CoffeeScript-ism. Just useundefinedor don't initialize.i = ++iin loops (lines 238, 251).i++is clearer and standard.i = ++iis technically fine but looks like a bug.return falsein click handlers. This calls bothpreventDefault()andstopPropagation(). Usually onlypreventDefault()is intended. Stopping propagation can break other event listeners.- Inconsistent
var self = thisvs.bind(). The code mixes both patterns. Pick one for consistency. - The
option()method uses$.extendfor a shallow merge. With nested option objects this would overwrite sub-objects entirely rather than merging them. Not an issue with the current flat options, but fragile.
If tackling these in order of impact-to-effort ratio:
- Fix resize handler memory leak (Section 3)
- Fix
.lb-prevLink/.lb-nextLinkdead selectors (Section 3) - Add
preloader.onerrorhandling (Section 3) - Fix SVG query string detection (Section 3)
- Cancel pending image loads on rapid navigation (Section 3)
- Cache repeated jQuery
.find()calls (Section 7)
- Add focus trap and focus restoration for accessibility (Section 1)
- Add ARIA attributes (
role="dialog",aria-modal, etc.) (Section 1) - Default
sanitizeTitletotrue(Section 2) - Fix selector injection with
.filter()(Section 2) - Switch overlay to
position: fixedand removesizeOverlay()(Section 5) - Expose public API methods and events (Section 6)
- Replace Grunt/Bower/JSHint/JSCS with modern tooling (Section 4)
- Add CSS custom properties for theming (Section 5)
- Drop jQuery dependency (Section 8)
- Add test suite (Section 9)
- Replace image assets with inline SVGs (Section 5)