Magnetic UI
Interfaces that feel a magnet. One small on-device model reads the iPhone's magnetometer and tells your views where the magnet is, whether it is really there, and when it taps the air. Input for any view or library.
The model
One model reads the magnetometer every frame and answers three things. It is input only: pair it with any view or library.
Where the magnet is
- Reads One magnetometer change, plus the magnet's face and strength when you know them.
- Answers Position in screen points, height above the glass, and its own uncertainty, over the screen or beside the phone.
- Tested Real iPhone 15 Pro, median error: 1.2 cm over the screen, 2.0–2.7 cm with the magnet 1.5 cm beside the phone.
Whether it is really there
- Reads The last 15 s of the field, and whether the phone was overloaded since its last zero.
- Answers Presence (0–1) and the phone's own offset, so leftovers are cleared and a resting magnet is kept.
- Tested Real iPhone 15 Pro: a magnet taken away after an overload recognised as gone 96–100 % of the time.
Air taps
- Reads The last 1.28 s at 50 Hz. In a dip the field barely turns; in a sweep or a flick it does.
- Answers A tap, about 0.1 s before the hand is back, with where it landed.
- Tested Real iPhone 15 Pro: 11 of 11 taps over the top half, no false tap in 34 near misses.
Inside: three parts in one file, ~990k weights in half precision, run with Accelerate on every frame.
Built for the phone
Trained on physics and real magnets
The model learned from simulated magnets (sizes from a small ring to a large N52 block, every tilt and face, with sensor noise, drift and leftovers) and from real magnet sessions on an iPhone 15 Pro: a printed mat of known spots and recorded air taps. Those sessions tuned the simulator, retrained the air-tap part and tested every part. No user data.
On the phone, every frame
A 2 MB model in half precision, run with Accelerate. It ships inside your app: no server, no network request at runtime.
No permission prompt
The magnetometer is open to every app, so the magnet works the moment the screen appears.
Any magnet
A fridge magnet works; a small neodymium ring works best. iPhone with iOS 17 or newer, or Android 10 or newer.
Install Magnetic UIInstall, usage and the model are free with an account.
npm install magnetkit
import { useRef, useState } from "react";
import { MagnetCalibration, useMagnetBridge, useMagnetGestures, useMagneticPull } from "magnetkit";
export function Header() {
const ref = useRef<HTMLElement>(null);
const { state, connect, calibrated } = useMagnetBridge();
const [calibrating, setCalibrating] = useState(false);
// 1. Everything marked data-magnetic is pulled toward the magnet and springs back.
useMagneticPull(ref);
// 2. Air taps, pushes, yanks and swipes.
useMagnetGestures((g) => {
if (g.g === "tap") openMenu();
});
return (
<header ref={ref}>
<a data-magnetic href="/">Home</a>
<a data-magnetic href="/docs">Docs</a>
{state !== "open" ? (
<button onClick={connect}>Connect magnet</button>
) : !calibrated ? (
<button onClick={() => setCalibrating(true)}>Calibrate</button>
) : null}
{/* 3. Once per phone and magnet: five rings, about 10 s, kept by the app. */}
<MagnetCalibration open={calibrating} onClose={() => setCalibrating(false)} />
</header>
);
}
import { getMagnetBridge, useMagnet, useMagnetFrame, simulateMagnetWithPointer } from "magnetkit";
// The latest reading as state (one render per animation frame)…
const f = useMagnet();
const at = f?.pos && getMagnetBridge().toClient(f.pos); // page px
// …or every reading without rendering (about 50 a second).
useMagnetFrame((f) => {
dot.style.opacity = String(f.presence);
});
// No phone at hand: the pointer plays the magnet (press and hold).
if (import.meta.env.DEV) simulateMagnetWithPointer();
- Visitors need the Magnet Bridge app on an Android phone: tap Start bridge, then connect from your page. iPhone browsers can't take part.
- Positions arrive in screen dp;
bridge.toClient(p)turns them into page px,useMagneticPulldoes it for you. useMagneticPull(ref, { selector, stiffness, damping, force, reach })tunes the springs; it stays off with reduced motion.- Headless calibration:
calibrate(bridge, { onUpdate })hands you each ring to draw in your own style.
// Package.swift, or in Xcode: File › Add Package Dependencies… › paste the URL (Branch: main) .package(url: "https://github.com/Jakubantalik/Libraries.dev", branch: "main") // …and in your target's dependencies: .product(name: "MagnetKit", package: "Libraries.dev")
Magnet.mfxl from the Model tab and add it to your app target. It loads on first use.
import SwiftUI
import MagnetKit
// 1. Tell the model about your phone and magnet (once, e.g. in your App's init).
let magnet = MagnetSource.shared
magnet.config.locatorEnabled = true
magnet.config.sensorPosition = CGPoint(x: 70, y: 86) // the magnetometer, window points
magnet.config.magnetFace = 1 // north face toward the user
magnet.config.magnetStrength = 6500 // µT·cm³, optional
// 2. Read it every frame.
struct MagnetDot: View {
var body: some View {
TimelineView(.animation) { ctx in
let r = MagnetSource.shared.reading(now: ctx.date.timeIntervalSinceReferenceDate)
if let p = r.screenPosition, r.presence > 0.5 {
Circle().fill(.white).frame(width: 24, height: 24).position(p)
}
}
.ignoresSafeArea()
.onAppear { MagnetSource.shared.attach() }
.onDisappear { MagnetSource.shared.detach() }
}
}
// 3. Air taps, pushes, yanks and swipes.
MagnetSource.shared.addGestureHandler { gesture in
switch gesture {
case .tap(let at): openMenu(at: at) // at: global points
case .pullAway(let speed): flingAway(speed) // cm/s
case .push, .swipe: break
}
}
// A reading is plain values, so anything can follow it:
// a SwiftUI offset, a spring, a shader uniform.
let r = MagnetSource.shared.reading(now: now)
card.offset(x: r.toward.dx * r.pull * 24, y: r.toward.dy * r.pull * 24)
// Metal's liquid rings read it for you:
import MetalFxKit // brings MagnetKit along
MetalFx(variant: .circle, source: .magnet) {
Image(systemName: "arrow.right").frame(width: 52, height: 52)
}
| Field | What it is |
|---|---|
screenPosition | Where the magnet is, global points (smoothed); nil while the locator is off or unsure. |
screenSpread, height | The locator's 1-σ uncertainty in points, and the height above the glass in cm. |
presence | 0–1, from the presence network. Fade your effect with it; MagnetKit already fades pull. |
pull, toward | Closeness on a 0–1 log scale and the unit direction from the sensor toward the magnet. Enough for effects without the locator. |
distance, approachSpeed | Rough distance in cm and how fast it is closing in (cm/s, + toward the phone). |
tooClose | The magnet is close enough to the sensor to magnetise the phone. Ask the user to hold it a little farther. |
calibrating | A new zero is being taken. Show "keep the magnet away". |
.tap(at:)An air tap from the tap network.atis the bottom of the dip, global points, when the locator is on..push(speed:),.pullAway(speed:)The magnet darted toward the phone or was yanked away, in cm/s, read from how fast ln|B| changes over the last 0.12 s..swipe(direction:speed:)The located point crossed the screen faster thangestureSwipeSpeed(1,500 pt/s).
- Keep the magnet about 2 cm or more from the sensor. Above ~2,000 µT it magnetises parts of the phone and leaves a 50–250 µT offset for minutes; the kit catches that leftover when the magnet is pulled away, and the presence network clears what remains.
- Calibration runs on the first
attach(), so keep magnets away then.calibrate()re-zeros on demand; MagSafe accessories, steel tables and laptops shift the zero. - Beside the phone, a magnet held 2–5 cm above the desk is the reliable case. Resting on the desk, its side and distance are sure but its bearing is not, so
deskPrior(on by default) places it beside that edge. - Air taps far from the sensor barely change the field: they are missed rather than guessed.
- The Simulator has no magnetometer: feed
MagnetSource.shared.simulatedFieldfromMagnetSimulator.field(magnet:sensor:northUp:)to drive it with a draggable puck.
npx expo install react-native-magnetkit
// app.json: the plugin copies the model into the iOS and Android apps.
{
"expo": {
"plugins": [["react-native-magnetkit", { "model": "./assets/Magnet.mfxl" }]]
}
}
npx expo run:ios // or run:android; a development build, not Expo Go
Magnet.mfxl from the Model tab into assets/ first. Bare React Native: run npx install-expo-modules, then add Magnet.mfxl to the iOS target's Copy Bundle Resources and to android/app/src/main/assets.
import { useState } from "react";
import { Pressable, Text } from "react-native";
import { Magnet, MagnetCalibration, MagneticView, useMagnetGestures } from "react-native-magnetkit";
export function Home() {
const [calibrating, setCalibrating] = useState(false);
// Air taps, pushes, yanks and swipes.
useMagnetGestures((g) => {
if (g.g === "tap") openMenu(g.at); // at: window points
});
return (
<>
{/* 1. Views the magnet pulls. They collide, and spring back when it leaves. */}
<MagneticView style={styles.chip}><Text>Home</Text></MagneticView>
<MagneticView style={styles.chip}><Text>Search</Text></MagneticView>
{/* 2. Once per phone and magnet: five rings, about 10 s, kept across launches. */}
{!Magnet.getMagnet() && (
<Pressable onPress={() => setCalibrating(true)}><Text>Calibrate</Text></Pressable>
)}
<MagnetCalibration visible={calibrating} onClose={() => setCalibrating(false)} />
</>
);
}
import { useMagnet, useMagnetFrame } from "react-native-magnetkit";
// The latest reading as state (one render per animation frame)…
const f = useMagnet();
// f.pos: the magnet, window points · f.presence 0-1 · f.pull 0-1, f.toward
// f.strength µT · f.distance cm · f.approachSpeed cm/s · f.tooClose · f.calibrating
// …or every reading without rendering, e.g. into an Animated value.
useMagnetFrame((f) => glow.setValue(f.pull * f.presence));
Magnet.hasModel()tells you the model is in the app. Without it readings still come, but nothing is located and there are no air taps.MagneticViewruns its physics on the JS thread and moves through Animated values, so nothing re-renders. Tune it withgetMagneticField(); it stays put with reduced motion.- The sensor runs only while something listens: a hook, a
MagneticVieworMagnet.addFrameListener. Magnet.zero()takes a new zero with no magnet near; MagSafe accessories, steel tables and laptops shift it.- A working example app:
packages/metal-fx/ports/react-native/magnet-examplein the repository.
// Get the code: clone Libraries.dev (or add it as a git submodule) next to your app.
git submodule add https://github.com/Jakubantalik/Libraries.dev libraries-dev
// settings.gradle.kts
include(":magnetkit")
project(":magnetkit").projectDir = file("libraries-dev/packages/metal-fx/ports/android/magnetkit")
// app/build.gradle.kts
dependencies { implementation(project(":magnetkit")) }
Magnet.mfxl from the Model tab and put it in your app's src/main/assets/. It reads the uncalibrated magnetometer, so Android's own calibration can't slowly cancel a nearby magnet. Tested on a Pixel 9a through the Magnet Bridge app.
import dev.libraries.magnetkit.*
// 1. Tell the model about your phone and magnet (once).
val magnet = MagnetSource.get(context)
magnet.config.locatorEnabled = true
magnet.config.screenSize = Pt(411.0, 923.0) // the screen, dp
magnet.config.sensorPosition = Pt(205.0, 70.0) // the magnetometer, dp
magnet.config.magnetFace = 1 // north face toward the user
magnet.config.magnetStrength = 6500.0 // µT·cm³, optional
// 2. Attach while visible, read it every frame.
override fun onStart() { super.onStart(); magnet.attach() }
override fun onStop() { magnet.detach(); super.onStop() }
Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
val r = magnet.reading(frameTimeNanos / 1e9)
r.screenPosition?.takeIf { r.presence > 0.5 }?.let { p -> moveDot(p.x, p.y) } // dp
Choreographer.getInstance().postFrameCallback(this)
}
})
// 3. Air taps, pushes, yanks and swipes (main thread).
magnet.addGestureListener { g ->
when (g) {
is MagnetGesture.Tap -> openMenu(g.at)
is MagnetGesture.PullAway -> flingAway(g.speed)
else -> {}
}
}
ws://127.0.0.1:47613: one JSON frame per reading (position, presence, strength, field) and one per gesture. The live demo on the Preview tab uses it.
Magnet-1.0.zip: the model (Magnet.mfxl, 2 MB, v1.0) and its license. One file, three parts: locator (where the magnet is), presence (is it really there) and tap (air taps). Small fully connected networks in half precision; each part's header lists its inputs, outputs and constants.
import json, struct
import numpy as np
def read_header(b):
assert b[:4] == b"MFXL"
n = struct.unpack("<I", b[4:8])[0]
return json.loads(b[8:8 + n]), 8 + n
data = open("Magnet.mfxl", "rb").read()
model, start = read_header(data) # kind "magnet", version, parts
def load(name):
p = next(p for p in model["parts"] if p["name"] == name)
b = data[start + p["offset"]: start + p["offset"] + p["length"]]
head, off = read_header(b) # inputs, outputs, layers, constants
layers = []
for L in head["layers"]:
n_in, n_out = L["in"], L["out"]
w = np.frombuffer(b, np.float16, n_in * n_out, off).astype(np.float32).reshape(n_out, n_in)
off += w.size * 2
bias = np.frombuffer(b, np.float16, n_out, off).astype(np.float32)
off += bias.size * 2
layers.append((w, bias, L.get("act") == "silu"))
return head, layers
def run(layers, x):
for w, bias, silu in layers:
x = w @ x + bias
if silu:
x = x / (1 + np.exp(-x)) # SiLU
return x
# Where the magnet is, from one field change (µT, device axes).
head, locator = load("locator")
def locate(b, strength=None, face=None):
b = np.asarray(b, np.float32)
nb = np.linalg.norm(b)
u = b / nb
if face is not None: u = u * face
elif u[2] < 0: u = -u
x = np.array([u[0], u[1], u[2],
(np.log(nb) - head["logbMid"]) / 2,
(np.log(strength) - head["logkMid"]) / 1.5 if strength else 0,
1.0 if strength else 0.0, 1.0 if face is not None else 0.0], np.float32)
y = run(locator, x)
d = y[:3] / np.linalg.norm(y[:3])
r = np.exp(y[3] + head["logrMid"]) # cm
return d * r # magnet relative to the sensor, cm
# A magnet 2 cm right, 3 cm below and 1.5 cm above the sensor, north face up:
print(locate([64.4, -96.6, -60.8], strength=6500, face=1)) # ≈ [1.94, -3.02, 1.58]
Magnet.mfxl"MFXL", a little-endian UInt32 header length, a JSON header (kind"magnet",version,partswith offsets and lengths), then each part as a complete MFXL file of its own.partThe same layout, its header listinglayers(in,out,act), then each layer's weights (out × in, row-major) and biases as float16. Activation SiLU, none on the last layer.locator7 inputs (field direction, log strength, magnet strength and face when known) → 6 outputs: direction, log distance and its uncertainty. Device axes: x right, y toward the top edge, z out of the screen; cm from the magnetometer.presenceThe last 15 s of the field (1.28 s at 50 Hz in detail, a 10 Hz magnitude history, the overload peak) → presence logit and the phone's own offset.tapThe last 1.28 s at 50 Hz → "a tap just finished" logit.
MagnetPresence and MagnetTap (Swift and Kotlin). Trained on simulated magnet physics and real magnet sessions on an iPhone 15 Pro, which tuned the simulator and test every part.
Two licenses
- The model
Magnet.mfxl, its weights, and any copy, conversion, quantization or fine-tune of them: the Libraries.dev Model License. - The code MagnetKit for iOS and Android,
magnetkitfor React,react-native-magnetkitand the loader on this page: MIT, free for any use.
Free, no plan needed
- Development, testing and evaluation.
- Prototypes, demos and internal tools that are not released to the public.
- Personal and non-commercial projects, open-source projects that earn no revenue included, with the model kept inside the app.
Commercial release
A product that contains or loads the model, made available outside your own team (App Store, a public TestFlight, a website, a delivery to a client), when it is sold, earns revenue (ads, subscriptions, in-app purchases), is built for a client or is used in a business. It needs a plan held by you:
- Pro One person, such as a solo builder or freelancer: unlimited products, client work included. Pro Lifetime covers the same for 12 months from purchase, every model included; after that, shipped builds stay live and a new release with a model needs a subscription (bought before 27 October 2026: every model, no end date).
- Business A company or organization: unlimited products.
- Enterprise Terms set by a separate written agreement, for example custom training or volume deals. jakub@libraries.dev
Builds released while your plan was active may stay available after it ends. A new release, including an update that ships the model, needs an active plan.
Conditions
- Ship the model only inside the product that uses it, such as your app bundle. Never on its own: no public repository, model hub or download link.
- Don't sublicense, sell, rent or lend the model.
- Don't use the model, its weights or its outputs to train, fine-tune, distil or evaluate a model that does the same job, other than for your own product under this license.
- Keep the license and copyright notice that come with it. Every download includes
LICENSE-MODEL.md.
Good to know
- Ownership The model stays Libraries.dev's. Your products, code and data stay yours.
- Breaches The license ends if it is broken, and comes back if the breach is fixed within 30 days.
- No warranty The model estimates from sensor readings and can be wrong. Don't rely on it where an error could cause injury, damage or financial loss.
- Earlier copies
Magnet.mfxlv1.0 was in the public repository under MIT from 20 to 24 October 2026; copies obtained then stay MIT. Every copy since, and every later version, is under the Model License only.
This is a summary. The full Model License is what applies; plans are on Pricing.
Add MagnetKit from Libraries.dev to my SwiftUI app: views that react to a
magnet near the iPhone, using its on-device magnet model.
Install (Swift Package Manager, iOS 17+):
.package(url: "https://github.com/Jakubantalik/Libraries.dev", branch: "main")
.product(name: "MagnetKit", package: "Libraries.dev")
Setup (once):
import MagnetKit
let magnet = MagnetSource.shared
magnet.config.locatorEnabled = true
magnet.config.sensorPosition = CGPoint(x: 70, y: 86) // magnetometer spot, window points (iPhone 15 Pro)
magnet.config.magnetFace = 1 // +1 north toward the user, -1 south
magnet.config.magnetStrength = 6500 // µT·cm³, optional
MagnetSource.shared.attach() // on appear; detach() on disappear
Every frame (inside a TimelineView(.animation)):
let r = MagnetSource.shared.reading(now: ctx.date.timeIntervalSinceReferenceDate)
- r.screenPosition: CGPoint?: the magnet, global points (locator on)
- r.screenSpread, r.height: uncertainty (pt) and height above the glass (cm)
- r.presence: 0-1: is a magnet really there
- r.pull (0-1), r.toward (unit vector from the sensor): work without the locator
- r.distance (cm), r.approachSpeed (cm/s), r.tooClose, r.calibrating
Gestures:
MagnetSource.shared.addGestureHandler { g in
switch g {
case .tap(let at): break // air tap: magnet dipped toward the glass and back
case .pullAway(let speed): break // yanked away, cm/s
case .push(let speed): break // darted toward the phone, cm/s
case .swipe(let dir, let speed): break
}
}
The model (MagnetModel, Magnet.mfxl v1.0, 2 MB; trained on magnet
physics and real magnet sessions, run on device with Accelerate, no permission prompt) answers
where the magnet is, whether it is really there, and air taps. It is NOT in the
package: ask the user to download Magnet.mfxl from https://libraries.dev/magnetic.html
(Model tab, free account) and add it to the app target (iOS) or src/main/assets
(Android); MagnetModel.bundled loads it from there. Never commit it to a public
repo. Model License: free for development and non-commercial apps; a commercial
release needs Libraries.dev Pro or Business.
Config: locatorMode .network (default) or .dipole (physics; needs magnetFace),
deskPrior, presenceModel, tapModel, gestureSwipeSpeed.
Pairs with any library: a reading is plain values. With MetalFxKit,
MetalFx(variant: .circle, source: .magnet) bends liquid-metal rings toward it.
Keep the magnet 2 cm+ from the sensor (r.tooClose), calibrate with no magnet
near, and in the Simulator feed MagnetSource.shared.simulatedField from
MagnetSimulator.field(magnet:sensor:northUp:).
Docs: https://libraries.dev/magnetic.html
Add Magnetic UI from Libraries.dev to my React website: elements that a real
magnet beside an Android phone pulls, through the on-device magnet model.
Install: npm install magnetkit (React 18+)
How it works: browsers can't read the raw magnetometer, so the Magnet Bridge
app (Android 10+, https://libraries.dev/downloads/MagnetBridge.apk) runs the
model on the phone and serves readings to allowed sites over
ws://127.0.0.1:47613. The site ships no model file. Visitors open the app, tap
Start bridge, then connect from the page (Chrome asks once to allow local
network access). iPhone browsers can't take part.
import { useRef, useState } from "react";
import { MagnetCalibration, useMagnetBridge, useMagnetGestures, useMagneticPull } from "magnetkit";
const ref = useRef<HTMLElement>(null);
const { state, connect, calibrated, bridge } = useMagnetBridge();
useMagneticPull(ref); // elements with data-magnetic inside ref get pulled and spring back
useMagnetGestures((g) => {}); // g.g: "tap" | "push" | "pullAway" | "swipe"
<MagnetCalibration open={calibrating} onClose={(ok) => setCalibrating(false)} /> // once per phone and magnet
Show a "Connect magnet" button while state !== "open" (connect() opens the
socket), then "Calibrate" while !calibrated.
Readings: useMagnet() (state, one render per frame) or useMagnetFrame(cb)
(every reading, ~50/s, no render). Fields: pos (screen dp, null when unsure;
bridge.toClient(pos) gives page px), spread, height (cm), presence (0-1, is a
magnet really there), pull (0-1), strength (µT), delta (µT, device axes),
tooClose, calibrating.
Tuning: useMagneticPull(ref, { selector, enabled, stiffness, damping, force, reach }).
Off with prefers-reduced-motion. Headless calibration: calibrate(bridge, { onUpdate }).
Development without a phone: simulateMagnetWithPointer() makes the pointer
the magnet (press and hold).
License: the kit is MIT; the model runs inside the Magnet Bridge app.
Docs: https://libraries.dev/magnetic
Add Magnetic UI from Libraries.dev to my React Native (Expo) app: views that a
real magnet beside the phone pulls, through the on-device magnet model
(MagnetKit for iOS and Android, as an Expo module).
Install:
npx expo install react-native-magnetkit
app.json: "plugins": [["react-native-magnetkit", { "model": "./assets/Magnet.mfxl" }]]
Then a development build (npx expo run:ios / run:android), not Expo Go.
Bare React Native: npx install-expo-modules first, then add Magnet.mfxl to the
iOS target's Copy Bundle Resources and android/app/src/main/assets.
The model (Magnet.mfxl v1.0, 2 MB) is NOT in the npm package: ask the user to
download it from https://libraries.dev/magnetic (Model tab, free account) into
assets/. Never commit it to a public repo. Model License: free for development
and non-commercial apps; a commercial release needs Libraries.dev Pro or
Business. Magnet.hasModel() checks it is there.
import { Magnet, MagnetCalibration, MagneticView, useMagnet, useMagnetFrame, useMagnetGestures } from "react-native-magnetkit";
<MagneticView style={...}>...</MagneticView> // a View the magnet pulls; several collide and spring back
<MagnetCalibration visible={calibrating} onClose={(ok) => setCalibrating(false)} />
// Calibrate once per phone and magnet (five rings, ~10 s); Magnet.getMagnet() is null until then.
useMagnetGestures((g) => {}); // g.g: "tap" (g.at, window points) | "push" | "pullAway" | "swipe"
const f = useMagnet(); // or useMagnetFrame(cb) for every reading without rendering
Fields: pos (window points, null when unsure), spread, height (cm), presence
(0-1), pull (0-1), toward (unit vector), strength (µT), distance (cm),
approachSpeed (cm/s), tooClose, calibrating, zeroed.
Magnet.zero() re-zeros with no magnet near. getMagneticField() tunes the
MagneticView physics (stiffness, damping, force, reach). Off with reduced motion.
Keep the magnet 2 cm+ from the sensor (f.tooClose).
Docs: https://libraries.dev/magnetic