Interactive Dotted Background

By the end of this article you will have built a fully interactive dotted background shader in SwiftUI. The dots respond to touch and mouse hover — they glow, repel, or attract based on proximity. More importantly, you'll understand exactly why every line works.

We'll build it step by step, pausing to visualize each concept before writing the shader code.

What we'll cover

  • UV normalisation — making math screen-size independent
  • SDF — describing shapes as mathematical distance functions
  • smoothstep — converting a distance to a soft 0–1 mask (antialiasing)
  • fract — tiling a coordinate space infinitely
  • floor — identifying which tile a pixel belongs to
  • Uniforms — passing live data from Swift to Metal
  • mix — linear interpolation between two values
  • normalize — extracting direction from a vector
  • Coordinate-space displacement — the trick behind repulsion and attraction

What we're building

A grid of dots rendered entirely on the GPU. Each dot reacts to the position of your finger or cursor. Three modes: glow, repulsion, and attraction.

Final result — dotted background

Move your cursor or touch to interact

mode

Step 0 — The shader signature

Before drawing anything, let's understand the SwiftUI shader signature. This is the skeleton every step will follow.

[[stitchable]]
half4 step0_black(float2 position, half4 color,
                  float2 size,       // canvas size in points
                  float2 touch,      // touch position in points
                  float mode,        // 0=glow, 1=attraction, 2=repulsion
                  float intensity)   // 0=idle, 1=active (animated from Swift)
{
    return half4(0.0, 0.0, 0.0, 1.0);
}

Three things to notice:

[[stitchable]] — this attribute makes the function visible to SwiftUI's shader system. Without it, ShaderLibrary can't find your function even if it compiles.

float2 position, half4 color — SwiftUI injects these automatically for every pixel. You never pass them from Swift. position is in points (not pixels), color is the original pixel color of the view underneath.

Your uniforms come aftersize, touch, and mode are passed from Swift. The order must match exactly.

On the Swift side:

Color.black
    .visualEffect { content, geo in
        content.colorEffect(
            ShaderLibrary.step0_black(
                .float2(geo.size.width, geo.size.height),
                .float2(0, 0),
                .float(0),  // mode
                .float(0)   // intensity
            )
        )
    }

visualEffect gives you access to geo.size at layout time — that's why we use it instead of applying .colorEffect directly.

Step 0 — black canvas

Just proving the shader is wired up. Returns solid black.

[[stitchable]]
half4 step0_black(float2 position, half4 color,
                float2 size,
                float2 touch,
                float mode)
{
  return half4(0.0, 0.0, 0.0, 1.0);
}
SwiftUI Metal Shader

Step 1 — Drawing a circle with SDF

A shader can't draw a circle the way UIKit does — there's no "draw circle here" API. Instead, for every pixel, we ask a mathematical question: am I inside or outside the circle?

That question is answered by a Signed Distance Field (SDF).

dist = radius - length(uv - center)
  • Positive → inside the circle
  • Zero → exactly on the edge
  • Negative → outside
float dist = radius - length(uv - center)// + inside, − outside
radius0.25
move cursor over the canvas
dist = -0.0163✗ outside circle (negative)

We convert that distance to a 0–1 mask using smoothstep:

float circle = smoothstep(-eps, eps, dist);

smoothstep(-eps, eps, dist) returns 0 when dist ≤ -eps (outside), 1 when dist ≥ eps (inside), and a smooth S-curve in between. That in-between zone is the antialiased edge — roughly one pixel wide.

smoothstep(-a, +b, dist)// dist = radius - length(uv - center)
a-0.020
b+0.020
← outside (dist negative)circle edge (dist=0)inside (dist positive) →
dist0.0100.844
dist (0.010) is in the antialiased edge zone → result = 0.844

The full step 1 shader:

[[stitchable]]
half4 step1_circle(float2 position, half4 color, float2 size, float2 touch, float mode)
{
    float2 uv = position / size;
    float2 center = float2(0.5, 0.5);
    float radius = 0.05;

    float dist = radius - length(uv - center);
    float circle = smoothstep(-0.005, 0.005, dist);

    return half4(half3(circle), 1.0);
}

Why divide position by size? Position arrives in points (0 to ~400). Dividing by size normalises it to UV space (0 to 1). Now all our math is screen-size independent.

Why does the circle look oval? UV space stretches to fill the rectangle — 1 unit in X and 1 unit in Y cover different physical distances. Fix it before calling length():

float2 delta = (uv - center) * float2(size.x / size.y, 1.0);
float dist = radius - length(delta);

Step 1 — SDF circle

