Ken Burns Effect Slideshow

This is a simple solution for creating a slideshow with the Ken Burns Effect.

This style of slideshow is best when configured to direct the viewer’s attention to particular focal points within the images. It supports 52 possible effects that pan and zoom in various directions. Each slide can have its own set interval for how long the image should be displayed.

It also allows for various resource optimizations for quick load times and efforts to reduce wasting the user’s device resources:

  • Lazy loading with fetchpriority.
  • Deferred loading with data-src attributes.
  • Pausing the slideshow when the visitor scrolls the slideshow out of view or changes browser tabs.

HTML

<main>
    <div class="kenburns-demo">
        <div class="kenburns-demo__viewport">
            <div class="kenburns" data-kb-fade="1000" aria-label="Photo slideshow">
                <div class="kenburns__slide" data-kb-effect="classic-in-bottom-left-center" data-kb-interval="5000">
                    <img src="kenburns-demo-photos/01.jpg" alt="" width="1728" height="998" draggable="false" decoding="async" fetchpriority="high">
                </div>

                <div class="kenburns__slide" data-kb-effect="classic-in-bottom-left-center" data-kb-interval="5000">
                    <img src="kenburns-demo-photos/01.jpg" data-src="kenburns-demo-photos/02.jpg" alt="" width="1728" height="998" draggable="false" decoding="async">
                </div>

                <div class="kenburns__slide" data-kb-effect="classic-in-bottom-left-center" data-kb-interval="5000">
                    <img src="kenburns-demo-photos/01.jpg" data-src="kenburns-demo-photos/03.jpg" alt="" width="1728" height="998" draggable="false" decoding="async">
                </div>
            </div>
        </div>

        <div class="kenburns-demo__controls">
            <div class="kenburns-demo__control kenburns-demo__control--checkbox">
                <label for="enable-all-effects">
                    <input class="kenburns-demo__all-effects" id="enable-all-effects" type="checkbox">
                    Enable All Effects
                </label>
            </div>

            <div class="kenburns-demo__control">
                <label for="effect">Ken Burns Effect:</label>
                <select class="kenburns-demo__effect" id="effect"></select>
            </div>

            <div class="kenburns-demo__control">
                <label for="interval">Slide Transition Time: (in milliseconds)</label>
                <input class="kenburns-demo__interval" id="interval" type="number" min="1000" max="20000" step="500" value="5000" required aria-describedby="interval-help">
                <span id="interval-help">Ex: 5000 = 5 seconds. Minimum: 1000.</span>
            </div>
        </div>
    </div>
</main>

CSS

/*
|--------------------------------------------------------------------------
| Ken Burns
|--------------------------------------------------------------------------
*/
.kenburns {
    --kb-aspect-ratio: 1728 / 998;
    --kb-fade-duration: 1000ms;

    position: relative;
    width: 100%;
    max-width: 100%;
    aspect-ratio: var(--kb-aspect-ratio);
    overflow: hidden;
    isolation: isolate;
    background: #111;
}

/*
|--------------------------------------------------------------------------
| Slides
|--------------------------------------------------------------------------
*/
.kenburns__slide {
    position: absolute;
    inset: 0;
    z-index: 0;
    opacity: 0;
    overflow: hidden;
    pointer-events: none;
    transition: opacity var(--kb-fade-duration) ease-in-out;
}

.kenburns__slide.is-active {
    z-index: 2;
    opacity: 1;
}

.kenburns__slide.is-leaving {
    z-index: 1;
    opacity: 0;
}

.kenburns__slide picture,
.kenburns__slide img {
    display: block;
    width: 100%;
    height: 100%;
}

.kenburns__slide picture {
    overflow: hidden;
}

.kenburns__slide img {
    object-fit: cover;
    object-position: center;
    max-width: none;
    user-select: none;
    transform: translate3d(var(--kb-from-x, 0%), var(--kb-from-y, 0%), 0) scale(var(--kb-from-scale, 1));
    transform-origin: center;
    backface-visibility: hidden;
}

.kenburns__slide.is-active img,
.kenburns__slide.is-leaving img {
    animation: kenburns-move var(--kb-duration, 5000ms) linear both;
    will-change: transform;
}

