skip to content

In a Flutter 3.47 desktop app, why do Material buttons show an arrow cursor, and how do you give a custom row a hand cursor?

level: middleimportance: should knowfreq 30%

answer

  1. desktop convention: arrow over buttons
  2. WidgetStateMouseCursor.adaptiveClickable since 3.41
  3. MouseRegion cursor defaults to defer
  4. onEnter and onExit, not onHover
  5. opaque true blocks regions behind

basics

~20 s

Since Flutter 3.41, Material buttons and InkWell use WidgetStateMouseCursor.adaptiveClickable: a hand on the web, the basic arrow on desktop. For a custom row, wrap it in MouseRegion with cursor: SystemMouseCursors.click and track hover in onEnter and onExit.

solid answer

~40 s

Desktop platforms conventionally show the arrow over buttons, so since Flutter 3.41 Material buttons and `InkWell` default to `WidgetStateMouseCursor.adaptiveClickable`, which resolves to `SystemMouseCursors.click` only on the web and to `basic` elsewhere, and to `basic` when disabled. You can override it with `enabledMouseCursor` in a button's `styleFrom`, `ButtonStyle.mouseCursor`, or `InkWell.mouseCursor`. For a custom invoice row, wrap it in `MouseRegion(cursor: SystemMouseCursors.click, onEnter: ..., onExit: ...)` and flip a hovered flag in `setState`. `MouseRegion.cursor` defaults to `MouseCursor.defer`, letting the region behind decide, and `opaque` defaults to `true`, so it hides the pointer from sibling regions behind it. Prefer `onEnter`/`onExit` to `onHover`, which fires on every movement, and remember `onExit` is not called when the region disappears while hovered.

code

dart · 33 lines
dart
import 'package:flutter/material.dart';

class InvoiceRow extends StatefulWidget {
  const InvoiceRow({super.key, required this.number, required this.onOpen});

  final String number;
  final VoidCallback onOpen;

  @override
  State<InvoiceRow> createState() => _InvoiceRowState();
}

class _InvoiceRowState extends State<InvoiceRow> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    return MouseRegion(
      cursor: SystemMouseCursors.click,
      onEnter: (_) => setState(() => _hovered = true),
      onExit: (_) => setState(() => _hovered = false),
      child: GestureDetector(
        onTap: widget.onOpen,
        child: Container(
          color: _hovered ? colors.surfaceContainerHighest : colors.surface,
          padding: const EdgeInsets.all(12),
          child: Text('Invoice ${widget.number}'),
        ),
      ),
    );
  }
}

go deeper

for a junior

Recall that MouseRegion sets the cursor and reports enter and exit, and that desktop buttons show an arrow by default.

for a middle

Explain adaptiveClickable, MouseRegion's defer and opaque defaults, and why onEnter/onExit beat onHover for highlights.

for a senior

Show you handle onExit's removal gap, theme-level cursor overrides and consistent desktop conventions across a large app.

for a principal

Decide how closely the app follows each desktop platform's conventions versus a single cross-platform look, and encode that in the theme.

## Hover is a desktop and web concept On a touch screen there is no pointer resting over a widget, so hover and cursors do not exist. On desktop and web, a mouse is always somewhere, and users expect feedback: a highlight on the row under the pointer, a text cursor over editable text, a resize cursor over a splitter. Flutter handles this with **mouse cursors** and the **`MouseRegion`** widget. ## Why Material buttons show an arrow Native desktop apps on Windows, macOS and Linux show the ordinary arrow over buttons; the hand pointer is a web convention for links. Since **Flutter 3.41**, Material buttons and `InkWell` default to **`WidgetStateMouseCursor.adaptiveClickable`**: | Situation | Resolved cursor | |---|---| | Enabled, on the web | `SystemMouseCursors.click` (hand) | | Enabled, on desktop | `SystemMouseCursors.basic` (arrow) | | Disabled, anywhere | `SystemMouseCursors.basic` | The older `WidgetStateMouseCursor.clickable` still exists and resolves to the hand whenever the widget is enabled. To get the hand on desktop for a particular control: - pass `enabledMouseCursor: SystemMouseCursors.click` to `ElevatedButton.styleFrom`, `TextButton.styleFrom` and the other button `styleFrom` helpers; - or set `mouseCursor` in a `ButtonStyle`, typically in the theme; - or set `InkWell.mouseCursor` on custom tappable surfaces. ## MouseRegion for custom widgets `MouseRegion` reports pointer movement over its area and sets the cursor: | Property | Default | Meaning | |---|---|---| | `cursor` | `MouseCursor.defer` | let the next region behind choose the cursor | | `opaque` | `true` | hide the pointer from other regions visually behind it, except ancestors and descendants | | `onEnter` / `onExit` | none | pointer entered or left the region | | `onHover` | none | pointer moved within the region, with no button pressed | A hover highlight needs only `onEnter` and `onExit`. `onHover` fires on every mouse movement; calling `setState` from it rebuilds the row continuously for no visual change. ## A trap in onExit `onExit` is **not** called when the region itself disappears while hovered, for example when a row is removed from the list under the pointer. The framework deliberately skips it, because the widget is already unmounted and a `setState` there would throw. If hover state lives outside the widget that creates the `MouseRegion`, clear it yourself when you remove the widget. ## Putting it together 1. Wrap the row in `MouseRegion` with `cursor: SystemMouseCursors.click` if clicking opens the invoice. 2. Keep a `_hovered` flag in the row's `State`, set in `onEnter` and cleared in `onExit`. 3. Paint the highlight from the colour scheme, for example `colorScheme.surfaceContainerHighest`, so it follows light and dark themes. 4. Keep the tap handling in the gesture widget you already use; `MouseRegion` does not handle clicks. ## Checking behaviour per platform 1. Run the desktop build and hover every interactive surface: buttons should match the platform convention you chose, and custom rows should give clear feedback. 2. Run the web build too if the app ships there; `adaptiveClickable` shows the hand on the web, so the same widget behaves differently by design. 3. On touch devices none of this runs, so never hide essential information behind hover alone. ## Other cursors worth knowing - `SystemMouseCursors.text` over custom text-editing surfaces; - `SystemMouseCursors.resizeColumn` over a draggable column divider; - `SystemMouseCursors.forbidden` over a drop target that rejects the dragged item.

  • A Flutter desktop team wants the hand cursor back on every ElevatedButton. Where do they change it once?
    In the theme: give `ElevatedButtonThemeData` a style from `ElevatedButton.styleFrom(enabledMouseCursor: SystemMouseCursors.click)`, or set `ButtonStyle.mouseCursor` there. Every `ElevatedButton` then resolves to the hand when enabled, and a disabled button still gets `disabledMouseCursor` or the default.
  • An invoice row is deleted while the mouse is over it, and a separate status bar still says 'hovering'. Why?
    `MouseRegion.onExit` is not called when the region disappears while hovered, so the status bar never heard the pointer leave. Because the hover state lives outside the row, the code that deletes the row must also clear that state.

saying these in an interview costs you the question

  • Material buttons in Flutter 3.47 show a hand cursor on desktop by default.
  • MouseRegion's cursor defaults to SystemMouseCursors.click.
  • Calling setState in onHover is the right way to track hover.
  • onExit always fires, even when the hovered widget is removed.
  • MouseRegion handles clicks as well as hover.