A single circle at the centre of the canvas using a Signed Distance Field.

[[stitchable]]
half4 step1_circle(float2 position, half4 color, float2 size, float2 touch, float mode)
{
  float2 uv = position / size;
  float2 center = float2(0.5, 0.5);
  float radius = 0.05;

  float dist = radius - length(uv - center);
  float circle = smoothstep(-0.005, 0.005, dist);

  return half4(half3(circle), 1.0);
}
SwiftUI Metal Shader

Step 2 — Tiling with fract()

One dot is a start. We need 400. The tool is fract().

fract(x) returns the fractional part of x — everything after the decimal point:

fract(0.7)  = 0.7
fract(1.7)  = 0.7   // strips the 1
fract(3.7)  = 0.7   // strips the 3

It always returns a value in [0, 1). The key property: it repeats. Scale UV by N, then take fract, and you get N tiles each with their own fresh 0–1 coordinate space.

float2 cellUV = fract(uv.x * cols)
uv.x0.370
cols5
uv.x * cols
1.850
floor(...)
1
fract(...)
0.850
dot centre
0.300
pixel at uv.x=0.370 → scaled to 1.850 → cell 1 → fract = 0.850 → dot centre (1+0.5)/5 = 0.300
float2 cellUV = fract(uv * float2(cols, rows));

Now cellUV goes from (0,0) to (1,1) inside each cell. We can run the same SDF circle inside every cell simultaneously. The dot is always at (0.5, 0.5) in cell space.

[[stitchable]]
half4 step2_grid(float2 position, half4 color, float2 size, float2 touch, float mode)
{
    float2 uv = position / size;
    float cols = 20.0;
    float rows = cols * (size.y / size.x);  // keep cells square

    float2 cellUV = fract(uv * float2(cols, rows));
    float radius = 0.15;
    float dist = radius - length(cellUV - 0.5);
    float dot_ = smoothstep(-0.02, 0.02, dist);

    return half4(half3(dot_), 1.0);
}

Why cols * (size.y / size.x) for rows? If we used the same number for both, cells would only be square on a square canvas. Multiplying by the aspect ratio corrects for that.

Step 2 — dot grid

fract() tiles the SDF circle across the whole canvas.

[[stitchable]]
half4 step2_grid(float2 position, half4 color, float2 size, float2 touch, float mode)
{
  float2 uv = position / size;
  float cols = 20.0;
  float rows = cols * (size.y / size.x);

  float2 cellUV = fract(uv * float2(cols, rows));
  float radius = 0.15;
  float dist = radius - length(cellUV - 0.5);
  float dot_ = smoothstep(-0.02, 0.02, dist);

  return half4(half3(dot_), 1.0);
}
SwiftUI Metal Shader

Step 3 — The touch uniform

The grid is static. Now we pass the touch position from Swift to the shader as a uniform.

ShaderLibrary.step3_touch(
    .float2(geo.size.width, geo.size.height),
    .float2(touchPosition.x, touchPosition.y),  // ← live position
    .float(0)
)

To prove the uniform is arriving correctly, we render a small bright indicator dot at the touch position. This also sets up nicely for step 4 — it's the thing that will "push" the grid.

[[stitchable]]
half4 step3_touch(float2 position, half4 color, float2 size, float2 touch, float mode)
{
    float2 uv = position / size;
    float cols = 20.0;
    float rows = cols * (size.y / size.x);

    // Dot grid — same as step 2
    float2 cellUV = fract(uv * float2(cols, rows));
    float dotRadius = 0.15;
    float dotMask = smoothstep(-0.02, 0.02, dotRadius - length(cellUV - 0.5));

    // Touch indicator
    float2 touchUV = touch / size;
    float indicatorRadius = 0.03;
    float indicator = smoothstep(-0.02, 0.02, indicatorRadius - length(uv - touchUV));

    half3 c = half3(dotMask) + half3(0.0, 0.5, 1.0) * indicator;
    return half4(c, 1.0);
}

Note: touch arrives in points, same coordinate space as position. We divide by size to convert it to UV space before comparing with uv.

Step 3 — touch indicator

The blue dot orbits automatically in this preview. On device it follows your touch.

[[stitchable]]
half4 step3_touch(float2 position, half4 color, float2 size, float2 touch, float mode)
{
  float2 uv = position / size;
  float cols = 20.0;
  float rows = cols * (size.y / size.x);

  float2 cellUV = fract(uv * float2(cols, rows));
  float dotRadius = 0.15;
  float dotMask = smoothstep(-0.02, 0.02, dotRadius - length(cellUV - 0.5));

  float2 touchUV = touch / size;
  float indicatorRadius = 0.03;
  float indicator = smoothstep(-0.02, 0.02, indicatorRadius - length(uv - touchUV));

  half3 c = half3(dotMask) + half3(0.0, 0.5, 1.0) * indicator;
  return half4(c, 1.0);
}
SwiftUI Metal Shader

