native_mouse_cursor 1.0.2 copy "native_mouse_cursor: ^1.0.2" to clipboard
native_mouse_cursor: ^1.0.2 copied to clipboard

Turn any image, SVG, or painted glyph into a real OS mouse cursor on Flutter desktop, web & Android.

example/lib/main.dart

// Copyright (c) 2026 Rami Al-Dhafiri.
// SPDX-License-Identifier: MIT

import 'dart:math' as math;
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:native_mouse_cursor/native_mouse_cursor.dart';

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  // Toggles the in-app painted overlay (hides the system cursor). Meaningful on
  // web/desktop, where the system cursor can be hidden.
  bool _force = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: const Color(0xFF5B5BD6),
        brightness: Brightness.light,
      ),
      // Wrap the whole app once. Pass-through where a native/CSS cursor exists;
      // `force` paints the overlay everywhere.
      builder: (context, child) =>
          NativeMouseCursorOverlay(force: _force, child: child!),
      home: ShowcasePage(
        force: _force,
        onForceChanged: (v) => setState(() => _force = v),
      ),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────

class ShowcasePage extends StatefulWidget {
  const ShowcasePage({
    super.key,
    required this.force,
    required this.onForceChanged,
  });

  final bool force;
  final ValueChanged<bool> onForceChanged;

  @override
  State<ShowcasePage> createState() => _ShowcasePageState();
}

