Turn any image, SVG, or painted glyph into a real OS mouse cursor β on Flutter desktop, web & Android.
π Try the live web demo β (web preview β full native cursors shine on macOS / Windows / Linux / Android)
Unlike a cursor "painted" inside Flutter (a widget that chases the pointer), a
NativeMouseCursor is handed to the operating system, so the OS compositor
draws it for you. πͺ
- β‘ Zero lag β tracks the hardware pointer exactly, with no one-frame trail.
- π«§ No jitter β a shadow or glow baked into the bitmap never shimmers, even while the cursor rotates.
- π Drop-in β it's a real
MouseCursor, so it works anywhere aSystemMouseCursorsvalue does (MouseRegion,InkWell, scrollbars, β¦). - π Rotation & mirroring β spin a glyph by angle or flip it on demand; each variant is baked and cached automatically.
- π Baked drop shadows β CSS-style shadows rendered into the bitmap, so they stay rock-steady at every angle.
- π₯οΈ HiDPI-crisp β bakes at your device pixel ratio and re-bakes on change.
- ποΈ Optional painted overlay β on web/desktop, opt into an in-app overlay that hides the system cursor and paints a perfectly seamless per-region one.
βοΈ Infinite drag βInfiniteDragRegiongives an unbounded value scrub on every platform (desktop edge-warp, web Pointer Lock incl. Firefox), with a cursor that wraps the viewport on web.- π¦ SPM-first on macOS β no CocoaPods required.
ΒΉ Each cursor is applied as a CSS cursor: url(...) value, sized in logical
px and capped at 128 px (browsers draw a cursor image at its intrinsic pixels and
Chrome ignores larger ones). For HiDPI crispness it also emits a
device-resolution image via image-set(β¦ 2x), with the plain url() as a
fallback. For a perfectly seamless per-region cursor, wrap your app in
NativeMouseCursorOverlay(force: true) to paint
the glyph and hide the CSS cursor instead.
Β² Native PointerIcon for tablets/Chromebooks with a connected mouse,
trackpad or stylus on API 24+. On older devices the system pointer is used.
For a rotating cursor, prefer the
painted overlay β rapid PointerIcon swaps
flicker on Android.
Β³ iPadOS draws and manages the pointer itself β there's no API to install an arbitrary bitmap cursor, nor to hide the system pointer, so the system pointer is used. (A painted overlay would just show through it as a double cursor.) iPhone is touch-only β no pointer to replace.
dependencies:
native_mouse_cursor: ^1.2.0flutter pub add native_mouse_cursorThe whole API is: register a source under an id, then get it. π―
Everything hard β loading the glyph, rotation, the baked drop shadow, automatic bitmap sizing, the angle-keyed cache, background warming and DPR re-baking β lives in the package.
Mix NativeMouseCursorMixin into your State and the rest is automatic: it
points the cache at the context's devicePixelRatio (re-baking on a DPR change)
and rebuilds when a cursor finishes baking β so you can call svg / get
straight from build():
import 'package:native_mouse_cursor/native_mouse_cursor.dart';
class _MyState extends State<MyWidget> with NativeMouseCursorMixin {
@override
void initState() {
super.initState();
// π Register here, NOT in build() β svg() kicks off an async load + bake,
// so it's a one-time side effect. For an SVG asset that's the whole call;
// size, shadow and the hotspot all default.
NativeMouseCursor.svg('rotate', 'assets/icons/rotate.svg');
// size: defaults to the SVG's own (viewBox) size
// shadow: defaults to x:0 y:1 blur:1.5 black 50% (Ο=blur/2); null = none
}
@override
Widget build(BuildContext context) {
// π build() only fetches β the bitmap is baked + cached per angle on
// demand, and the mixin rebuilds when a fresh one lands.
return MouseRegion(
// get() never returns null: until the bitmap is baked it returns
// SystemMouseCursors.basic, so no `??` is needed.
cursor: NativeMouseCursor.get('rotate', angle: handleAngleRadians),
child: handle,
);
}
}π‘
NativeMouseCursor.has(id)lets you guard a one-off lazy registration if you can't register up front. Prefer not to use the mixin? CallNativeMouseCursor.configure(devicePixelRatio:, onReady:)yourself once (and again whenever the DPR changes) instead.
Pick the register call that matches your glyph β all take the same id,
size, shadow and hotspot options:
| Call | Glyph source |
|---|---|
πΌοΈ NativeMouseCursor.svg |
an SVG asset path (re-rasterised from vector) |
π
NativeMouseCursor.image |
a decoded ui.Image |
βοΈ NativeMouseCursor.draw |
a CursorPainter you paint into a box yourself |
π οΈ NativeMouseCursor.builder |
produce the bitmap yourself per angle + DPR |
NativeMouseCursor.image('pointer', myUiImage, size: const Size(24, 24));There's no rotation flag β just the angle you pass to get. A fixed cursor is
simply one you always fetch at the default angle (0), so a single bitmap is baked
and reused:
NativeMouseCursor.svg('resize-h', 'assets/resize-h.svg'); // β
// ...
cursor: NativeMouseCursor.get('resize-h'),For a glyph that turns with a handle, vary the angle β each rotation bucket is baked and cached the first time it's requested (the at-rest angle is warmed in the background; the nearest already-baked angle is shown meanwhile). The bitmap box is always sized for the glyph's diagonal, so it never clips as it turns. π
flipX / flipY are resolved at get time, so one registered glyph yields a
mirrored pair on demand β no second asset:
NativeMouseCursor.svg('hand', 'assets/hand-right.svg');
// the same glyph, flipped β a left hand from the right-hand asset:
cursor: NativeMouseCursor.get('hand', flipX: pointingLeft),Every (angle, flip) combination is baked and cached the first time it's asked
for; the unflipped variant is warmed in the background.
By default the click point is the glyph's centre. To anchor it elsewhere (e.g. a
tip-anchored pointer), pass hotspot in the glyph's own coords (its size /
SVG viewBox, origin top-left) β the package centres the glyph in the auto-sized
bitmap and maps the hotspot in for you, so you never deal with box coordinates:
// A 32Γ32 arrow whose tip is at (9, 3):
NativeMouseCursor.svg('pointer', 'assets/icons/pointer.svg',
hotspot: const Offset(9, 3));Cursors bake at the DPR passed to configure and re-bake automatically when you
call configure again with a new one, so they stay crisp on Retina/HiDPI.
Release them when you're done:
NativeMouseCursor.dispose('rotate'); // π§Ή one cursor
NativeMouseCursor.disposeAll(); // π§Ό everythingInfinite drag: drag a number (or any handle) and the value keeps changing
forever because the pointer never runs out of room. Wrap your handle in
InfiniteDragRegion and you're done β it handles every platform and browser
for you, and hands you the effective delta to apply each frame:
double value = 0;
InfiniteDragRegion(
// Optional: a baked cursor. While locked on web it's painted WRAPPING the
// viewport so it never disappears.
cursor: NativeMouseCursor.get('scrub', fallback: SystemMouseCursors.resizeLeftRight),
onScrub: (delta) => setState(() => value += delta.dx * scrubRate),
onActiveChanged: (active) => setState(() => _dragging = active), // optional
child: Text('$value'),
);InfiniteDragRegion uses one of two models, chosen per platform via
canWarpPointer(). On macOS, Windows and Linux β X11, and Wayland on modern
compositors β it warps: the OS teleports the visible cursor back from the
edge while your drag events drive the value. On the web and on older
Wayland compositors it locks: the pointer is hidden/frozen and an unbounded
relative-motion stream drives the value, with a painted cursor wrapping the
viewport (on Firefox the lock engages on a click). Mobile has neither, so
the drag simply stops at the edge. A single Linux build serves both X11 and
Wayland.
Prefer to own the gesture? InfiniteDragController is still public β feed it the
pointer + viewport and it returns the effective dx (warp-jump frame skipped,
edge already wrapped):
final _drag = InfiniteDragController();
GestureDetector(
onHorizontalDragStart: (d) => _drag.start(d.globalPosition),
onHorizontalDragUpdate: (d) async {
final dx = await _drag.update(
globalPosition: d.globalPosition,
delta: d.delta,
viewportSize: MediaQuery.sizeOf(context),
);
setState(() => value += dx * scrubRate);
},
onHorizontalDragEnd: (_) => _drag.end(),
onHorizontalDragCancel: () => _drag.cancel(),
child: handle,
);π‘ That snippet is the desktop warp path. The lock-based paths (web, Wayland) and Firefox's click-to-engage are exactly what
InfiniteDragRegionwires up for you.
The low-level primitive is also public: NativeMouseCursor.warpPointer(x, y)
teleports the OS pointer to logical window coords, and
NativeMouseCursor.canWarpPointer() reports whether the host supports it.
Works on macOS, Windows, Linux and the web. For the exact per-platform APIs/protocols, the Firefox click-to-engage model and the Linux build requirements, see doc/infinite_drag.md.
Want the cursor painted inside Flutter instead of as a real OS cursor? Wrap
your app in NativeMouseCursorOverlay(force: true): it hides the system cursor
and paints the same baked bitmap at the live pointer position.
MaterialApp(
builder: (context, child) =>
NativeMouseCursorOverlay(force: kIsWeb, child: child!),
home: const MyHomePage(),
);This is useful where the system cursor can actually be hidden:
Web β a perfectly seamless per-region cursor (the engine's CSS handling is best-effort across regions); the CSS cursor is hidden.
Android β recommended for a rotating cursor: the native
PointerIconflickers when swapped rapidly (an OS quirk), so the painted overlay (system pointer hidden) gives smooth rotation.macOS / Windows / Linux β preview the painted cursor (the native cursor is already pixel-perfect, so you rarely need this).
Off by default; the widget is a transparent pass-through unless force is set.
β οΈ The overlay is a Flutter widget chasing the pointer, so it has a one-frame lag a real OS cursor doesn't. It only works where the system cursor can be hidden β not on iOS/iPadOS (the system pointer can't be hidden, so a painted one would just double it).
The example/ app is an interactive showcase β rotation (an arrow
that aims at a dot), mirroring (flipX/flipY), the hotspot (a red dot marking
the true pointer position), the baked shadow, and all four cursor sources β plus
a switch to toggle the painted overlay.
cd example && flutter run -d macos # or -d chrome / windows / linuxNativeMouseCursor extends Flutter's MouseCursor. When the framework activates
the cursor for a pointer, the plugin asks the host to make the matching OS cursor
current (NSCursor.set() / SetCursor / gdk_window_set_cursor). Because
activation flows through Flutter's own cursor machinery, the OS cursor isn't
fought over by the engine's system-cursor handling. π€
With NativeMouseCursorOverlay(force: true),
activation is intercepted instead: it keeps the baked bitmaps, hides the system
cursor, and paints the active cursor at the live pointer position.
Rami Al-Dhafiri.
MIT Β© Rami Al-Dhafiri.