Skip to content
Magnet Dots preview
An animated render of the SwiftUI source on this page.

Magnet Dots

From syxUI — written for both platforms, not translated between them.

Interactive

A dot grid that bulges away from your finger, or pulls toward it, and eases back.

Backgrounds · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • background
  • grid
  • dots
  • magnet
  • cursor
  • interactive
  • touch

The actual source

MagnetDots.swift
// Magnet Dots · syxUI · https://syxui.dev/components/magnet-dots
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A dot grid that bulges away from a finger, or pulls toward it, and eases
/// back on release.
///
/// At rest it is indistinguishable from a plain dot lattice — no sim, no
/// per-node state at all. The whole effect is one scalar `envelope`
/// (1 while touched, a critically-damped decay after release) multiplying a
/// stateless per-dot displacement field, which is why this stays an `S`.
struct MagnetDots: View {
    var spacing: CGFloat = 20
    /// How far the field reaches from the touch point.
    var radius: CGFloat = 120
    /// Negative pulls dots in.
    var strength: CGFloat = 18
    var dotSize: CGFloat = 2.6
    var color: Color = Color(red: 0.290, green: 0.290, blue: 0.322)
    /// `"push"`, `"pull"`, or `"swirl"`.
    var mode: String = "push"

    /// Preview override. When set, a synthetic finger travels a Lissajous
    /// path that never lifts, so the loop closes perfectly — see
    /// `previews/Animate.swift`.
    var phase: Double? = nil

    /// When true and untouched for 4s, a synthetic finger drifts the same
    /// Lissajous path so a hero section can demo the effect unattended. Off
    /// by default — it must never animate behind real content uninvited.
    var idleDemo: Bool = false

    private let releaseOmega: Double = 14
    private let releaseFloor: Double = 0.003
    private let idleDelay: Double = 4.0
    private let idlePeriod: Double = 6.0
    private let settleDelay: UInt64 = 600_000_000

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    @State private var touch: CGPoint? = nil
    @State private var isDown = false
    @State private var lastTouchPoint: CGPoint = .zero
    @State private var releasedAt: Date? = nil
    @State private var idleAnchor: Date = Date()
    @State private var isActive = false

    private var pausedNow: Bool {
        if phase != nil || reduceMotion { return true }
        return touch == nil && !isActive && !idleDemo
    }