// `NativeMouseCursorMixin` auto-configures the bake DPR and rebuilds when a
// cursor finishes baking — so the demos can just call `get` from `build`.
class _ShowcasePageState extends State<ShowcasePage> with NativeMouseCursorMixin {
  bool _registered = false;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (!_registered) {
      _registered = true;
      _registerCursors();
    }
  }

  Future<void> _registerCursors() async {
    const box = ui.Size(30, 30);
    // The pointer's natural click point is its tip, in glyph coords → (2.1, 0.9).
    final tip = ui.Offset(_arrowTip.dx * box.width, _arrowTip.dy * box.height);

    // draw — pivots on its TIP (the hotspot is the rotation origin) to aim at
    // the dot.
    NativeMouseCursor.draw('rotate', size: box, painter: _arrow, hotspot: tip);

    // hotspot demo — the SAME glyph with a tip hotspot vs a centre hotspot.
    NativeMouseCursor.draw('tip', size: box, painter: _arrow, hotspot: tip);
    NativeMouseCursor.draw('centre', size: box, painter: _arrow);

    // shadow on / off — a natural pointer (tip is the click point).
    NativeMouseCursor.draw('shadowed', size: box, painter: _arrow, hotspot: tip);
    NativeMouseCursor.draw('plain',
        size: box, painter: _arrow, shadow: null, hotspot: tip);

    // image — the pointer; the mirroring demo flips it horizontally with flipX.
    NativeMouseCursor.image('hand', await _arrowImage(64),
        size: box, hotspot: tip);

    // the four source types, side by side (tip hotspot; the builder ring is
    // symmetric so its centre default is fine).
    NativeMouseCursor.svg('src_svg', 'assets/pointer.svg',
        size: box, hotspot: tip);
    NativeMouseCursor.image('src_img', await _arrowImage(64),
        size: box, hotspot: tip);
    NativeMouseCursor.draw('src_draw',
        size: box,
        painter: (c, s) => _arrow(c, s, fill: const ui.Color(0xFFBBE1FF)),
        hotspot: tip);
    NativeMouseCursor.builder('src_builder', build: _builderCursor);
  }

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    final modeLabel =
        widget.force ? 'Overlay' : (kIsWeb ? 'CSS url()' : 'Native OS');
    return Scaffold(
      backgroundColor: scheme.surfaceContainerLowest,
      appBar: AppBar(
        backgroundColor: scheme.surfaceContainerLowest,
        titleSpacing: 20,
        title: const Text('native_mouse_cursor',
            style: TextStyle(fontWeight: FontWeight.w700)),
        actions: [
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 12),
            child: Row(
              children: [
                Chip(
                  visualDensity: VisualDensity.compact,
                  label: Text(modeLabel),
                  avatar: Icon(
                    widget.force ? Icons.brush_outlined : Icons.mouse_outlined,
                    size: 16,
                  ),
                ),
                const SizedBox(width: 8),
                Switch(value: widget.force, onChanged: widget.onForceChanged),
                const Tooltip(
                  message: 'Paint the cursor in-app and hide the\n'
                      'system cursor (web / desktop)',
                  child: Icon(Icons.info_outline, size: 18),
                ),
              ],
            ),
          ),
        ],
      ),
      body: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 760),
          child: ListView(
            padding: const EdgeInsets.fromLTRB(20, 8, 20, 40),
            children: [
              Padding(
                padding: const EdgeInsets.only(bottom: 8, top: 4),
                child: Text(
                  'Real OS mouse cursors from your own glyphs. Hover each demo.',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ),
              _DemoCard(
                icon: Icons.rotate_right,
                title: 'Rotation',
                description:
                    'One get(id, angle:) call — the arrow rotates to aim at the '
                    'dot. Each angle is baked once and cached.',
                child: _RotationDemo(),
              ),
              _DemoCard(
                icon: Icons.flip,
                title: 'Mirroring',
                description:
                    'Move between quadrants — flipX / flipY mirror one registered '
                    'glyph on demand (no second asset). The tip stays on the '
                    'pointer.',
                child: _MirrorDemo(),
              ),
              _DemoCard(
                icon: Icons.my_location,
                title: 'Hotspot',
                description:
                    'The red dot marks the true pointer position — i.e. the '
                    'hotspot. Same glyph, two hotspots: see it at the tip vs the '
                    'centre.',
                child: _HotspotDemo(),
              ),
              _DemoCard(
                icon: Icons.contrast,
                title: 'Baked drop shadow',
                description:
                    'A CSS-style shadow baked into the bitmap — rock-steady '
                    'at every angle, never shimmering.',
                child: _ShadowDemo(),
              ),
              _DemoCard(
                icon: Icons.category_outlined,
                title: 'Cursor sources',
                description:
                    'Register a glyph from an SVG, a ui.Image, a painter, or a '
                    'custom per-angle builder.',
                child: _SourcesDemo(),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// ───────────────────────────────── demos ─────────────────────────────────────

class _RotationDemo extends StatefulWidget {
  @override
  State<_RotationDemo> createState() => _RotationDemoState();
}

class _RotationDemoState extends State<_RotationDemo> {
  double _angle = 0;

  @override
  Widget build(BuildContext context) {
    return _Stage(
      height: 220,
      child: LayoutBuilder(
        builder: (context, c) {
          final center = Offset(c.maxWidth / 2, c.maxHeight / 2);
          return MouseRegion(
            cursor: NativeMouseCursor.get('rotate', angle: _angle),
            onHover: (e) {
              // Turn the pointer so its tip aims from the cursor at the dot.
              final d = center - e.localPosition;
              setState(() => _angle = math.atan2(d.dy, d.dx) - _arrowForward);
            },
            child: CustomPaint(
              painter: _DotPainter(Theme.of(context).colorScheme.primary),
              child: const SizedBox.expand(),
            ),
          );
        },
      ),
    );
  }
}

class _MirrorDemo extends StatefulWidget {
  @override
  State<_MirrorDemo> createState() => _MirrorDemoState();
}

class _MirrorDemoState extends State<_MirrorDemo> {
  bool _flipX = false;
  bool _flipY = false;

  @override
  Widget build(BuildContext context) {
    final divider = Theme.of(context).colorScheme.outlineVariant;
    return _Stage(
      height: 200,
      child: LayoutBuilder(
        builder: (context, c) {
          final center = Offset(c.maxWidth / 2, c.maxHeight / 2);
          return MouseRegion(
            cursor:
                NativeMouseCursor.get('hand', flipX: _flipX, flipY: _flipY),
            onHover: (e) {
              final fx = e.localPosition.dx > center.dx; // right half
              final fy = e.localPosition.dy > center.dy; // bottom half
              if (fx != _flipX || fy != _flipY) {
                setState(() {
                  _flipX = fx;
                  _flipY = fy;
                });
              }
            },
            child: Column(
              children: [
                Expanded(
                  child: Row(
                    children: [
                      Expanded(child: _cell('—', !_flipX && !_flipY)),
                      VerticalDivider(width: 1, color: divider),
                      Expanded(child: _cell('flipX', _flipX && !_flipY)),
                    ],
                  ),
                ),
                Divider(height: 1, color: divider),
                Expanded(
                  child: Row(
                    children: [
                      Expanded(child: _cell('flipY', !_flipX && _flipY)),
                      VerticalDivider(width: 1, color: divider),
                      Expanded(child: _cell('flipX · flipY', _flipX && _flipY)),
                    ],
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  Widget _cell(String label, bool active) {
    final primary = Theme.of(context).colorScheme.primary;
    return AnimatedContainer(
      duration: const Duration(milliseconds: 120),
      color: active ? primary.withValues(alpha: 0.10) : Colors.transparent,
      alignment: Alignment.center,
      child: Text(
        label,
        style: TextStyle(
          fontWeight: active ? FontWeight.w700 : FontWeight.w400,
          color: active ? primary : Colors.black54,
        ),
      ),
    );
  }
}

class _HotspotDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 150,
      child: Row(
        children: [
          Expanded(child: _HotspotTarget(label: 'tip hotspot', id: 'tip')),
          const SizedBox(width: 12),
          Expanded(
              child: _HotspotTarget(label: 'centre hotspot', id: 'centre')),
        ],
      ),
    );
  }
}

class _HotspotTarget extends StatefulWidget {
  const _HotspotTarget({required this.label, required this.id});
  final String label;
  final String id;

  @override
  State<_HotspotTarget> createState() => _HotspotTargetState();
}

class _HotspotTargetState extends State<_HotspotTarget> {
  Offset? _pointer;

  @override
  Widget build(BuildContext context) {
    return _Stage(
      height: 150,
      child: MouseRegion(
        cursor: NativeMouseCursor.get(widget.id),
        onHover: (e) => setState(() => _pointer = e.localPosition),
        onExit: (e) => setState(() => _pointer = null),
        child: Stack(
          alignment: Alignment.center,
          children: [
            CustomPaint(
              painter: _PointerDotPainter(_pointer),
              child: const SizedBox.expand(),
            ),
            Align(
              alignment: Alignment.topCenter,
              child: Padding(
                padding: const EdgeInsets.only(top: 10),
                child: Text(widget.label,
                    style: Theme.of(context).textTheme.bodySmall),
              ),
            ),
            if (_pointer == null)
              Text('hover here',
                  style: Theme.of(context)
                      .textTheme
                      .bodySmall
                      ?.copyWith(color: Colors.black38)),
          ],
        ),
      ),
    );
  }
}

class _ShadowDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 120,
      child: Row(
        children: [
          Expanded(child: _Swatch(label: 'with shadow', id: 'shadowed')),
          const SizedBox(width: 12),
          Expanded(child: _Swatch(label: 'no shadow', id: 'plain')),
        ],
      ),
    );
  }
}

class _SourcesDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    const items = [
      ('SVG', 'src_svg'),
      ('Image', 'src_img'),
      ('Painter', 'src_draw'),
      ('Builder', 'src_builder'),
    ];
    return SizedBox(
      height: 110,
      child: Row(
        children: [
          for (var i = 0; i < items.length; i++) ...[
            if (i > 0) const SizedBox(width: 10),
            Expanded(child: _Swatch(label: items[i].$1, id: items[i].$2)),
          ],
        ],
      ),
    );
  }
}

// ──────────────────────────────── building blocks ────────────────────────────

/// A titled, described card wrapping a demo stage.
class _DemoCard extends StatelessWidget {
  const _DemoCard({
    required this.icon,
    required this.title,
    required this.description,
    required this.child,
  });

  final IconData icon;
  final String title;
  final String description;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Card(
      elevation: 0,
      margin: const EdgeInsets.only(bottom: 16),
      color: scheme.surface,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(20),
        side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.6)),
      ),
      child: Padding(
        padding: const EdgeInsets.all(18),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(icon, size: 20, color: scheme.primary),
                const SizedBox(width: 8),
                Text(title,
                    style: const TextStyle(
                        fontSize: 17, fontWeight: FontWeight.w700)),
              ],
            ),
            const SizedBox(height: 6),
            Text(description, style: Theme.of(context).textTheme.bodyMedium),
            const SizedBox(height: 14),
            child,
          ],
        ),
      ),
    );
  }
}

