SandSlider: Building a WebGL Image Slider with Sand Blow Transitions in Three.js

One image disappears. Another image appears.

Sometimes it fades. Sometimes it slides. Sometimes somebody adds a scale animation, blur or parallax and calls it a completely new carousel.

I wanted something different.

Instead of moving the current image away as one solid rectangle, I wanted the image to physically break apart into thousands of tiny particles , as if a strong gust of wind was turning it into sand and carrying it away.

Underneath, the next slide should already be waiting.

That idea eventually became SandSlider — a reusable JavaScript slider built with Three.js, WebGL and custom GLSL shaders .

The transition did not actually start with the slider itself. It evolved from an earlier experiment I published on this blog: Sand Blow Effect for Images Using Three.js, WebGL and Shaders .

That original SandPixel experiment answered the first question:

Can an ordinary image be transformed into thousands of particles and realistically blown away?

SandSlider answers the next one:

Can this effect become the transition engine of a practical, reusable image slider?

The answer is yes.

And this tutorial explains exactly how it works.

 

1. From SandPixel Effect to a Real Slider

The original SandPixel / Sand Blow effect was primarily about the visual experiment.

Take an image, sample its pixels, convert them into particles and use WebGL to animate them.

That already looked interesting, but a production slider needs much more than an effect.

We need multiple images, state management, navigation, autoplay, looping, responsive image fitting, pagination, transition locking, callbacks, cleanup and a way to synchronize normal HTML content with the WebGL animation.

This is where SandSlider differs from the original experiment.

The visual idea remained the same, but the architecture had to grow around it.

The resulting component supports image-only slides as well as slides containing metadata such as a title, description and CTA.

A slide can therefore be as simple as:

'./images/photo.webp'
 

or contain additional content:

     {
    src: './images/photo.webp',
    header: 'SandSlider',
    subheader: 'WebGL transition powered by thousands of particles.',
    cta: {
        label: 'Learn more',
        href: '#details'
    }
} 
 

This separation is important.

Three.js handles the image transition. HTML handles the content.

The two layers communicate through slider callbacks.

 

2. What We Are Building

The final result behaves like a normal slider from the user's perspective.

There are previous and next buttons, pagination dots, autoplay and looping.

But the transition itself is completely different.

When the slider moves from image A to image B, image B is first placed underneath image A. Image A then begins to erode from one side while thousands of colored particles detach from it and accelerate away.

For a short moment, three visual systems exist simultaneously:

  1. the sharp current image,

  2. the particle representation of the current image,

  3. the next image underneath.

That three-layer architecture is the key to the whole effect.

Without this separation, the effect would be much harder to control.

 

3. Project Structure

A minimal project can be extremely small:

     sand-slider/
│
├── index.html
├── sandslider.js
│
└── images/
    ├── slide-01.webp
    ├── slide-02.webp
    ├── slide-03.webp
    └── slide-04.webp
   
 

There is no build process required in the demo version.

Three.js is loaded as an ES module through an import map:

<script type="importmap">
{
    "imports": {
        "three": "https://cdn.jsdelivr.net/npm/This email address is being protected from spambots. You need JavaScript enabled to view it..0/build/three.module.js"
    }
}
</script>
 

Then SandSlider can simply import it:

import * as THREE from 'three';
 

and the application imports the slider itself:

 
import { SandSlider } from './sandslider.js';
 

4. Do Not Run It Through file://

There is one detail that can easily cause confusion when testing the project.

Do not double-click index.html and run it directly from the filesystem.

The slider loads images using fetch() , so the project should be served through HTTP.

For example:

python3 -m http.server 5173
 

Then open:

http://127.0.0.1:5173/
 

The demo included with the project even checks location.protocol and displays an explicit warning when somebody attempts to launch it through file:// .

If images come from another domain, remember that the remote server also needs to allow the browser to fetch those assets through CORS.

 

5. Minimal HTML

The WebGL renderer only needs a container.

<div id="stage"></div>
 

Give it dimensions:

html,
body {
    margin: 0;
    width: 100%;
    height: 100%;
}

#stage {
    position: absolute;
    inset: 0;
}
 

The slider automatically inserts its Three.js canvas into this container.

It also creates its own navigation interface when navigation or pagination is enabled.

The actual WebGL component therefore does not require complicated slider markup.

