May 17, 2024

Enhancing User Navigation: Implementing Arrow Key Scrolling in Flutter Web

In this article, we explore practical solutions to enable arrow key scrolling in Flutter web, enhancing user navigation and fluidity in your applications.

Author

Alan Paul VargheseAlan Paul VargheseSenior Software Engineer - II

Subject Matter Expert

Shubham KumarShubham KumarTech Lead - II
Enhancing User Navigation: Implementing Arrow Key Scrolling in Flutter Web


Introduction

In the world of web development with Flutter, crafting intuitive and seamless user experiences is paramount. Flutter, Google's open-source UI toolkit, empowers developers to build beautiful and high-performance applications for mobile, web, and desktop from a single codebase. However, despite its versatility and robustness, Flutter web presents some challenges out of the box, one of which is that scrolling via arrow keys is not enabled by default. This seemingly small limitation can hinder user navigation and disrupt the fluidity of interactions. In this article, we delve into the intricacies of this issue and explore practical solutions to empower developers to enable arrow key scrolling effortlessly within their Flutter web applications.

The default behavior is shown below:

Scrolling via arrow keys is not enabled by default in Flutter web


Solution 1: Enabling Keyboard Arrow Key Scrolling with "primary" ListView

In this approach, we tackle the challenge of enabling keyboard arrow key scrolling in Flutter web by leveraging the "primary" property within the ListView widget. By setting "primary" to true, we designate the ListView as the primary scroll view within its parent widget hierarchy. This configuration facilitates scrolling using the keyboard arrow keys, providing a straightforward solution for scenarios where a single scroll view is sufficient and nested scrolls are not required.

To implement this approach, simply set the "primary" property to true within the ListView widget, allowing users to navigate through the content seamlessly using keyboard arrow keys.

class Example1 extends StatelessWidget {
  const Example1({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
          child: ListView.separated(
              primary: true,
              itemBuilder: (context, index) {
                return ListViewItem(index: index);
              },
              separatorBuilder: (context, index) => const SizedBox(
                    height: 10,
                  ),
              itemCount: 200)),
    );
  }
}

class ListViewItem extends StatelessWidget {
  final int index;
  const ListViewItem({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return UnconstrainedBox(
      alignment: Alignment.center,
      child: SizedBox(
        width: 300,
        height: 75,
        child: Card(
          child: ListTile(
            title: Text(
              "At Index $index",
              style: const TextStyle(fontSize: 22),
            ),
            subtitle: const Text("some random text"),
          ),
        ),
      ),
    );
  }
}


Solution 2: Enabling Keyboard Arrow Key Scrolling with GestureDetector and FocusScope

In this approach, we utilise the GestureDetector and FocusScope widgets to implement keyboard arrow key scrolling within the ListView in Flutter web applications. This method offers a flexible and intuitive solution for enabling keyboard-based navigation, enhancing the user experience across various web interfaces.

Implementation Details:

  • GestureDetector Integration: We start by encapsulating the ListView within a GestureDetector widget. This allows us to detect user input gestures, such as taps, which we leverage to manage focus within the ListView.
  • FocusScope Management: Next, we introduce a FocusScope widget to manage the focus state of the ListView dynamically. By creating and maintaining a FocusScopeNode instance, we gain fine-grained control over the focus behavior, ensuring that the ListView responds appropriately to user interaction. 
  • Setting Primary Property: Within the ListView, we dynamically set the "primary" property based on the focus state of the ListView. When the ListView gains focus, we set "primary" to true, indicating that it should become the primary scroll view within its parent widget hierarchy. This enables smooth and intuitive scrolling using the keyboard arrow keys, enhancing accessibility and usability.
class Example2 extends StatefulWidget {
  const Example2({super.key});

  @override
  State<Example2> createState() => _Example2State();
}

class _Example2State extends State<Example2> {
  late FocusScopeNode _focusNode;

  @override
  void initState() {
    _focusNode = FocusScopeNode();
    super.initState();
  }