Step 4 — Glow

Now we make the dots react. Dots near the touch should grow and brighten.

The challenge: fract() gives us a pixel's position inside its cell, but we need to know where that cell's dot is in world space — so we can measure how far it is from the touch.

This is where floor() comes in.

float2 scaled = uv * float2(cols, rows);
float2 cellIndex = floor(scaled);              // which cell: (0,0), (1,0), (2,0)...
float2 dotWorld = (cellIndex + 0.5) / float2(cols, rows);  // dot centre in UV space

floor() gives us the integer cell index. Adding 0.5 moves to the centre of that cell. Dividing by the grid dimensions converts back to UV space.

float2 cellIndex = floor(uv * cols)// which cell?
uv.x0.37
uv.y0.55
cols5
cellIndex
(1, 2)
dotWorld
(0.30, 0.50)
length(dotWorld - pixel)
0.0860
pixel → cell (1,2) → dot centre (0.300, 0.500) → distance = 0.0860

With the dot's world position, we can measure the distance to the touch and compute an influence value:

float touchDist = length(dotWorld - touchUV);
float influenceRadius = 0.2;
float influence = 1.0 - smoothstep(0.0, influenceRadius, touchDist);
// influence = 1 at the touch, smoothly fades to 0 beyond influenceRadius

Then use influence to modulate radius and brightness:

float radius = mix(minRadius, maxRadius, influence);
float brightness = mix(0.25, 1.0, influence);

mix(a, b, t) is linear interpolation — a when t=0, b when t=1.

[[stitchable]]
half4 step4_glow(float2 position, half4 color, float2 size, float2 touch, float mode)
{
    float2 uv = position / size;
    float cols = 20.0;
    float rows = cols * (size.y / size.x);

    float2 scaled = uv * float2(cols, rows);
    float2 cellIndex = floor(scaled);
    float2 dotWorld = (cellIndex + 0.5) / float2(cols, rows);

    float2 touchUV = touch / size;
    float touchDist = length(dotWorld - touchUV);
    float influenceRadius = 0.2;
    float influence = 1.0 - smoothstep(0.0, influenceRadius, touchDist);

    float2 cellUV = fract(scaled);
    float minRadius = 0.12;
    float maxRadius = 0.22;
    float radius = mix(minRadius, maxRadius, influence);
    float dist = radius - length(cellUV - 0.5);
    float dotMask = smoothstep(-0.02, 0.02, dist);

    float brightness = mix(0.25, 1.0, influence);
    return half4(half3(brightness * dotMask), 1.0);
}

Step 4 — glow

Dots grow and brighten near the touch. The touch orbits automatically in this preview.

[[stitchable]]
half4 step4_glow(float2 position, half4 color, float2 size, float2 touch, float mode)
{
  float2 uv = position / size;
  float cols = 20.0;
  float rows = cols * (size.y / size.x);

  float2 scaled = uv * float2(cols, rows);
  float2 cellIndex = floor(scaled);
  float2 dotWorld = (cellIndex + 0.5) / float2(cols, rows);

  float2 touchUV = touch / size;
  float touchDist = length(dotWorld - touchUV);
  float influenceRadius = 0.2;
  float influence = 1.0 - smoothstep(0.0, influenceRadius, touchDist);

  float2 cellUV = fract(scaled);
  float minRadius = 0.12;
  float maxRadius = 0.22;
  float radius = mix(minRadius, maxRadius, influence);
  float dist = radius - length(cellUV - 0.5);
  float dotMask = smoothstep(-0.02, 0.02, dist);

  float brightness = mix(0.25, 1.0, influence);
  return half4(half3(brightness * dotMask), 1.0);
}
SwiftUI Metal Shader

Step 5 — Repulsion

Dots should push away from the touch. There's a counterintuitive trick here.

There are no dots to move. A dot isn't an object — it's just the result of asking "is this pixel inside a circle?" at every pixel. We can't move it directly.

Instead, we shift the dot center before asking the question. We compute where the dot logically sits in scaled grid space (neighbor + 0.5), then nudge that center away from the touch before measuring distance to it.

float2 awayDir = dotWorld - touchUV;  // direction: touch → dot
float2 dir = normalize(awayDir);

