native_mouse_cursor 1.1.0
native_mouse_cursor: ^1.1.0 copied to clipboard
Turn any image, SVG, or painted glyph into a real OS mouse cursor on Flutter desktop, web & Android.
[native_mouse_cursor logo]
native_mouse_cursor #
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. ๐ช
โจ Why use it #
- โก 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.
- ๐ฆ SPM-first on macOS โ no CocoaPods required.
๐งฉ Platform support #
| Platform | Backend | Status | |
|---|---|---|---|
| [] | macOS | NSCursor (Swift, SPM) |
โ Supported |
| [] | Windows | HCURSOR (Win32) |
โ Supported |
| [] | Linux | GdkCursor (GTK) |
โ Supported |
| [] | Android | PointerIcon (API 24+) |
โ Supported ยฒ |
| [] | Web | CSS url(...) cursor |
โ Supported ยน |
| [] | iOS / iPadOS | system pointer | โ Not possible ยณ |
ยน 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.
๐ฆ Install #
dependencies:
native_mouse_cursor: ^1.0.1
flutter pub add native_mouse_cursor
๐ Quick start #
The 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.
๐จ Cursor sources #
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));
๐ Rotation #
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. ๐
โ๏ธ Mirroring #
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.
๐ฏ Hotspot #
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));
๐ฅ๏ธ High-DPI & disposing #
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(); // ๐งผ everything
โ๏ธ Infinite drag (relative / warp) #
Infinite drag: drag a number (or any handle) and the value keeps changing
forever because the pointer wraps at the window edge instead of running into
it. InfiniteDragController owns all the per-OS work โ you keep your
own gesture and just feed it the pointer + viewport, and it returns the
effective dx to apply (warp-jump frame already 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,
);
๐ก The snippet above is the desktop warp path. To also support web and Wayland (where the OS stops sending motion to Flutter while the pointer is locked), pass an
onLockedDeltacallback tostart()and apply the same scrub there โ see doc/infinite_drag.md.
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 (X11 and Wayland) and the web โ each via the right native mechanism (cursor warp on most desktops; pointer-lock on Wayland and the web). The controller hides all of that, so your code stays the same everywhere. Mobile falls back to an ordinary clamped drag. For the per-platform mechanisms, the Wayland lock approach and the build requirements, see doc/infinite_drag.md.
๐๏ธ Painted overlay (web / desktop) #
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).
๐งช Example #
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 / linux
โ๏ธ How it works #
NativeMouseCursor 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.
๐ค Author #
Rami Al-Dhafiri.
๐ License #
MIT ยฉ Rami Al-Dhafiri.