/// A rounded, soft-filled interactive area.
class _Stage extends StatelessWidget {
  const _Stage({required this.height, required this.child});
  final double height;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return SizedBox(
      height: height,
      width: double.infinity,
      child: ClipRRect(
        borderRadius: BorderRadius.circular(14),
        child: ColoredBox(
          color: scheme.surfaceContainerHighest.withValues(alpha: 0.5),
          child: child,
        ),
      ),
    );
  }
}

/// A labelled swatch that shows the cursor registered under [id].
class _Swatch extends StatelessWidget {
  const _Swatch({required this.label, required this.id});
  final String label;
  final String id;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return MouseRegion(
      cursor: NativeMouseCursor.get(id),
      child: DecoratedBox(
        decoration: BoxDecoration(
          color: scheme.surfaceContainerHighest.withValues(alpha: 0.5),
          borderRadius: BorderRadius.circular(14),
          border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.6)),
        ),
        child: Center(
          child: Text(label,
              style: const TextStyle(fontWeight: FontWeight.w600)),
        ),
      ),
    );
  }
}

/// Draws the target dot the rotation arrow points at.
class _DotPainter extends CustomPainter {
  _DotPainter(this.color);
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    final center = size.center(Offset.zero);
    canvas.drawCircle(center, 9, Paint()..color = color);
    canvas.drawCircle(
        center,
        17,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2
          ..color = color.withValues(alpha: 0.35));
  }

  @override
  bool shouldRepaint(_DotPainter oldDelegate) => oldDelegate.color != color;
}