// Shift dot center AWAY from touch → dot appears to have moved away
dotCenter -= dir * (influence * maxDisplacement);
[[stitchable]]
half4 step5_repulsion(float2 position, half4 color, float2 size, float2 touch, float mode)
{
    float2 uv = position / size;
    float cols = 20.0;
    float rows = cols * (size.y / size.x);
    float2 grid = float2(cols, rows);

    float2 scaled = uv * grid;
    float2 cellIndex = floor(scaled);
    float2 dotWorld = (cellIndex + 0.5) / grid;

    float2 touchUV = touch / size;
    float2 awayDir = dotWorld - touchUV;
    float touchDist = length(awayDir);
    float2 dir = touchDist > 0.001 ? normalize(awayDir) : float2(0.0);
    float influenceRadius = 0.2;
    float influence = 1.0 - smoothstep(0.0, influenceRadius, touchDist);
    float maxDisplacement = 0.4;

    float2 dotCenter = cellIndex + 0.5;
    dotCenter -= dir * (influence * maxDisplacement);  // push away

    float radius = 0.12;
    float dist = radius - length(scaled - dotCenter);
    float dotMask = smoothstep(-0.02, 0.02, dist);

    float brightness = mix(0.25, 1.0, 1.0 - influence);
    return half4(half3(brightness * dotMask), 1.0);
}

Why touchDist > 0.001 before normalize? normalize(float2(0,0)) divides by zero — it produces NaN. The guard returns float2(0) when the touch is exactly on a dot centre.

Step 5 — repulsion

Dots push away from the touch. We shift the dot center, not the sample coordinate.

[[stitchable]]
half4 step5_repulsion(float2 position, half4 color, float2 size, float2 touch, float mode)
{
  float2 uv = position / size;
  float cols = 20.0;
  float rows = cols * (size.y / size.x);
  float2 grid = float2(cols, rows);

  float2 scaled = uv * grid;
  float2 cellIndex = floor(scaled);
  float2 dotWorld = (cellIndex + 0.5) / grid;

  float2 touchUV = touch / size;
  float2 awayDir = dotWorld - touchUV;
  float touchDist = length(awayDir);
  float2 dir = touchDist > 0.001 ? normalize(awayDir) : float2(0.0);
  float influenceRadius = 0.2;
  float influence = 1.0 - smoothstep(0.0, influenceRadius, touchDist);
  float maxDisplacement = 0.4;

  float2 dotCenter = cellIndex + 0.5;
  dotCenter -= dir * (influence * maxDisplacement);

  float radius = 0.12;
  float dist = radius - length(scaled - dotCenter);
  float dotMask = smoothstep(-0.02, 0.02, dist);

  float brightness = mix(0.25, 1.0, 1.0 - influence);
  return half4(half3(brightness * dotMask), 1.0);
}
SwiftUI Metal Shader

Step 6 — Attraction

One sign flip. That's the entire difference between repulsion and attraction.

// Repulsion: shift dot center AWAY from touch
dotCenter -= dir * (influence * maxDisplacement);

// Attraction: shift dot center TOWARD touch
dotCenter += dir * (influence * maxDisplacement);
[[stitchable]]
half4 step6_attraction(float2 position, half4 color, float2 size, float2 touch, float mode)
{
    float2 uv = position / size;
    float cols = 20.0;
    float rows = cols * (size.y / size.x);
    float2 grid = float2(cols, rows);

    float2 scaled = uv * grid;
    float2 cellIndex = floor(scaled);
    float2 dotWorld = (cellIndex + 0.5) / grid;

    float2 touchUV = touch / size;
    float2 awayDir = dotWorld - touchUV;
    float touchDist = length(awayDir);
    float2 dir = touchDist > 0.001 ? normalize(awayDir) : float2(0.0);
    float influenceRadius = 0.2;
    float influence = 1.0 - smoothstep(0.0, influenceRadius, touchDist);
    float maxDisplacement = 0.4;

    float2 dotCenter = cellIndex + 0.5;
    dotCenter += dir * (influence * maxDisplacement);  // ← flipped

    float radius = 0.12;
    float dist = radius - length(scaled - dotCenter);
    float dotMask = smoothstep(-0.02, 0.02, dist);

    float brightness = mix(0.25, 1.0, influence);
    return half4(half3(brightness * dotMask), 1.0);
}

Step 6 — attraction

One sign flip from repulsion. Dots collapse toward the touch.