This is intentional.

Instead of authoring twenty nested elements for every slide, we provide the component with data and let JavaScript build the rendering system.

 

6. Defining Slides

Let's create the content:

const SLIDES = [
    {
        src: './images/sandpixel.webp',
        header: 'SandPixel',
        subheader: 'The image breaks into sand and reveals the next slide.',
        cta: {
            label: 'See the effect',
            href: '#demo'
        }
    },
    {
        src: './images/project-two.webp',
        header: 'WebGL Motion',
        subheader: 'Thousands of particles rendered by the GPU.'
    },
    {
        src: './images/project-three.webp',
        header: 'Three.js',
        subheader: 'A reusable transition engine instead of a single demo.'
    }
];
 

src is mandatory.

The remaining properties are optional.

SandSlider also accepts simple image URL strings and normalizes several convenient aliases such as title , text , description , ctaLabel , ctaHref and ctaTarget .

This makes the component easier to integrate with data coming from a CMS or API.

 

7. Creating SandSlider

Basic initialization looks like this:

const slider = await SandSlider.create(
    document.getElementById('stage'),
    {
        images: SLIDES,
        autoplay: true,
        loop: true,
        nav: true,
        pagination: true
    }
);
 

Notice that create() is asynchronous.

This matters because SandSlider loads and prepares all image resources before starting the animation loop.

Internally it loads every image, generates its particle grid, creates a Three.js texture and stores the prepared slide in memory.

For applications that display a loading overlay, this gives us a convenient point at which it can be removed:

const slider = await SandSlider.create(stage, options);

loader.classList.add('hidden');
 

8. Configuration Reference

The component exposes the most important visual and behavioral parameters directly through its options object.

Option Default Description
images [] Array of image URLs or slide objects
holdMs 3200 Time the finished slide remains visible before autoplay starts the next transition
maxLongSide 900 Maximum long side of the particle grid
releaseSpan 1.9 Time span over which particles are released
background 0x050608 WebGL background color
autoplay true Automatically advance slides
loop true Return to the first slide after the final one
startIndex 0 Initial slide
fit 'contain' Image fitting mode: contain or cover
fitPadding object Internal visual spacing around the rendered image
dimUnder true Temporarily darken the incoming image during transition
dimAmount 0.55 Strength of that dimming
dimFadeMs 900 Duration of the dim fade-out
nav true Show previous/next controls
pagination true Show pagination dots
onSlide null Callback fired after a transition settles
onTransition null Callback fired when transition starts
onFrame null Callback fired when the visible image frame changes

One particularly important performance option is:

  
    maxLongSide
  

It controls the resolution used to generate particles.

It does not simply resize the visible HTML image.

It controls how dense the WebGL particle representation becomes.

 

9. Turning an Image into Sand

This is the most interesting part.

Before an image can disappear as sand, SandSlider converts it into a grid.

First, the source image is drawn onto an off-screen canvas.

ctx.drawImage(img, 0, 0, width, height);
 

Then its pixel data is extracted:

const { data } = ctx.getImageData(0, 0, width, height);
 

For every sampled pixel the component creates data describing a future particle.

The generated buffers include:

     position
color
baseSpeed
acceleration
vertical speed
phase
release delay 
 

The original RGB values become the color of the corresponding particle.

The effect is therefore not a generic cloud placed over the image.

The particles actually inherit their colors from the image itself.

That is why the disintegrating cloud still visually resembles the image while it is being destroyed.

 

10. Why maxLongSide Matters

Imagine processing a 1920 × 1080 image pixel by pixel.

That would create more than two million potential points.

Do that for several slides and the effect becomes unnecessarily expensive very quickly.

SandSlider therefore scales the image used for particle generation so that its longest side does not exceed maxLongSide .

For example:

maxLongSide: 720
 

reduces the amount of data substantially while the visual effect can still look dense enough on a normal screen.

This gives us a practical quality/performance control.

Higher values produce a denser disintegration.

Lower values reduce GPU and memory requirements.

The ideal setting depends on the dimensions of the slider and the devices you expect visitors to use.

 

11. Creating an Uneven Wind Front

If every particle started moving at the same moment, the result would look like a rectangular cloud suddenly sliding sideways.

That is not what we want.

The particle release needs an irregular front.

