
Magnet Dots
From syxUI — written for both platforms, not translated between them.
InteractiveA 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
// 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)
}
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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


