Image Carousel from HappyFiles Folders in WordPress

by | Feb 13, 2026

HappyFiles organizes your WordPress media library into folders — but those folders are really just custom taxonomy terms under the hood. That means you can query them with WP_Query or get_posts and build whatever you want with the results. We used that to build a lightweight, continuously scrolling logo carousel that pulls images from a specific folder, supports drag interaction, and lets editors control the display order through a custom meta field.

The Setup

The whole thing is a single shortcode — — that you drop into any page or post. It queries the HappyFiles folder, renders the images in a flex track, duplicates them once for the seamless loop illusion, and handles the scroll animation via requestAnimationFrame. No external JS libraries, no jQuery dependency.

There are two pieces to wire up: the custom meta field (so editors can set image order), and the shortcode itself.

Adding a “Carousel Order” Field to the Media Library

We wanted editors to be able to reorder images without touching code. The simplest approach: a custom meta field on each attachment that takes a number. Lower numbers come first.

add_filter('attachment_fields_to_edit', function($fields, $post) {
    $fields['hf_carousel_order'] = [
        'label' => 'Carousel Order',
        'input' => 'text',
        'value' => get_post_meta($post->ID, 'hf_carousel_order', true),
        'helps' => 'Numeric order for the carousel (lower = first).',
    ];
    return $fields;
}, 10, 2);

add_filter('attachment_fields_to_save', function($post, $attachment) {
    if (isset($attachment['hf_carousel_order'])) {
        update_post_meta($post['ID'], 'hf_carousel_order', sanitize_text_field($attachment['hf_carousel_order']));
    }
    return $post;
}, 10, 2);

This hooks into attachment_fields_to_edit and attachment_fields_to_save. The field shows up in the media detail modal and the full attachment edit screen. Set values like 1, 2, 3. Images without a value get appended at the end, sorted by title.

The Shortcode

The shortcode does a few things: queries two sets of images (ordered and unordered), merges them, renders the track with duplicated slides for looping, and outputs the JS for continuous scrolling and drag support.