.kenburns.is-paused .kenburns__slide img {
    animation-play-state: paused;
}

.kenburns:not(.is-ready) .kenburns__slide:first-child {
    z-index: 2;
    opacity: 1;
}

/*
|--------------------------------------------------------------------------
| Motion
|--------------------------------------------------------------------------
*/
@keyframes kenburns-move {
    from {
        transform: translate3d(var(--kb-from-x, 0%), var(--kb-from-y, 0%), 0) scale(var(--kb-from-scale, 1));
    }

    to {
        transform: translate3d(var(--kb-to-x, 0%), var(--kb-to-y, 0%), 0) scale(var(--kb-to-scale, 1));
    }
}

/*
|--------------------------------------------------------------------------
| Reduced Motion
|--------------------------------------------------------------------------
*/
@media (prefers-reduced-motion: reduce) {
    .kenburns__slide {
        transition-duration: 400ms;
    }

    .kenburns__slide.is-active img,
    .kenburns__slide.is-leaving img {
        animation: none;
    }
}

JavaScript

/*
|--------------------------------------------------------------------------
| Ken Burns Effect
|--------------------------------------------------------------------------
|
| Creates responsive image slideshows with CSS-driven Ken Burns motion,
| lazy-loaded slide assets, and automatic pause/resume behavior.
|
*/
const kenBurns = () => {
    'use strict';

    /*
    |--------------------------------------------------------------------------
    | Settings
    |--------------------------------------------------------------------------
    |
    | Shared defaults, scale values, and warning state used by every slideshow
    | instance.
    |
    */
    var DEFAULT_INTERVAL = 5000;
    var DEFAULT_FADE = 1000;
    var DEFAULT_EFFECT = 'zoom-in-center';
    var DEFAULT_ASPECT_RATIO = '1728 / 998';
    var WIDE_SCALE = 1;
    var EDGE_WIDE_SCALE = 1.1;
    var PAN_SCALE = 1.18;
    var CLOSE_SCALE = 1.22;
    var warnedEffects = Object.create(null);

    /*
    |--------------------------------------------------------------------------
    | Motion Positions
    |--------------------------------------------------------------------------
    |
    | Named frame offsets for zoom and pan presets. Positive values move the
    | oversized image down or right within the visible slideshow frame.
    |
    */
    var POSITIONS = {
        center: { x: '0%', y: '0%' },
        left: { x: '4.5%', y: '0%' },
        right: { x: '-4.5%', y: '0%' },
        top: { x: '0%', y: '4.5%' },
        bottom: { x: '0%', y: '-4.5%' },
        topLeft: { x: '4.5%', y: '4.5%' },
        topRight: { x: '-4.5%', y: '4.5%' },
        bottomLeft: { x: '4.5%', y: '-4.5%' },
        bottomRight: { x: '-4.5%', y: '-4.5%' }
    };

    /*
    |--------------------------------------------------------------------------
    | Movement Factory
    |--------------------------------------------------------------------------
    |
    | Converts named start and end positions into CSS custom property values
    | consumed by the Ken Burns animation.
    |
    */
    function movement(fromPosition, fromScale, toPosition, toScale) {
        var from = POSITIONS[fromPosition];
        var to = POSITIONS[toPosition];

        return {
            fromX: from.x,
            fromY: from.y,
            fromScale: String(fromScale),
            toX: to.x,
            toY: to.y,
            toScale: String(toScale)
        };
    }

    /*
    |--------------------------------------------------------------------------
    | Effect Presets
    |--------------------------------------------------------------------------
    |
    | Reusable motion recipes keyed by the data-kb-effect attribute. Each preset
    | defines the image transform at the beginning and end of a slide.
    |
    */
    var PRESETS = {
        /*
        |--------------------------------------------------------------------------
        | Solo Zoom Presets
        |--------------------------------------------------------------------------
        |
        | Zooms toward or away from a fixed focal point.
        |
        */
        'zoom-in-center': movement('center', WIDE_SCALE, 'center', CLOSE_SCALE),
        'zoom-out-center': movement('center', CLOSE_SCALE, 'center', WIDE_SCALE),
        'zoom-in-top-left': movement('topLeft', EDGE_WIDE_SCALE, 'topLeft', CLOSE_SCALE),
        'zoom-out-top-left': movement('topLeft', CLOSE_SCALE, 'topLeft', EDGE_WIDE_SCALE),
        'zoom-in-top-right': movement('topRight', EDGE_WIDE_SCALE, 'topRight', CLOSE_SCALE),
        'zoom-out-top-right': movement('topRight', CLOSE_SCALE, 'topRight', EDGE_WIDE_SCALE),
        'zoom-in-bottom-left': movement('bottomLeft', EDGE_WIDE_SCALE, 'bottomLeft', CLOSE_SCALE),
        'zoom-out-bottom-left': movement('bottomLeft', CLOSE_SCALE, 'bottomLeft', EDGE_WIDE_SCALE),
        'zoom-in-bottom-right': movement('bottomRight', EDGE_WIDE_SCALE, 'bottomRight', CLOSE_SCALE),
        'zoom-out-bottom-right': movement('bottomRight', CLOSE_SCALE, 'bottomRight', EDGE_WIDE_SCALE),

        /*
        |--------------------------------------------------------------------------
        | Solo Pan Presets
        |--------------------------------------------------------------------------
        |
        | Pans across an already enlarged image so the frame never shows gaps.
        |
        */
        'pan-left-right': movement('left', PAN_SCALE, 'right', PAN_SCALE),
        'pan-right-left': movement('right', PAN_SCALE, 'left', PAN_SCALE),
        'pan-bottom-top': movement('bottom', PAN_SCALE, 'top', PAN_SCALE),
        'pan-top-bottom': movement('top', PAN_SCALE, 'bottom', PAN_SCALE),
        'pan-bottom-left-top-right': movement('bottomLeft', PAN_SCALE, 'topRight', PAN_SCALE),
        'pan-top-right-bottom-left': movement('topRight', PAN_SCALE, 'bottomLeft', PAN_SCALE),
        'pan-bottom-right-top-left': movement('bottomRight', PAN_SCALE, 'topLeft', PAN_SCALE),
        'pan-top-left-bottom-right': movement('topLeft', PAN_SCALE, 'bottomRight', PAN_SCALE),
        'pan-left-top-right': movement('left', PAN_SCALE, 'topRight', PAN_SCALE),
        'pan-left-bottom-right': movement('left', PAN_SCALE, 'bottomRight', PAN_SCALE),
        'pan-right-top-left': movement('right', PAN_SCALE, 'topLeft', PAN_SCALE),
        'pan-right-bottom-left': movement('right', PAN_SCALE, 'bottomLeft', PAN_SCALE),

        /*
        |--------------------------------------------------------------------------
        | Classic Motion Presets
        |--------------------------------------------------------------------------
        |
        | Combines zooming and panning for traditional Ken Burns movement.
        |
        */
        'classic-in-top-left': movement('center', WIDE_SCALE, 'topLeft', CLOSE_SCALE),
        'classic-in-top-right': movement('center', WIDE_SCALE, 'topRight', CLOSE_SCALE),
        'classic-in-bottom-left': movement('center', WIDE_SCALE, 'bottomLeft', CLOSE_SCALE),
        'classic-in-bottom-right': movement('center', WIDE_SCALE, 'bottomRight', CLOSE_SCALE),
        'classic-in-left-right': movement('left', EDGE_WIDE_SCALE, 'right', CLOSE_SCALE),
        'classic-in-right-left': movement('right', EDGE_WIDE_SCALE, 'left', CLOSE_SCALE),
        'classic-in-bottom-top': movement('bottom', EDGE_WIDE_SCALE, 'top', CLOSE_SCALE),
        'classic-in-top-bottom': movement('top', EDGE_WIDE_SCALE, 'bottom', CLOSE_SCALE),
        'classic-in-bottom-left-top-right': movement('bottomLeft', EDGE_WIDE_SCALE, 'topRight', CLOSE_SCALE),
        'classic-in-bottom-right-top-left': movement('bottomRight', EDGE_WIDE_SCALE, 'topLeft', CLOSE_SCALE),
        'classic-in-top-left-top-right': movement('topLeft', EDGE_WIDE_SCALE, 'topRight', CLOSE_SCALE),
        'classic-in-bottom-left-bottom-right': movement('bottomLeft', EDGE_WIDE_SCALE, 'bottomRight', CLOSE_SCALE),
        'classic-in-top-left-center': movement('topLeft', EDGE_WIDE_SCALE, 'center', CLOSE_SCALE),
        'classic-in-top-right-center': movement('topRight', EDGE_WIDE_SCALE, 'center', CLOSE_SCALE),
        'classic-in-bottom-left-center': movement('bottomLeft', EDGE_WIDE_SCALE, 'center', CLOSE_SCALE),
        'classic-in-bottom-right-center': movement('bottomRight', EDGE_WIDE_SCALE, 'center', CLOSE_SCALE),
        'classic-out-top-left': movement('topLeft', CLOSE_SCALE, 'center', WIDE_SCALE),
        'classic-out-top-right': movement('topRight', CLOSE_SCALE, 'center', WIDE_SCALE),
        'classic-out-bottom-left': movement('bottomLeft', CLOSE_SCALE, 'center', WIDE_SCALE),
        'classic-out-bottom-right': movement('bottomRight', CLOSE_SCALE, 'center', WIDE_SCALE),
        'classic-out-left-right': movement('left', CLOSE_SCALE, 'right', EDGE_WIDE_SCALE),
        'classic-out-right-left': movement('right', CLOSE_SCALE, 'left', EDGE_WIDE_SCALE),
        'classic-out-bottom-top': movement('bottom', CLOSE_SCALE, 'top', EDGE_WIDE_SCALE),
        'classic-out-top-bottom': movement('top', CLOSE_SCALE, 'bottom', EDGE_WIDE_SCALE),
        'classic-out-bottom-left-top-right': movement('bottomLeft', CLOSE_SCALE, 'topRight', EDGE_WIDE_SCALE),
        'classic-out-bottom-right-top-left': movement('bottomRight', CLOSE_SCALE, 'topLeft', EDGE_WIDE_SCALE),
        'classic-out-center-top-left': movement('center', CLOSE_SCALE, 'topLeft', EDGE_WIDE_SCALE),
        'classic-out-center-top-right': movement('center', CLOSE_SCALE, 'topRight', EDGE_WIDE_SCALE),
        'classic-out-center-bottom-left': movement('center', CLOSE_SCALE, 'bottomLeft', EDGE_WIDE_SCALE),
        'classic-out-center-bottom-right': movement('center', CLOSE_SCALE, 'bottomRight', EDGE_WIDE_SCALE)
    };

    /*
    |--------------------------------------------------------------------------
    | Numeric Option Normalization
    |--------------------------------------------------------------------------
    |
    | Ensures timing and sizing inputs are positive numbers before they are used
    | in animation or layout calculations.
    |
    */
    function toPositiveNumber(value, fallback) {
        var number = Number(value);

        return Number.isFinite(number) && number > 0 ? number : fallback;
    }

    /*
    |--------------------------------------------------------------------------
    | Preset Lookup
    |--------------------------------------------------------------------------
    |
    | Resolves effect names to preset objects and warns once when markup asks for
    | an unknown effect.
    |
    */
    function getPreset(name) {
        if (PRESETS[name]) {
            return PRESETS[name];
        }

        if (!warnedEffects[name]) {
            warnedEffects[name] = true;
            window.console.warn(
                'Unknown Ken Burns effect "' + name + '". Falling back to "' + DEFAULT_EFFECT + '".'
            );
        }

        return PRESETS[DEFAULT_EFFECT];
    }

    /*
    |--------------------------------------------------------------------------
    | Slide Animation Styles
    |--------------------------------------------------------------------------
    |
    | Writes and clears the CSS custom properties that drive an individual
    | slide's transform animation.
    |
    */
    function setStyleProperties(slide, preset, duration) {
        slide.style.setProperty('--kb-from-x', preset.fromX);
        slide.style.setProperty('--kb-from-y', preset.fromY);
        slide.style.setProperty('--kb-from-scale', preset.fromScale);
        slide.style.setProperty('--kb-to-x', preset.toX);
        slide.style.setProperty('--kb-to-y', preset.toY);
        slide.style.setProperty('--kb-to-scale', preset.toScale);
        slide.style.setProperty('--kb-duration', duration + 'ms');
    }

    function removeStyleProperties(slide) {
        slide.style.removeProperty('--kb-from-x');
        slide.style.removeProperty('--kb-from-y');
        slide.style.removeProperty('--kb-from-scale');
        slide.style.removeProperty('--kb-to-x');
        slide.style.removeProperty('--kb-to-y');
        slide.style.removeProperty('--kb-to-scale');
        slide.style.removeProperty('--kb-duration');
    }

    /*
    |--------------------------------------------------------------------------
    | Slide Image Lookup
    |--------------------------------------------------------------------------
    |
    | Returns the image element inside a slide so asset loading and aspect-ratio
    | logic can work with either direct images or picture markup.
    |
    */
    function getSlideImage(slide) {
        return slide.querySelector('img');
    }

    /*
    |--------------------------------------------------------------------------
    | Object Ownership Check
    |--------------------------------------------------------------------------
    |
    | Safely checks pause-reason maps created with a null prototype.
    |
    */
    function hasOwn(object, key) {
        return Object.prototype.hasOwnProperty.call(object, key);
    }

    /*
    |--------------------------------------------------------------------------
    | Slideshow Constructor
    |--------------------------------------------------------------------------
    |
    | Stores instance state, reads options from JavaScript and data attributes,
    | then initializes the slideshow.
    |
    */
    function KenBurnsSlideshow(element, options) {
        this.element = element;
        this.options = options || {};
        this.slides = Array.prototype.slice.call(element.querySelectorAll('.kenburns__slide'));
        this.currentIndex = 0;
        this.timer = null;
        this.fadeTimer = null;
        this.isRunning = false;
        this.pauseReasons = Object.create(null);
        this.remainingTime = 0;
        this.nextDueTime = 0;
        this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
        this.intersectionObserver = null;
        this.fadeDuration = toPositiveNumber(
            this.options.fadeDuration || element.dataset.kbFade,
            DEFAULT_FADE
        );

        this.init();
    }

    /*
    |--------------------------------------------------------------------------
    | Static Preset Export
    |--------------------------------------------------------------------------
    |
    | Exposes the preset map for inspection or programmatic reuse.
    |
    */
    KenBurnsSlideshow.presets = PRESETS;

    /*
    |--------------------------------------------------------------------------
    | Instance Initialization
    |--------------------------------------------------------------------------
    |
    | Prepares the DOM, filters unusable slides, loads the first assets, and
    | starts automatic playback when there is more than one slide.
    |
    */
    KenBurnsSlideshow.prototype.init = function () {
        var firstImage;

        this.element.style.setProperty('--kb-fade-duration', this.fadeDuration + 'ms');
        this.element.classList.add('is-ready');

        this.slides = this.slides.filter(function (slide) {
            return Boolean(getSlideImage(slide));
        });

        firstImage = this.slides.length ? getSlideImage(this.slides[0]) : null;
        this.setAspectRatio(firstImage);

        if (!this.slides.length) {
            return;
        }

        this.loadSlideAssets(this.slides[0]);
        this.preloadUpcomingSlide(0);
        this.bindAutoPause();

        this.slides.forEach(function (slide) {
            slide.classList.remove('is-active', 'is-leaving');
            slide.setAttribute('aria-hidden', 'true');
        });

        this.activateSlide(0);

        if (this.slides.length > 1) {
            this.start();
        }
    };

    /*
    |--------------------------------------------------------------------------
    | Aspect Ratio Setup
    |--------------------------------------------------------------------------
    |
    | Sizes the slideshow from image attributes when available, then falls back
    | to natural image dimensions or the default ratio.
    |
    */
    KenBurnsSlideshow.prototype.setAspectRatio = function (image) {
        var width;
        var height;
        var updateFromNaturalSize;

        if (!image) {
            this.element.style.setProperty('--kb-aspect-ratio', DEFAULT_ASPECT_RATIO);
            return;
        }

        width = toPositiveNumber(image.getAttribute('width'), 0);
        height = toPositiveNumber(image.getAttribute('height'), 0);

        if (width && height) {
            this.element.style.setProperty('--kb-aspect-ratio', width + ' / ' + height);
            return;
        }

        updateFromNaturalSize = function () {
            if (image.naturalWidth && image.naturalHeight) {
                this.element.style.setProperty(
                    '--kb-aspect-ratio',
                    image.naturalWidth + ' / ' + image.naturalHeight
                );
            }
        }.bind(this);

        if (image.complete) {
            updateFromNaturalSize();
        } else {
            this.element.style.setProperty('--kb-aspect-ratio', DEFAULT_ASPECT_RATIO);
            image.addEventListener('load', updateFromNaturalSize, { once: true });
        }
    };

    /*
    |--------------------------------------------------------------------------
    | Slide Timing
    |--------------------------------------------------------------------------
    |
    | Reads a slide-specific interval or instance option and falls back to the
    | default duration.
    |
    */
    KenBurnsSlideshow.prototype.getSlideInterval = function (slide) {
        return toPositiveNumber(
            slide.dataset.kbInterval || this.options.interval,
            DEFAULT_INTERVAL
        );
    };

    /*
    |--------------------------------------------------------------------------
    | Slide Effect Selection
    |--------------------------------------------------------------------------
    |
    | Resolves the movement preset for a slide from markup, instance options, or
    | the default effect.
    |
    */
    KenBurnsSlideshow.prototype.getSlidePreset = function (slide) {
        return getPreset(slide.dataset.kbEffect || this.options.effect || DEFAULT_EFFECT);
    };

    /*
    |--------------------------------------------------------------------------
    | Lazy Asset Loading
    |--------------------------------------------------------------------------
    |
    | Promotes deferred data-src and data-srcset attributes onto the active or
    | preloaded slide.
    |
    */
    KenBurnsSlideshow.prototype.loadSlideAssets = function (slide) {
        var image = getSlideImage(slide);
        var sources = Array.prototype.slice.call(slide.querySelectorAll('source[data-srcset]'));

        if (!image || slide.dataset.kbLoaded === 'true') {
            return;
        }

        sources.forEach(function (source) {
            source.setAttribute('srcset', source.dataset.srcset);
            source.removeAttribute('data-srcset');
        });

        if (image.dataset.src) {
            image.setAttribute('src', image.dataset.src);
            image.removeAttribute('data-src');
        }

        slide.dataset.kbLoaded = 'true';
    };

    /*
    |--------------------------------------------------------------------------
    | Upcoming Slide Preload
    |--------------------------------------------------------------------------
    |
    | Loads the next slide's image sources before it becomes active.
    |
    */
    KenBurnsSlideshow.prototype.preloadUpcomingSlide = function (index) {
        var nextIndex;

        if (this.slides.length < 2) {
            return;
        }

        nextIndex = (index + 1) % this.slides.length;
        this.loadSlideAssets(this.slides[nextIndex]);
    };

    /*
    |--------------------------------------------------------------------------
    | Pause Reason State
    |--------------------------------------------------------------------------
    |
    | Tracks whether any independent pause source is currently holding playback.
    |
    */
    KenBurnsSlideshow.prototype.hasPauseReasons = function () {
        var reason;

        for (reason in this.pauseReasons) {
            if (hasOwn(this.pauseReasons, reason)) {
                return true;
            }
        }

        return false;
    };

    /*
    |--------------------------------------------------------------------------
    | Pause Playback
    |--------------------------------------------------------------------------
    |
    | Records why playback paused, freezes animation styling, and preserves the
    | remaining time before the next slide transition.
    |
    */
    KenBurnsSlideshow.prototype.pauseFor = function (reason) {
        if (this.pauseReasons[reason]) {
            return;
        }

        this.pauseReasons[reason] = true;
        this.remainingTime = this.nextDueTime
            ? Math.max(this.nextDueTime - Date.now(), 0)
            : this.getSlideInterval(this.slides[this.currentIndex]);
        this.element.classList.add('is-paused');
        window.clearTimeout(this.timer);
        this.timer = null;
    };

    /*
    |--------------------------------------------------------------------------
    | Resume Playback
    |--------------------------------------------------------------------------
    |
    | Clears one pause reason and restarts the transition timer only when no
    | other pause source remains active.
    |
    */
    KenBurnsSlideshow.prototype.resumeFor = function (reason) {
        if (!this.pauseReasons[reason]) {
            return;
        }

        delete this.pauseReasons[reason];

        if (this.hasPauseReasons()) {
            return;
        }

        this.element.classList.remove('is-paused');

        if (this.isRunning) {
            this.queueNext(this.remainingTime || this.getSlideInterval(this.slides[this.currentIndex]));
        }
    };

    /*
    |--------------------------------------------------------------------------
    | Page Visibility Handler
    |--------------------------------------------------------------------------
    |
    | Pauses hidden tabs and resumes when the document becomes visible again.
    |
    */
    KenBurnsSlideshow.prototype.handleVisibilityChange = function () {
        if (document.visibilityState === 'hidden') {
            this.pauseFor('visibility');
        } else {
            this.resumeFor('visibility');
        }
    };

    /*
    |--------------------------------------------------------------------------
    | Automatic Pause Binding
    |--------------------------------------------------------------------------
    |
    | Connects visibility and viewport observers so inactive slideshows do not
    | keep advancing offscreen.
    |
    */
    KenBurnsSlideshow.prototype.bindAutoPause = function () {
        document.addEventListener('visibilitychange', this.visibilityChangeHandler);
        this.handleVisibilityChange();

        if ('IntersectionObserver' in window) {
            this.intersectionObserver = new IntersectionObserver(function (entries) {
                var entry = entries[0];

                if (entry && entry.isIntersecting) {
                    this.resumeFor('intersection');
                } else {
                    this.pauseFor('intersection');
                }
            }.bind(this), {
                threshold: 0.01
            });

            this.intersectionObserver.observe(this.element);
        }
    };

    /*
    |--------------------------------------------------------------------------
    | Slide Activation
    |--------------------------------------------------------------------------
    |
    | Loads, configures, and displays the requested slide while resetting its CSS
    | animation timeline for looped playback.
    |
    */
    KenBurnsSlideshow.prototype.activateSlide = function (index) {
        var slide = this.slides[index];
        var interval = this.getSlideInterval(slide);
        var preset = this.getSlidePreset(slide);

        this.loadSlideAssets(slide);
        this.preloadUpcomingSlide(index);
        setStyleProperties(slide, preset, interval + this.fadeDuration);
        slide.classList.remove('is-active', 'is-leaving');
        slide.setAttribute('aria-hidden', 'false');

        // Force a new animation timeline when a slide is reused after looping.
        void slide.offsetWidth;
        slide.classList.add('is-active');
    };

    /*
    |--------------------------------------------------------------------------
    | Refresh Current Settings
    |--------------------------------------------------------------------------
    |
    | Restarts the current slide after its data attributes change, clearing any
    | pending transition while preserving playback and automatic pause state.
    |
    */
    KenBurnsSlideshow.prototype.refresh = function () {
        if (!this.slides.length) {
            return;
        }

        window.clearTimeout(this.timer);
        window.clearTimeout(this.fadeTimer);
        this.timer = null;
        this.fadeTimer = null;
        this.nextDueTime = 0;
        this.remainingTime = this.getSlideInterval(this.slides[this.currentIndex]);

        this.slides.forEach(function (slide) {
            slide.classList.remove('is-leaving');
        });
        this.activateSlide(this.currentIndex);

        if (this.isRunning) {
            this.queueNext(this.remainingTime);
        }
    };

    /*
    |--------------------------------------------------------------------------
    | Transition Queue
    |--------------------------------------------------------------------------
    |
    | Schedules the next slide change unless playback is currently paused.
    |
    */
    KenBurnsSlideshow.prototype.queueNext = function (delay) {
        var currentSlide = this.slides[this.currentIndex];
        var requestedDelay = Number(delay);
        var interval = Number.isFinite(requestedDelay) && requestedDelay >= 0
            ? requestedDelay
            : this.getSlideInterval(currentSlide);

        if (this.hasPauseReasons()) {
            this.remainingTime = interval;
            return;
        }

        window.clearTimeout(this.timer);
        this.nextDueTime = Date.now() + interval;
        this.timer = window.setTimeout(function () {
            this.remainingTime = 0;
            this.nextDueTime = 0;
            this.goTo((this.currentIndex + 1) % this.slides.length);
        }.bind(this), interval);
    };

    /*
    |--------------------------------------------------------------------------
    | Slide Navigation
    |--------------------------------------------------------------------------
    |
    | Crossfades from the current slide to a requested slide and schedules the
    | following transition when playback is running.
    |
    */
    KenBurnsSlideshow.prototype.goTo = function (nextIndex) {
        var previousSlide;

        if (!this.slides.length || nextIndex === this.currentIndex) {
            return;
        }

        window.clearTimeout(this.timer);
        window.clearTimeout(this.fadeTimer);

        previousSlide = this.slides[this.currentIndex];
        previousSlide.classList.add('is-leaving');
        previousSlide.classList.remove('is-active');
        previousSlide.setAttribute('aria-hidden', 'true');

        this.currentIndex = nextIndex;
        this.activateSlide(nextIndex);

        this.fadeTimer = window.setTimeout(function () {
            previousSlide.classList.remove('is-leaving');
        }, this.fadeDuration);

        if (this.isRunning) {
            this.queueNext();
        }
    };

    /*
    |--------------------------------------------------------------------------
    | Start Playback
    |--------------------------------------------------------------------------
    |
    | Begins automatic slide advancement for multi-slide instances.
    |
    */
    KenBurnsSlideshow.prototype.start = function () {
        if (this.isRunning || this.slides.length < 2) {
            return;
        }

        this.isRunning = true;

        if (!this.hasPauseReasons()) {
            this.queueNext(this.remainingTime || this.getSlideInterval(this.slides[this.currentIndex]));
        }
    };

    /*
    |--------------------------------------------------------------------------
    | Stop Playback
    |--------------------------------------------------------------------------
    |
    | Cancels active timers and resets playback scheduling state.
    |
    */
    KenBurnsSlideshow.prototype.stop = function () {
        this.isRunning = false;
        window.clearTimeout(this.timer);
        window.clearTimeout(this.fadeTimer);
        this.timer = null;
        this.fadeTimer = null;
        this.nextDueTime = 0;
        this.remainingTime = 0;
    };

    /*
    |--------------------------------------------------------------------------
    | Destroy Instance
    |--------------------------------------------------------------------------
    |
    | Tears down listeners, observers, classes, styles, and the element reference
    | created during initialization.
    |
    */
    KenBurnsSlideshow.prototype.destroy = function () {
        this.stop();
        document.removeEventListener('visibilitychange', this.visibilityChangeHandler);

        if (this.intersectionObserver) {
            this.intersectionObserver.disconnect();
            this.intersectionObserver = null;
        }

        this.pauseReasons = Object.create(null);
        this.element.classList.remove('is-ready');
        this.element.classList.remove('is-paused');
        this.element.style.removeProperty('--kb-fade-duration');
        this.element.style.removeProperty('--kb-aspect-ratio');
        this.slides.forEach(function (slide) {
            slide.classList.remove('is-active', 'is-leaving');
            slide.removeAttribute('aria-hidden');
            removeStyleProperties(slide);
        });

        if (this.element.kenBurnsSlideshow === this) {
            delete this.element.kenBurnsSlideshow;
        }
    };

    /*
    |--------------------------------------------------------------------------
    | DOM Auto-Initialization
    |--------------------------------------------------------------------------
    |
    | Creates slideshow instances for every .kenburns element that has not
    | already been initialized.
    |
    */
    function initKenBurnsSlideshows() {
        var slideshows = Array.prototype.slice.call(document.querySelectorAll('.kenburns'));

        slideshows.forEach(function (slideshow) {
            if (!slideshow.kenBurnsSlideshow) {
                slideshow.kenBurnsSlideshow = new KenBurnsSlideshow(slideshow);
            }
        });
    }

    /*
    |--------------------------------------------------------------------------
    | Public API
    |--------------------------------------------------------------------------
    |
    | Makes the constructor available on window for manual initialization,
    | inspection, or teardown.
    |
    */
    window.KenBurnsSlideshow = KenBurnsSlideshow;

    /*
    |--------------------------------------------------------------------------
    | Startup
    |--------------------------------------------------------------------------
    |
    | Waits for DOM readiness when needed, otherwise initializes immediately.
    |
    */
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initKenBurnsSlideshows);
    } else {
        initKenBurnsSlideshows();
    }
};
kenBurns();