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=repulsion, 2=attraction
{
    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)
            )
        )
    }

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 sample coordinate before asking the question. If we shift cellUV toward the touch, we're asking "what would I see if I were looking from slightly closer to the touch?" — which makes the dot appear to have moved away.

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

// Shift sample TOWARD touch → dot appears to move AWAY
float2 cellUV = fract(scaled) - dir * (influence * maxDisplacement);

The sign is the key: subtracting moves the sample toward the touch, which displaces the apparent dot position away. This is a coordinate-space illusion, not real movement.

[[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 scaled = uv * float2(cols, rows);
    float2 cellIndex = floor(scaled);
    float2 dotWorld = (cellIndex + 0.5) / float2(cols, rows);

    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 cellUV = fract(scaled) - dir * (influence * maxDisplacement);
    float radius = 0.12;
    float dist = radius - length(cellUV - 0.5);
    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. Coordinate-space illusion — nothing is actually moving.

[[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 scaled = uv * float2(cols, rows);
  float2 cellIndex = floor(scaled);
  float2 dotWorld = (cellIndex + 0.5) / float2(cols, rows);

  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 cellUV = fract(scaled) - dir * (influence * maxDisplacement);
  float radius = 0.12;
  float dist = radius - length(cellUV - 0.5);
  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 sample TOWARD touch → dot appears to move AWAY
float2 cellUV = fract(scaled) - dir * (influence * maxDisplacement);

// Attraction: shift sample AWAY from touch → dot appears to move TOWARD it
float2 cellUV = fract(scaled) + dir * (influence * maxDisplacement);

By shifting the sample away from the touch, we're asking "what would I see from further away?" — which makes the dot appear to have moved closer.

[[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 scaled = uv * float2(cols, rows);
    float2 cellIndex = floor(scaled);
    float2 dotWorld = (cellIndex + 0.5) / float2(cols, rows);

    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 cellUV = fract(scaled) + dir * (influence * maxDisplacement);  // ← flipped
    float radius = 0.12;
    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 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 scaled = uv * float2(cols, rows);
  float2 cellIndex = floor(scaled);
  float2 dotWorld = (cellIndex + 0.5) / float2(cols, rows);

  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 cellUV = fract(scaled) + dir * (influence * maxDisplacement);
  float radius = 0.12;
  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