    var body: some View {
        TimelineView(.animation(paused: pausedNow)) { timeline in
            Canvas { context, size in
                let now = timeline.date.timeIntervalSinceReferenceDate
                let (effectiveTouch, envelope) = fieldSource(now: now, size: size)
                draw(context: context, size: size, touch: effectiveTouch, envelope: envelope)
            }
        }
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { value in
                    guard phase == nil, !reduceMotion else { return }
                    if touch == nil { idleAnchor = Date() }
                    isDown = true
                    touch = value.location
                }
                .onEnded { value in
                    guard phase == nil, !reduceMotion else { return }
                    isDown = false
                    releaseTouch(at: value.location)
                }
        )
        .onContinuousHover(coordinateSpace: .local) { hoverPhase in
            guard phase == nil, !reduceMotion, !isDown else { return }
            switch hoverPhase {
            case .active(let location):
                if touch == nil { idleAnchor = Date() }
                touch = location
            case .ended:
                if let current = touch { releaseTouch(at: current) }
            }
        }
        .accessibilityHidden(true)
    }

    // MARK: - Field source

    /// Picks where the "finger" is and how strongly it acts this frame: the
    /// scripted preview path, a live touch, an idle self-demo, or the
    /// post-release decay — in that priority order.
    private func fieldSource(now: Double, size: CGSize) -> (CGPoint?, Double) {
        if let phase {
            return (lissajousPoint(t: phase, size: size), 1)
        }
        if let touch {
            return (touch, 1)
        }
        let idleElapsed = now - idleAnchor.timeIntervalSinceReferenceDate
        if idleDemo, !reduceMotion, idleElapsed >= idleDelay {
            let t = (idleElapsed - idleDelay) / idlePeriod
            return (lissajousPoint(t: t, size: size), 1)
        }
        if let releasedAt {
            let elapsed = max(now - releasedAt.timeIntervalSinceReferenceDate, 0)
            let decay = (1 + releaseOmega * elapsed) * exp(-releaseOmega * elapsed)
            if decay > releaseFloor {
                return (lastTouchPoint, decay)
            }
        }
        return (nil, 0)
    }

    private func lissajousPoint(t: Double, size: CGSize) -> CGPoint {
        let ux = 0.5 + 0.30 * sin(2 * .pi * t)
        let uy = 0.5 + 0.22 * sin(4 * .pi * t + 0.7)
        return CGPoint(x: ux * size.width, y: uy * size.height)
    }

    // MARK: - Drawing

    private func draw(context: GraphicsContext, size: CGSize, touch: CGPoint?, envelope: Double) {
        guard spacing > 0 else { return }
        let columns = Int(size.width / spacing) + 2
        let rows = Int(size.height / spacing) + 2
        let radiusSq = radius * radius

        // Bucket the bulge into 4 discrete sizes so the whole field draws as
        // 4 fills instead of one per dot (~600 on a full screen).
        var buckets = [Path(), Path(), Path(), Path()]

        for row in 0..<rows {
            for column in 0..<columns {
                let point = CGPoint(x: CGFloat(column) * spacing, y: CGFloat(row) * spacing)
                var renderPoint = point
                var intensity: Double = 0

                if let touch, envelope > releaseFloor {
                    let dx = point.x - touch.x
                    let dy = point.y - touch.y
                    let distSq = dx * dx + dy * dy
                    // Only dots inside `radius` do the sqrt and the falloff.
                    if distSq < radiusSq {
                        let dist = max(sqrt(distSq), 0.001)
                        let normalized = 1 - dist / radius
                        let falloff = normalized * normalized
                        intensity = Double(falloff) * envelope
                        let magnitude = strength * falloff * CGFloat(envelope)
                        let rx = dx / dist
                        let ry = dy / dist
                        switch mode {
                        case "pull":
                            renderPoint.x -= rx * magnitude
                            renderPoint.y -= ry * magnitude
                        case "swirl":
                            renderPoint.x += -ry * magnitude
                            renderPoint.y += rx * magnitude
                        default:
                            renderPoint.x += rx * magnitude
                            renderPoint.y += ry * magnitude
                        }
                    }
                }

                let bucketIndex = min(3, Int(intensity * 4))
                let scale = 1 + 0.9 * (Double(bucketIndex) / 3.0)
                let dotRadius = dotSize * CGFloat(scale) / 2
                buckets[bucketIndex].addEllipse(in: CGRect(
                    x: renderPoint.x - dotRadius, y: renderPoint.y - dotRadius,
                    width: dotRadius * 2, height: dotRadius * 2
                ))
            }
        }

        for bucket in buckets {
            context.fill(bucket, with: .color(color))
        }
    }

    // MARK: - Interaction

    private func releaseTouch(at point: CGPoint) {
        lastTouchPoint = point
        releasedAt = Date()
        idleAnchor = Date()
        touch = nil
        isActive = true
        Task {
            try? await Task.sleep(nanoseconds: settleDelay)
            await MainActor.run {
                if touch == nil { isActive = false }
            }
        }
    }
}

#Preview {
    MagnetDots()
        .frame(width: 420, height: 300)
}
iOS 17 · No dependencies

SwiftUI note. One squared-distance test per dot, and only the dots inside `radius` do the sqrt and the falloff — at the defaults that is ~113 of 860 dots on a full screen, so cost scales with area/spacing^2 for the test and radius^2/spacing^2 for the real work. There is no per-node state: release is one scalar critically-damped envelope multiplying the whole field. Touch-down, drag, and `.onContinuousHover` all write the same `touch` point, so a Mac cursor bulges the grid exactly like a finger, and it ships single-pointer on purpose — the spec's own note is that multi-touch here buys very little. With nothing touching it, `previews/Animate.swift` drives a synthetic finger around a Lissajous path via `phase`, which never lifts and closes the loop exactly. Set `idleDemo` to run the same path for real after 4s of idle time.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27