[[stitchable]]
half4 step6_attraction(float2 position, half4 color, float2 size, float2 touch, float mode)
{
  float2 uv = position / size;
  float cols = 20.0;
  float rows = cols * (size.y / size.x);
  float2 grid = float2(cols, rows);

  float2 scaled = uv * grid;
  float2 cellIndex = floor(scaled);
  float2 dotWorld = (cellIndex + 0.5) / grid;

  float2 touchUV = touch / size;
  float2 awayDir = dotWorld - touchUV;
  float touchDist = length(awayDir);
  float2 dir = touchDist > 0.001 ? normalize(awayDir) : float2(0.0);
  float influenceRadius = 0.2;
  float influence = 1.0 - smoothstep(0.0, influenceRadius, touchDist);
  float maxDisplacement = 0.4;

  float2 dotCenter = cellIndex + 0.5;
  dotCenter += dir * (influence * maxDisplacement);

  float radius = 0.12;
  float dist = radius - length(scaled - dotCenter);
  float dotMask = smoothstep(-0.02, 0.02, dist);

  float brightness = mix(0.25, 1.0, influence);
  return half4(half3(brightness * dotMask), 1.0);
}
SwiftUI Metal Shader

Putting it all together

The step-by-step shaders each check only their own cell's dot. That works for glow, but for repulsion and attraction, a displaced dot can shift into a neighbouring cell's territory — and since we're only checking one cell, it gets cut off at the boundary.

The fix is to check a 3×3 neighbourhood of cells for every pixel. If a neighbour's dot has been displaced into our territory, we'll catch it. We keep the brightest contribution across all 9 candidates.

Two more additions polish the final version:

  • intensity uniform — animated from Swift (0 when idle, 1 on touch/hover), so the effect fades in and out smoothly rather than snapping on.
  • Aspect-corrected touch distancelength(float2(awayDir.x, awayDir.y * aspect)) keeps the influence radius circular even on non-square canvases.
[[stitchable]]
half4 dottedBackground(float2 position, half4 color,
                       float2 size,
                       float2 touch,
                       float mode,
                       float intensity)
{
    float2 uv = position / size;
    float cols = 40.0;
    float rows = cols * (size.y / size.x);
    float2 grid = float2(cols, rows);

    float2 scaled = uv * grid;
    float2 currentCell = floor(scaled);

    float2 touchUV = touch / size;
    float aspect = size.y / size.x;
    float influenceRadius = 0.4;
    float maxDisplacement = 0.6;

    float bestBrightness = 0.0;
    float bestDotMask = 0.0;

    for (int dy = -1; dy <= 1; dy++) {
        for (int dx = -1; dx <= 1; dx++) {
            float2 neighbor = currentCell + float2(dx, dy);
            float2 dotWorld = (neighbor + 0.5) / grid;

            float2 awayDir = dotWorld - touchUV;
            float touchDist = length(float2(awayDir.x, awayDir.y * aspect));
            float2 dir = touchDist > 0.001 ? normalize(awayDir) : float2(0.0);
            float influence = (1.0 - smoothstep(0.0, influenceRadius, touchDist)) * intensity;

            float2 dotCenter = neighbor + 0.5;
            float radius = 0.12;
            float brightness = 0.25;

            if (mode < 0.5) {
                // Glow
                radius = mix(0.12, 0.22, influence);
                brightness = mix(0.25, 1.0, influence);
            } else if (mode < 1.5) {
                // Attraction
                dotCenter += dir * (influence * maxDisplacement);
                brightness = mix(0.25, 1.0, influence);
            } else {
                // Repulsion
                dotCenter -= dir * (influence * maxDisplacement);
                brightness = mix(0.25, 1.0, 1.0 - influence);
            }

            float dist = radius - length(scaled - dotCenter);
            float dotMask = smoothstep(-0.02, 0.02, dist);

            if (dotMask * brightness > bestDotMask * bestBrightness) {
                bestDotMask = dotMask;
                bestBrightness = brightness;
            }
        }
    }

    return half4(half3(bestBrightness * bestDotMask), 1.0);
}

On the Swift side, intensity is driven by a @State variable animated on drag/hover:

Color.black
    .visualEffect { [touchPosition, mode, intensity] content, geo in
        content.colorEffect(
            ShaderLibrary.dottedBackground(
                .float2(geo.size.width, geo.size.height),
                .float2(touchPosition.x, touchPosition.y),
                .float(Float(mode.rawValue)),
                .float(Float(intensity))
            )
        )
    }
    .gesture(
        DragGesture(minimumDistance: 0)
            .onChanged { touchPosition = $0.location; withAnimation(.easeOut(duration: 0.15)) { intensity = 1 } }
            .onEnded { _ in withAnimation(.easeOut(duration: 0.5)) { intensity = 0 } }
    )