/// Marks the true pointer position with a red dot — i.e. where the cursor's
/// hotspot sits. Compare it against the rendered glyph's tip vs centre.
class _PointerDotPainter extends CustomPainter {
  _PointerDotPainter(this.point);
  final Offset? point;

  @override
  void paint(Canvas canvas, Size size) {
    final p = point;
    if (p == null) return;
    canvas
      ..drawCircle(p, 5, Paint()..color = Colors.white)
      ..drawCircle(p, 3.5, Paint()..color = const Color(0xFFE53935));
  }

  @override
  bool shouldRepaint(_PointerDotPainter oldDelegate) =>
      oldDelegate.point != point;
}

// ──────────────────────────────── glyph painters ─────────────────────────────

// The classic OS pointer: tip at the top-left, tail leg to the bottom. Outline
// in normalised (0..1) box coords; the tip is what aims/clicks.
const _arrowTip = ui.Offset(0.07, 0.03);
const _arrowOutline = <ui.Offset>[
  ui.Offset(0.07, 0.03), // tip
  ui.Offset(0.07, 0.74), // left edge, down
  ui.Offset(0.27, 0.59), // notch base, left
  ui.Offset(0.40, 0.93), // tail leg, bottom-left
  ui.Offset(0.52, 0.88), // tail leg, bottom-right
  ui.Offset(0.39, 0.55), // notch base, right
  ui.Offset(0.66, 0.52), // right wing
];

// The angle the tip points at rotation 0 (from the box centre), so the rotation
// demo can turn it to aim anywhere.
final double _arrowForward =
    math.atan2(_arrowTip.dy - 0.5, _arrowTip.dx - 0.5);

/// Paints the pointer into a [size]-logical box.
void _arrow(ui.Canvas canvas, ui.Size size,
    {ui.Color fill = const ui.Color(0xFFFFFFFF)}) {
  final w = size.width, h = size.height;
  final path = ui.Path()
    ..moveTo(_arrowOutline.first.dx * w, _arrowOutline.first.dy * h);
  for (final o in _arrowOutline.skip(1)) {
    path.lineTo(o.dx * w, o.dy * h);
  }
  path.close();
  canvas.drawPath(path, ui.Paint()..color = fill);
  canvas.drawPath(
    path,
    ui.Paint()
      ..color = const ui.Color(0xFF1A1A1A)
      ..style = ui.PaintingStyle.stroke
      ..strokeWidth = w * 0.07
      ..strokeJoin = ui.StrokeJoin.round,
  );
}

/// Rasterises the pointer into a [px]² image.
Future<ui.Image> _arrowImage(int px) {
  final recorder = ui.PictureRecorder();
  final canvas = ui.Canvas(recorder);
  _arrow(canvas, ui.Size(px.toDouble(), px.toDouble()));
  return recorder.endRecording().toImage(px, px);
}

/// A custom per-angle builder cursor: a ringed dot (here angle-independent).
Future<ui.Image> _builderCursor(double angle, double dpr) {
  const logical = 26.0;
  final px = (logical * dpr).round();
  final recorder = ui.PictureRecorder();
  final canvas = ui.Canvas(recorder)..scale(dpr);
  const center = ui.Offset(logical / 2, logical / 2);
  canvas.drawCircle(
      center, logical * 0.42, ui.Paint()..color = const ui.Color(0xFF7E57C2));
  canvas.drawCircle(
    center,
    logical * 0.42,
    ui.Paint()
      ..style = ui.PaintingStyle.stroke
      ..strokeWidth = 1.6
      ..color = const ui.Color(0xFFFFFFFF),
  );
  return recorder.endRecording().toImage(px, px);
}
0
likes
160
points
175
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Turn any image, SVG, or painted glyph into a real OS mouse cursor on Flutter desktop, web & Android.

Repository (GitHub)
View/report issues

Topics

#cursor #mouse #pointer #ui #desktop

License

MIT (license)

Dependencies

flutter, flutter_svg, flutter_web_plugins, plugin_platform_interface, web

More

Packages that depend on native_mouse_cursor

Packages that implement native_mouse_cursor