  @override
  void dispose() {
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: GestureDetector(
          onTap: () {
            _focusNode.requestFocus();
            setState(() {});
          },
          child: FocusScope(
            node: _focusNode,
            child: ListView.separated(
                primary: _focusNode.hasFocus,
                itemBuilder: (context, index) {
                  return ListViewItem(index: index);
                },
                separatorBuilder: (context, index) => const SizedBox(
                      height: 10,
                    ),
                itemCount: 200),
          ),
        ),
      ),
    );
  }
}

class ListViewItem extends StatelessWidget {
  final int index;
  const ListViewItem({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return UnconstrainedBox(
      alignment: Alignment.center,
      child: SizedBox(
        width: 300,
        height: 75,
        child: Card(
          child: ListTile(
            title: Text(
              "At Index $index",
              style: const TextStyle(fontSize: 22),
            ),
            subtitle: const Text("some random text"),
          ),
        ),
      ),
    );
  }
}


Solution 3: Implementing Keyboard Arrow Key Scrolling with KeyboardListener and ScrollController

In this method, we leverage the KeyboardListener and ScrollController widgets to monitor keyboard inputs and adjust scrolling behaviour dynamically within the ListView in Flutter web applications. By detecting arrow key events and animating the scroll position accordingly, we provide users with an intuitive and responsive scrolling experience, enhancing usability and accessibility.

Implementation Details:

  • KeyboardListener Integration: We begin by encapsulating the ListView within a KeyboardListener widget. This widget allows us to listen for keyboard events and respond to user inputs effectively. By specifying an autofocus focus node, we ensure that the ListView receives keyboard input focus by default, enabling seamless interaction.
  • ScrollController Management: Next, we instantiate a ScrollController to manage the scroll position of the ListView programmatically. This controller enables us to animate the scroll position in response to arrow key events, facilitating smooth and fluid scrolling behavior.
  • Adjusting Scroll Position: Within the KeyboardListener's onKeyEvent callback, we capture keyboard events and adjust the scroll position of the ListView dynamically. By detecting arrow key inputs, we calculate the desired scroll offset and use the ScrollController to animate the scroll position accordingly, ensuring a responsive and intuitive scrolling experience for users.
class Example3 extends StatefulWidget {
  const Example3({super.key});

  @override
  State<Example3> createState() => _Example3State();
}

class _Example3State extends State<Example3> {
  late ScrollController _controller;
  late FocusNode _focusNode;

  @override
  void initState() {
    _focusNode = FocusNode();
    _controller = ScrollController();
    super.initState();
  }

  @override
  void dispose() {
    _focusNode.dispose();
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: KeyboardListener(
          autofocus: true,
          focusNode: _focusNode,
          onKeyEvent: (value) {
            if (_controller.position.outOfRange) {
              return;
            }
            final offset = _controller.offset;
            if (value.physicalKey.debugName == "Arrow Down") {
              _controller.animateTo(offset + 50,
                  duration: const Duration(milliseconds: 500),
                  curve: Curves.linear);
            }
            if (value.physicalKey.debugName == "Arrow Up") {
              _controller.animateTo(offset - 50,
                  duration: const Duration(milliseconds: 500),
                  curve: Curves.linear);
            }
          },
          child: ListView.separated(
            controller: _controller,
            itemBuilder: (context, index) {
              return ListViewItem(index: index);
            },
            separatorBuilder: (context, index) => const SizedBox(
              height: 10,
            ),
            itemCount: 200,
          ),
        ),
      ),
    );
  }
}

class ListViewItem extends StatelessWidget {
  final int index;
  const ListViewItem({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return UnconstrainedBox(
      alignment: Alignment.center,
      child: SizedBox(
        width: 300,
        height: 75,
        child: Card(
          child: ListTile(
            title: Text(
              "At Index $index",
              style: const TextStyle(fontSize: 22),
            ),
            subtitle: const Text("some random text"),
          ),
        ),
      ),
    );
  }
}

By using the above solutions, we can achieve the following:

Result using the solutions mentioned.

Note: While the above discussed solutions are good for a single scroll view, they are not as effective for nested scrolling. For nested scrolling, the below solution can be used. 


Solution 4: Utilizing FocusableActionDetector for Keyboard Arrow Key Scrolling

In this approach, we harness the power of FocusableActionDetector to detect keyboard presses and ScrollController to scroll accordingly within the ListView in Flutter web applications. By defining shortcuts and actions for arrow key events, we enable seamless keyboard-based scrolling, enhancing user interaction and accessibility.

Implementation Details:

  • FocusableActionDetector Integration: We begin by encapsulating the ListView within a GestureDetector and FocusableActionDetector combination. This setup allows us to detect keyboard events and trigger corresponding actions in response to arrow key inputs.
  • Defining Shortcuts and Actions: Within the FocusableActionDetector, we define shortcuts for arrow key events (e.g., arrowUp, arrowDown) and associate them with callback actions. These actions invoke custom functions to adjust the scroll position of the ListView dynamically, facilitating smooth and intuitive scrolling behavior.
  • ScrollController Management: We instantiate a ScrollController to manage the scroll position of the ListView programmatically. By animating the scroll position in response to arrow key events, we ensure a responsive and fluid scrolling experience for users. 

class Example4 extends StatelessWidget {
  const Example4({super.key});
  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: SafeArea(
          child: Padding(
        padding: EdgeInsets.only(left: 10, top: 10),
        child: ScrollableList(
          children: [
            Text(
              "Heading 1",
              style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800),
            ),
            SizedBox(
              height: 20,
            ),
            Item(),
            SizedBox(
              height: 50,
            ),
            Text(
              "Heading 2",
              style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800),
            ),
            SizedBox(
              height: 20,
            ),
            Item(),
            SizedBox(
              height: 50,
            ),
            Text(
              "Heading 3",
              style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800),
            ),
            SizedBox(
              height: 20,
            ),
            Item(),
          ],
        ),
      )),
    );
  }
}

class Item extends StatelessWidget {
  const Item({super.key});

  @override
  Widget build(BuildContext context) {
    return UnconstrainedBox(
      alignment: Alignment.centerLeft,
      child: Container(
        height: 250,
        width: 400,
        padding: const EdgeInsets.all(8),
        decoration: BoxDecoration(
            color: Colors.grey[200], borderRadius: BorderRadius.circular(10)),
        child: ScrollableList(
          children: List.generate(
              50,
              (index) => ListTile(
                    contentPadding: EdgeInsets.zero,
                    leading: CircleAvatar(
                      backgroundColor:
                          Colors.accents[index % Colors.accents.length],
                      child: Text(index.toString()),
                    ),
                    title: Text("some random text at $index"),
                  )),
        ),
      ),
    );
  }
}

class ScrollableList extends StatefulWidget {
  final List<Widget> children;
  final Axis? scrollAxis;

  const ScrollableList({super.key, this.children = const [], this.scrollAxis});

  @override
  State<ScrollableList> createState() => _ScrollableList1State();
}

class _ScrollableList1State extends State<ScrollableList> {
  late FocusNode _focusNode;
  late ScrollController _controller;
  @override
  void initState() {
    _focusNode = FocusNode();
    _controller = ScrollController();
    super.initState();
  }

  @override
  void dispose() {
    _focusNode.dispose();
    _controller.dispose();
    super.dispose();
  }

  bool _checkIfOutOfRange() => _controller.position.outOfRange;
  void _handleDownPress() {
    if (_checkIfOutOfRange()) {
      return;
    }
    _controller.animateTo(_controller.offset + 50,
        duration: const Duration(milliseconds: 500), curve: Curves.linear);
  }