SandSlider calculates a release delay based primarily on the horizontal position of a pixel, but modifies it using its vertical position and a small random component.

Conceptually:

releaseDelay =
    horizontalPosition *
    releaseSpan *
    verticalWindProfile +
    randomness;
 

The result is a transition front that bends and changes speed instead of moving across the photograph as a perfectly straight vertical line.

This detail matters enormously.

Natural-looking motion usually comes from deliberately breaking mathematical perfection.

 

12. The Sharp Image Must Disappear Too

Moving the particles is only half of the problem.

Remember that underneath our particle system there is still a normal, sharp image.

If we simply start moving particles while leaving that image untouched, nothing would appear to disintegrate.

We would only see particles flying away from an image that remains visible.

That is why the top image uses its own shader.

The fragment shader calculates approximately the same release front as the particles and then uses:

discard;
 

for fragments that should already have disappeared.

In simplified form:

if (transitionHasReachedThisFragment) {
    discard;
}
This makes parts of the original sharp image vanish while corresponding particles fly away.

The transition therefore remains visually coherent.

 

13. The Particle Shader

The particles themselves use another custom ShaderMaterial .

Their movement happens primarily in the vertex shader.

Once a particle reaches its release time:

float flyAge = uBlowAge - aRelease;
 

its X position begins accelerating:

pos.x =
    home.x -
    (
        aBaseSpeed * flyAge +
        0.5 * aAccel * flyAge * flyAge
    );
This gives us something much more convincing than constant-speed movement.

There is also vertical motion:

pos.y =
    home.y +
    aSpeedY * flyAge +
    sin(...) * ...;
 

and depth variation:

pos.z =
    home.z +
    cos(...) * ...;
 

Consequently, particles do not simply travel in one flat horizontal line.

They spread vertically and slightly through the Z axis, producing a turbulent cloud.

Each point carries its original image color, so the fragment shader can remain remarkably small:

gl_FragColor = vec4(vColor, 1.0);
 

The complexity lives mostly in the geometry data and vertex movement.

 

14. Revealing the Next Slide

While the current image is disappearing, the next image already exists underneath it.

When _startBlow() begins, SandSlider binds the destination slide to the under-layer:

this._bindUnder(next);
this.underPlate.visible = true;
 

The upper image starts disappearing and the particle layer becomes active.

This means the new image does not suddenly replace the old one at the end.

It is physically revealed through the disappearing pixels of the previous image .

That distinction gives the transition much more visual continuity.

The next image may initially be darkened using dimUnder .

As more of the previous image disappears, the under-layer gradually returns to full brightness.

The implementation deliberately finishes the main transition before every last sand particle has necessarily disappeared from the screen.

This allows the new slide to settle while the remaining particle trail completes visually in the background.

 

15. Slider State: IDLE and BLOW

Visually complex components often become unstable because too many things can happen simultaneously.

SandSlider keeps the basic state intentionally simple.

There are two main modes:

const MODE_IDLE = 0;
const MODE_BLOW = 1;
 

When the slider is idle, navigation may start another transition.

When a blow transition is active, another one cannot start.

For example:

next() {
    if (this.mode !== MODE_IDLE || this.disposed) {
        return false;
    }

    ...
}
 

The same protection exists for previous-slide and direct-index navigation.

This prevents users from rapidly stacking multiple expensive WebGL transitions on top of one another and corrupting the active slide state.

 

16. Navigation API

Once a SandSlider instance exists, we can control it programmatically.

slider.next();
slider.prev();
slider.goTo(3);
slider.pause();
slider.play();
slider.blow();
 

We can also inspect its current state:

slider.currentIndex;
slider.currentSlide;
slider.length;
slider.busy;
 

blow() is effectively a semantic shortcut for triggering the next sand transition.

That makes the component useful beyond standard arrows and dots.

A transition could be triggered by another interface element, scroll interaction, a presentation controller or some external application state.

 

17. Navigation and Pagination

When:

nav: true
 

SandSlider generates previous and next buttons.

When:

pagination: true
 

it generates one dot for every slide.

During an active transition these controls are disabled.

After the new slide settles, they become active again.

This is another small but important detail.

Allowing users to initiate additional navigation while hundreds of thousands of points may still be changing state is an easy way to introduce race conditions.

 