add_shortcode('hf_carousel', function($atts) {
    $atts = shortcode_atts([
        'folder'   => 73,
        'duration' => 60,
    ], $atts);

    // First: images that have a carousel order set
    $images = get_posts([
        'post_type'      => 'attachment',
        'post_mime_type' => 'image',
        'posts_per_page' => -1,
        'meta_key'       => 'hf_carousel_order',
        'orderby'        => 'meta_value_num',
        'order'          => 'ASC',
        'tax_query'      => [
            [
                'taxonomy' => 'happyfiles_category',
                'field'    => 'term_id',
                'terms'    => (int) $atts['folder'],
            ],
        ],
    ]);

    // Then: images without the meta, appended at the end
    $with_meta_ids = wp_list_pluck($images, 'ID');
    $unordered = get_posts([
        'post_type'      => 'attachment',
        'post_mime_type' => 'image',
        'posts_per_page' => -1,
        'post__not_in'   => $with_meta_ids ?: [0],
        'orderby'        => 'title',
        'order'          => 'ASC',
        'tax_query'      => [
            [
                'taxonomy' => 'happyfiles_category',
                'field'    => 'term_id',
                'terms'    => (int) $atts['folder'],
            ],
        ],
        'meta_query'     => [
            'relation' => 'OR',
            ['key' => 'hf_carousel_order', 'compare' => 'NOT EXISTS'],
            ['key' => 'hf_carousel_order', 'value' => '', 'compare' => '='],
        ],
    ]);

    $images = array_merge($images, $unordered);

    if (!$images) return '';

    $id  = 'hfc-' . wp_unique_id();
    $dur = (int) $atts['duration'];

    ob_start(); ?>
    <div id="<?= $id ?>" class="hf-carousel" style="overflow:hidden;cursor:grab;">
        <div class="hf-track" style="display:flex;align-items:center;width:max-content;">
            <?php for ($i = 0; $i < 2; $i++) :
                foreach ($images as $image) :
                    $url = wp_get_attachment_image_url($image->ID, 'large');
                    $alt = get_post_meta($image->ID, '_wp_attachment_image_alt', true);
            ?>
                <div class="hf-slide" style="flex-shrink:0;padding:0 8px;">
                    <img src="<?= esc_url($url) ?>"
                         alt="<?= esc_attr($alt) ?>"
                         style="height:70px;width:auto;object-fit:contain;display:block;">
                </div>
            <?php endforeach; endfor; ?>
        </div>
    </div>
    <script>
    (function(){
        var el    = document.getElementById('<?= $id ?>');
        var track = el.querySelector('.hf-track');
        var dur   = <?= $dur ?>;
        var dragging = false, startX = 0, offset = 0, dragOffset = 0;
        var raf, lastTime, speed;

        function calcSpeed() {
            speed = (track.scrollWidth / 2) / (dur * 1000);
        }

        function loop(time) {
            if (!lastTime) lastTime = time;
            if (!dragging) {
                var delta = time - lastTime;
                offset -= speed * delta;
                var half = track.scrollWidth / 2;
                if (Math.abs(offset) >= half) offset += half;
                track.style.transform = 'translateX(' + offset + 'px)';
            }
            lastTime = time;
            raf = requestAnimationFrame(loop);
        }

        function pointerX(e) {
            return e.touches ? e.touches[0].clientX : e.clientX;
        }

        function onStart(e) {
            dragging   = true;
            startX     = pointerX(e);
            dragOffset = offset;
            el.style.cursor = 'grabbing';
        }
        function onMove(e) {
            if (!dragging) return;
            var dx = pointerX(e) - startX;
            offset = dragOffset + dx;
            var half = track.scrollWidth / 2;
            if (offset > 0) offset -= half;
            if (offset < -half) offset += half;
            track.style.transform = 'translateX(' + offset + 'px)';
        }
        function onEnd() {
            if (!dragging) return;
            dragging = false;
            el.style.cursor = 'grab';
            lastTime = null;
        }

        el.addEventListener('mousedown', onStart);
        el.addEventListener('touchstart', onStart, {passive:true});
        window.addEventListener('mousemove', onMove);
        window.addEventListener('touchmove', onMove, {passive:true});
        window.addEventListener('mouseup', onEnd);
        window.addEventListener('touchend', onEnd);
        track.addEventListener('dragstart', function(e){ e.preventDefault(); });

        calcSpeed();
        raf = requestAnimationFrame(loop);
    })();
    </script>
    <?php
    return ob_get_clean();
});

Drop both blocks (the meta field filters and the shortcode) into your theme’s functions.php or a custom plugin file.

How It Works

The continuous scroll uses requestAnimationFrame instead of CSS animations. This gives us smooth, consistent motion and — more importantly — lets us interrupt it cleanly for drag interactions. The speed is calculated from the total track width and the duration parameter, so longer tracks don’t scroll faster just because there are more images.

The seamless loop is handled by rendering the full set of images twice. When the offset reaches the halfway point (the end of the first set), it resets back to zero. Because both halves are identical, there’s no visible jump.

Drag support works on both mouse and touch. While dragging, the animation loop still runs but skips the offset update — when the user releases, it picks back up from wherever they left off. We also kill the default image drag behavior so the browser doesn’t try to ghost-drag the <img> elements.

Each shortcode instance gets its own scoped ID via wp_unique_id(), so you can put multiple carousels on the same page without conflict.

Usage

Basic usage with defaults (folder 73, 60-second loop duration):


Override the folder or speed:


Multiple carousels on the same page work fine:



Adjusting the Image Size

The default is height: 70px with auto width — good for logo strips. If your images are wide landscape-format logos and only one or two show on mobile, constrain the width too. In the slide markup, swap the img style to something like:

height:70px;max-width:120px;width:auto;object-fit:contain;display:block;

Adjust max-width up or down depending on your image aspect ratios and how many you want visible at smaller viewports.

Finding Your Folder ID

Right-click the folder in the HappyFiles sidebar in your Media Library — the ID is in the term edit URL. Or look it up programmatically:

$term = get_term_by('name', 'My Folder Name', 'happyfiles_category');
echo $term->term_id;

The taxonomy slug is happyfiles_category (defined as the HAPPYFILES_TAXONOMY constant — verify in your install if you’re unsure).

Do you have a project in mind?