  void _handleUpPress() {
    if (_controller.offset == 0) {
      return;
    }
    _controller.animateTo(_controller.offset - 50,
        duration: const Duration(milliseconds: 500), curve: Curves.linear);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        _focusNode.requestFocus();
        setState(() {});
      },
      child: FocusableActionDetector(
        focusNode: _focusNode,
        shortcuts: {
          LogicalKeySet(LogicalKeyboardKey.arrowUp): ScrollUpIntent(),
          LogicalKeySet(LogicalKeyboardKey.arrowDown): ScrollDownIntent(),
          LogicalKeySet(LogicalKeyboardKey.arrowLeft): ScrollLeftIntent(),
          LogicalKeySet(LogicalKeyboardKey.arrowRight): ScrollRightIntent(),
        },
        actions: {
          ScrollUpIntent: CallbackAction<ScrollUpIntent>(
            onInvoke: (intent) {
              _handleUpPress();
              return;
            },
          ),
          ScrollDownIntent: CallbackAction<ScrollDownIntent>(
            onInvoke: (intent) {
              _handleDownPress();
              return;
            },
          ),
          ScrollRightIntent: CallbackAction<ScrollRightIntent>(
            onInvoke: (intent) {
              _handleDownPress();
              return;
            },
          ),
          ScrollLeftIntent: CallbackAction<ScrollLeftIntent>(
            onInvoke: (intent) {
              _handleUpPress();
              return;
            },
          ),
        },
        child: ListView.builder(
            controller: _controller,
            scrollDirection: widget.scrollAxis ?? Axis.vertical,
            itemCount: widget.children.length,
            itemBuilder: (context, index) {
              return widget.children[index];
            }),
      ),
    );
  }
}

class ScrollUpIntent extends Intent {}

class ScrollDownIntent extends Intent {}

class ScrollLeftIntent extends Intent {}

class ScrollRightIntent extends Intent {}
Final result
Hire Us Form


Conclusion

This article discussed different methods for arrow key scrolling in Flutter web apps, a common dev challenge. Techniques include using ListView properties, GestureDetector, FocusScope, KeyboardListener, and FocusableActionDetector widgets. These enhance navigation and interaction, provide smooth scrolling experiences, and improve accessibility. Flutter offers tools to adjust scrolling and keyboard interactions to meet varied user needs.

Subscribe to Our Newsletter

RELATED ARTICLES

More from the engineering frontline.

Dive deep into our research and insights on design, development, and the impact of various trends to businesses.
The Bug That Doesn't Show Up in Code Review: Why Your Flutter Web App Reloads on Safari
The Bug That Doesn't Show Up in Code Review: Why Your Flutter Web App Reloads on Safari
A real-world look at how oversized images can trigger Safari reloads and iOS crashes in Flutter apps and how smarter image decoding prevents them.
From Prompting to Process: What Changed When Flutter Shipped Agent Skills
From Prompting to Process: What Changed When Flutter Shipped Agent Skills
This blog explores how Flutter Agent Skills improve AI-assisted development by combining official framework workflows with project-specific guidance for more consistent development.
Why Everything Your AI Builds Looks the Same
Why Everything Your AI Builds Looks the Same
This blog explores why AI-generated interfaces often look alike and explains how design systems, product context, and reusable engineering practices help teams build distinctive, scalable
How We Built the Missing Bridge from Code to Figma
Technology

Jul 10, 2026

How We Built the Missing Bridge from Code to Figma
This blog explores how AI-generated React apps get turned into fully editable, designer-ready Figma files by reading React Fiber instead of the DOM.
Building a Resilient Hybrid-Cloud Network with WireGuard HA, Route-Based Failover, and Deep Observability
Technology

Jun 27, 2026

Building a Resilient Hybrid-Cloud Network with WireGuard HA, Route-Based Failover, and Deep Observability
A practical breakdown of building resilient AWS-to-on-premises connectivity with WireGuard HA, active-standby failover, and deep packet-forwarding observability.
We Built a 114-Second AWS-to-Azure Failover. Here’s What We Learned
Technology

Jun 19, 2026

We Built a 114-Second AWS-to-Azure Failover. Here’s What We Learned
A practical guide to building a 114-second multi-cloud disaster recovery failover between AWS and Azure — what we built, what broke, and what we learned.
Cloud-Native and Cloud-Agnostic Are Not Ideologies; They Are Business-Stage Decisions
Technology

Jun 12, 2026

Cloud-Native and Cloud-Agnostic Are Not Ideologies; They Are Business-Stage Decisions
This blog explains how organizations can balance speed, scalability, and operational flexibility as they grow from startup to enterprise scale.

The Right Conversation Can Save You Six Months.

Whether you’re navigating AI adoption, modernizing legacy systems, or scaling a product - we start by listening. No pitch deck. No template. A real conversation.