18. Making the WebGL Image Responsive

The canvas itself fills the entire slider container.

The image does not necessarily have to.

SandSlider calculates the camera position based on the aspect ratio of the image, container dimensions, fitting mode and configured padding.

Two modes are available:

fit: 'contain'
 

and:

fit: 'cover'
 

contain makes the complete image visible.

cover prioritizes filling the available frame.

fitPadding can reserve space around the projected image:

fitPadding: {
    top: 24,
    right: 24,
    bottom: 48,
    left: 24
}
 

The interesting detail is that this padding applies to the visual image frame, while the WebGL canvas remains full size.

So the sand is still able to fly outside the rectangular image area.

That is exactly what we want.

Cropping the WebGL canvas to the image frame would destroy much of the effect.

 

19. ResizeObserver and Frame Tracking

The component listens to normal window resize events, but it also uses ResizeObserver when available.

That matters when the slider lives inside a dynamic layout.

A container can change size without the browser window itself changing size — for example because a sidebar opens, a grid changes columns or another component modifies the page.

The slider recalculates the camera, rendered size, particle size and UI position whenever necessary.

There is also:

slider.getFrameRect()
 

which returns the projected image frame in CSS pixels.

And the onFrame callback can receive the same information.

This becomes particularly useful when we want to position HTML content exactly over the WebGL image.

 

20. Adding Normal HTML Content

Rendering text inside WebGL would be possible.

It would also make an ordinary website unnecessarily complicated.

Titles, descriptions and CTA links belong in HTML.

In the demo I use:

<div class="slide-copy" id="slideCopy" hidden>
    <h2 class="slide-header" id="slideHeader"></h2>
    <p class="slide-subheader" id="slideSubheader"></p>
    <a class="slide-cta" id="slideCta" hidden></a>
</div>
 

The JavaScript reads metadata from the currently selected slide and updates these elements.

This gives us normal selectable text, normal links, normal CSS styling and much easier responsive design.

The WebGL component remains responsible only for what WebGL is actually good at: rendering the image transition.

 

21. Synchronizing Content with the Transition

The important hook here is:

onTransition
 

The demo uses it like this:

onTransition: (
    _from,
    _to,
    _fromSlide,
    toSlide
) => {
    void setSlideCopy(
        toSlide,
        { animate: true }
    );
}
 

When the transition begins, the existing copy animates out.

The destination slide metadata is then inserted and the new copy animates back in.

The heading, subheading and CTA can use slightly different animation delays, producing a staggered entrance after the particle transition begins.

That makes WebGL motion and regular DOM animation feel like one coordinated sequence.

 

22. Full Initialization Example

A practical configuration can look like this:

const slider = await SandSlider.create(
    document.getElementById('stage'),
    {
        images: SLIDES,

        holdMs: 3500,
        maxLongSide: 720,

        autoplay: true,
        loop: true,

        fit: 'contain',

        nav: true,
        pagination: true,

        fitPadding: {
            top: 24,
            right: 24,
            bottom: 48,
            left: 24
        },

        background: 0x050608,

        onTransition: (
            fromIndex,
            toIndex,
            fromSlide,
            toSlide
        ) => {
            console.log(
                'Changing from',
                fromIndex,
                'to',
                toIndex
            );

            updateContent(toSlide);
        },

        onSlide: (
            index,
            slide
        ) => {
            console.log(
                'Transition finished:',
                index,
                slide
            );
        }
    }
);
 

23. The Animation Loop

The slider uses requestAnimationFrame() as its central rendering loop.

During idle state it decreases the autoplay timer.

During transition state it increments blowAge and passes that value into both the particle shader and the disappearing image shader.

Conceptually:

if (idle) {
    updateAutoplay();
}

if (blowing) {
    updateTransitionAge();
    updateParticles();
    updateImageErosion();
    updateUnderImage();
}

renderer.render(scene, camera);
 

The same transition clock therefore controls both sides of the illusion:

the image disappears and its pixels fly away according to the same timeline.

That synchronization is one of the most important architectural ideas in the project.

 

24. Performance: Why This Belongs on the GPU

Animating several hundred thousand individual DOM elements would obviously be a terrible idea.

The browser would need to manage an enormous amount of layout and element state.

Three.js lets us approach the problem differently.

Particle properties are packed into BufferGeometry attributes and sent to the GPU.

The JavaScript code does not manually update the position of every particle on every frame.

Instead it updates uniforms such as transition age and time.

The vertex shader then calculates the resulting particle position in parallel on the GPU.

This is exactly the kind of workload shaders are good at.

JavaScript orchestrates the state.

WebGL performs the visual work.

 

25. Cleaning Everything Up

Reusable WebGL components also need a proper destruction path.

SandSlider exposes:

slider.dispose();
 

This stops requestAnimationFrame , removes listeners, disconnects ResizeObserver , disposes textures, geometry, shader materials and the renderer, removes the generated canvas and removes slider UI.

That becomes especially important in SPAs or applications where components can be mounted and unmounted repeatedly.

Without cleanup, WebGL-heavy components can leave significant resources behind.

 

26. How SandSlider Differs from a Traditional Carousel

A traditional slider generally thinks in rectangles.

SandSlider thinks in states of an image .

The outgoing slide exists first as a complete image.

Then it exists simultaneously as an eroding image and a particle field.

Finally only the particles remain while the new image is already visible underneath.

The slider transition is therefore not:

slide A → slide B
 

but closer to:

A
↓
A + particle representation of A + B underneath
↓
particles of A + B
↓
B
 

That is the real architectural difference.

 

27. Another Approach: Triple Slider

SandSlider is not my first attempt at moving beyond the classic one-track carousel.

Another slider I created and described here on the blog is Triple Slider: Mastering Lane-Based UI Motion .

Triple Slider solves the problem from an entirely different direction.

Instead of destroying the outgoing image into particles, it approaches the interface as a coordinated motion system composed of separate visual areas.

The comparison is interesting because both projects start from essentially the same question:

How can we make a slider feel like a designed motion system instead of another generic carousel?

Triple Slider answers with coordinated lane-based motion.

SandSlider answers with particles, WebGL and shaders.

If you are interested in experimental slider interfaces rather than only the particle effect itself, the Triple Slider tutorial is a good companion to this article.

 

28. Where SandSlider Can Be Used

The effect is deliberately dramatic, so I would not use it for every product carousel containing twenty almost identical photographs.

It works best where the transition itself contributes to the visual identity of the page:

  • hero sections,

  • portfolio presentations,

  • creative agency websites,

  • game or entertainment projects,

  • campaign pages,

  • product launches,

  • experimental editorial layouts.

It can also work particularly well when each slide represents a distinct project or story rather than merely another photograph from the same gallery.

The transition needs enough time and visual space to breathe.

 

29. Ideas for Further Development

The current architecture creates a useful foundation for experiments beyond this implementation.

The wind direction could become configurable.

Particle turbulence could react to pointer movement.

Different particle sizes could be generated from image brightness.

A depth map could push selected areas further into the Z axis.

The release front could start from the center, from both sides, diagonally or from a user-selected origin.

Multiple transition presets could share the same slider API.

The important point is that these would be transition-engine changes .

Navigation, autoplay, image normalization, responsive sizing and slide state would not need to be reinvented every time.

That is exactly why turning the original SandPixel experiment into a real component was worthwhile.

 

30. Download & Demo

[DOWNLOAD]          |         [DEMO]         |         [GITHUB]

 

31. Final Thoughts

SandSlider started with a visual question.

What would happen if an image did not simply fade away, but physically broke apart?

The first Sand Blow Effect / SandPixel experiment proved that the effect itself could work.

SandSlider takes the next step and turns that experiment into an actual UI component.

There is a useful development lesson here.

Interesting frontend work often starts as something impractical.

A shader.

A strange animation.

A visual prototype.

A WebGL experiment made simply because you want to know whether an idea is possible.

The valuable second step is asking:

Can this experiment become reusable?

In this case that meant separating rendering from content, building predictable state management around the shader, adding responsive behavior, exposing lifecycle callbacks and giving the component a normal slider API.

The result still looks like an experiment.

Underneath, however, it behaves like a component.

And that combination is exactly what I wanted from SandSlider.

If you want to explore the original particle technique first, start with Sand Blow Effect for Images Using Three.js, WebGL and Shaders .

And if you want to see a completely different approach to creating a non-standard slider, continue with Triple Slider: Mastering Lane-Based UI Motion .