image

16 Mar 2026

9K

35K

Flutter Parallax Scroll Animations for Background and List

Parallax scrolling is a compelling visual effect where background content moves slower than foreground content when scrolling, creating an illusion of depth and immersion. In Flutter, achieving sophisticated parallax animations for both backgrounds and list items is highly flexible and performant, enhancing the user experience significantly.

Understanding Parallax in Flutter

The core principle of parallax relies on varying scroll speeds for different elements. When a user scrolls a certain distance, foreground elements might move 1:1 with the scroll, while background elements move at a fraction of that speed (e.g., 0.5:1). Flutter's declarative UI and robust scrolling mechanisms make this effect relatively straightforward to implement by manipulating widget positions or transformations based on the scroll offset.

Implementing Parallax for Backgrounds

For a background parallax effect, you typically use a scrollable widget (like SingleChildScrollView or CustomScrollView) to house your main content, and within a Stack, position your background image or widget. The background's vertical position is then adjusted based on the scroll controller's offset.

Example: Background Parallax

Consider a scenario where you have a tall background image that should scroll slower than the main content.


import 'package:flutter/material.dart';

class ParallaxBackgroundScreen extends StatefulWidget {
  @override
  _ParallaxBackgroundScreenState createState() => _ParallaxBackgroundScreenState();
}

class _ParallaxBackgroundScreenState extends State {
  final ScrollController _scrollController = ScrollController();
  double _offset = 0.0;

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(() {
      setState(() {
        _offset = _scrollController.offset;
      });
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Parallax Background')),
      body: Stack(
        children: [
          // Background Image with Parallax Effect
          Positioned(
            top: -0.5 * _offset, // Adjust multiplier for parallax speed
            left: 0,
            right: 0,
            child: SizedBox(
              height: 800, // Make sure background content is taller than screen
              child: Image.network(
                'https://picsum.photos/id/1018/800/1200', // Example tall image
                fit: BoxFit.cover,
                alignment: Alignment.topCenter,
              ),
            ),
          ),
          // Foreground Content
          SingleChildScrollView(
            controller: _scrollController,
            child: Column(
              children: [
                Container(
                  height: 300,
                  color: Colors.transparent, // Placeholder to push content down
                  alignment: Alignment.bottomCenter,
                  padding: EdgeInsets.only(bottom: 20),
                  child: Text(
                    'Welcome to the Parallax World!',
                    style: TextStyle(color: Colors.white, fontSize: 24, shadows: [
                      Shadow(offset: Offset(1,1), blurRadius: 3, color: Colors.black)
                    ]),
                  ),
                ),
                Container(
                  color: Colors.white,
                  child: Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: Column(
                      children: List.generate(20, (index) => Padding(
                        padding: const EdgeInsets.symmetric(vertical: 8.0),
                        child: Text(
                          'Content item ${index + 1}. This content scrolls normally over the parallax background. '
                          'The background image moves at a slower rate, creating a sense of depth.',
                          style: TextStyle(fontSize: 16),
                        ),
                      )),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

In this example, the background image's top position is dynamically updated by -0.5 * _offset. A smaller multiplier (e.g., 0.3) would make the background move even slower, while 0 would make it stationary.

Implementing Parallax for List Items

Applying parallax to individual list items creates a dynamic and engaging scroll experience. Each item's visual properties (e.g., vertical position, scale, opacity) are manipulated based on its current position within the viewport relative to the scroll controller's offset.

Example: List Item Parallax

This implementation often involves using a ListView.builder and a NotificationListener or direct access to the ScrollController to determine each item's viewport position.


import 'package:flutter/material.dart';

class ParallaxListItemScreen extends StatefulWidget {
  @override
  _ParallaxListItemScreenState createState() => _ParallaxListItemScreenState();
}

class _ParallaxListItemScreenState extends State {
  final ScrollController _scrollController = ScrollController();

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Parallax List Items')),
      body: ListView.builder(
        controller: _scrollController,
        itemCount: 15,
        itemExtent: 200, // Fixed height for simplicity in calculation
        itemBuilder: (context, index) {
          return AnimatedBuilder(
            animation: _scrollController,
            builder: (context, child) {
              final itemKey = GlobalKey();
              // Calculate item's position relative to the scroll view
              final RenderBox? renderBox = itemKey.currentContext?.findRenderObject() as RenderBox?;
              if (renderBox == null) {
                return ParallaxListItem(
                  itemKey: itemKey,
                  index: index,
                  scrollOffset: 0,
                  itemHeight: 200, // Must match itemExtent
                );
              }

              final itemOffset = renderBox.localToGlobal(Offset.zero).dy;
              final viewportHeight = MediaQuery.of(context).size.height;
              
              // Calculate the center of the item relative to the viewport center
              // A simple parallax effect based on how far the item is from the screen's center
              final screenCenter = viewportHeight / 2;
              final itemCenter = itemOffset + (200 / 2); // 200 is itemHeight
              final distanceToCenter = screenCenter - itemCenter;

              // Apply a small translation
              final parallaxTranslation = distanceToCenter * 0.1; // Adjust multiplier

              return Transform.translate(
                offset: Offset(0, parallaxTranslation),
                child: ParallaxListItem(
                  itemKey: itemKey,
                  index: index,
                  scrollOffset: _scrollController.offset, // Not directly used in this specific item parallax, but useful for other effects
                  itemHeight: 200,
                ),
              );
            },
          );
        },
      ),
    );
  }
}

class ParallaxListItem extends StatelessWidget {
  final Key itemKey;
  final int index;
  final double scrollOffset;
  final double itemHeight;

  const ParallaxListItem({
    required this.itemKey,
    required this.index,
    required this.scrollOffset,
    required this.itemHeight,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      key: itemKey, // Attach key for position calculation
      margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      decoration: BoxDecoration(
        color: Colors.blueGrey[100 + (index % 5) * 100],
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.1),
            blurRadius: 4,
            offset: Offset(0, 2),
          ),
        ],
      ),
      alignment: Alignment.center,
      child: Text(
        'Item ${index + 1}',
        style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white),
      ),
    );
  }
}

In the list item example, we wrap each item in an AnimatedBuilder that listens to the _scrollController. Inside the builder, we calculate the item's current screen position using its GlobalKey and RenderBox. The parallaxTranslation is then applied via Transform.translate, causing items near the screen's edges to move slightly differently than those in the center.

Alternative for List Item Parallax: LayoutBuilder with Viewport

For more robust list item parallax, especially for items with variable heights or more complex effects, you can use LayoutBuilder within each list item to get its local position relative to the scrollable viewport. This approach eliminates the need for GlobalKey and direct RenderBox lookup, which can be less performant for many items.


// This snippet shows the concept within a ListView.builder's item
// The ParallaxFlowDelegate example below is a more robust solution for complex parallax.

Widget _buildParallaxListItem(BuildContext context, int index) {
  return Container(
    height: 200, // Fixed height for simplicity
    margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
    child: ClipRRect(
      borderRadius: BorderRadius.circular(12),
      child: Stack(
        children: [
          // Background image for the list item
          Positioned.fill(
            child: Image.network(
              'https://picsum.photos/id/${10 + index}/400/300',
              fit: BoxFit.cover,
            ),
          ),
          // Parallax effect applied to the image based on its viewport position
          // Using LayoutBuilder to get local constraints
          LayoutBuilder(
            builder: (BuildContext context, BoxConstraints constraints) {
              // This is a simplified calculation.
              // In a real scenario, you'd need the item's scroll position
              // relative to the viewport. This often involves a ScrollNotification
              // or passing the scroll offset down.
              // For demonstration, let's assume a simplified effect.
              // To get the actual offset, one would need to use a GlobalKey or
              // listen to ScrollNotifications higher up and pass the scroll offset.
              
              // A more advanced approach would involve a NotificationListener.
              // For now, let's just illustrate the idea of applying transform.
              // This specific `LayoutBuilder` here will only give you the item's size,
              // not its position within the overall scroll view directly.
              
              // To make this work, you'd typically pass the current scroll offset
              // from the ListView's controller down to this builder.
              // Example:
              // final double itemScrollOffset = (index * constraints.maxHeight) - _scrollController.offset;
              // final double parallaxOffset = itemScrollOffset * 0.2; // Example effect

              // For a simple illustrative transform without actual scroll data passed in:
              return Transform.translate(
                offset: Offset(0, 0), // Placeholder, would be based on scrollOffset
                child: Image.network(
                  'https://picsum.photos/id/${10 + index}/400/300',
                  fit: BoxFit.cover,
                ),
              );
            },
          ),
          Align(
            alignment: Alignment.center,
            child: Text(
              'Item ${index + 1}',
              style: TextStyle(
                fontSize: 28,
                fontWeight: FontWeight.bold,
                color: Colors.white,
                shadows: [Shadow(blurRadius: 5, color: Colors.black)],
              ),
            ),
          ),
        ],
      ),
    ),
  );
}

For a truly elegant and performant list item parallax, consider using a CustomScrollView with a SliverList and a custom SliverChildBuilderDelegate. This allows you to observe each child's scroll progress and apply transformations with greater control.

Advanced List Item Parallax with Flow Widget

The Flow widget provides a highly optimized way to implement custom layouts and painting effects, making it excellent for complex parallax. You define a FlowDelegate that receives the scroll offset and calculates the transformation for each child.


import 'package:flutter/material.dart';

class ParallaxFlowDelegate extends FlowDelegate {
  final ScrollableState scrollable;
  final Widget listItemContext; // The context of the list item
  final double parallaxFactor;

  ParallaxFlowDelegate({
    required this.scrollable,
    required this.listItemContext,
    this.parallaxFactor = 0.5,
  }) : super(repaint: scrollable.position); // Repaint when scroll position changes

  @override
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    return BoxConstraints.tightFor(width: constraints.maxWidth);
  }

  @override
  void paintChildren(FlowPaintingContext context) {
    RenderBox? renderBox = listItemContext.findRenderObject() as RenderBox?;
    if (renderBox == null) return;

    final scrollExtent = scrollable.position.extentInside;
    final viewportDimension = scrollable.position.viewportDimension;
    final scrollOffset = scrollable.position.pixels;

    // Calculate the item's center within the viewport
    final itemGlobalOffset = renderBox.localToGlobal(Offset.zero);
    final itemCenter = itemGlobalOffset.dy + context.getChildSize(0)!.height / 2;
    
    // Calculate the difference from the viewport center
    final viewportCenter = viewportDimension / 2;
    final distanceToCenter = viewportCenter - itemCenter;

    // Apply parallax translation based on distance from center
    final parallax = distanceToCenter * parallaxFactor;

    context.paintChild(
      0, // Assuming one child in the Flow
      transform: Matrix4.translationValues(0.0, parallax, 0.0),
    );
  }

  @override
  bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
    return scrollable != oldDelegate.scrollable ||
           listItemContext != oldDelegate.listItemContext ||
           parallaxFactor != oldDelegate.parallaxFactor;
  }
}

// How to use it in a ListView.builder:
class ParallaxFlowListItemScreen extends StatefulWidget {
  @override
  _ParallaxFlowListItemScreenState createState() => _ParallaxFlowListItemScreenState();
}

class _ParallaxFlowListItemScreenState extends State {
  final ScrollController _scrollController = ScrollController();

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Parallax Flow List Items')),
      body: ListView.builder(
        controller: _scrollController,
        itemCount: 20,
        itemExtent: 200,
        itemBuilder: (context, index) {
          return Padding(
            padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
            child: ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: SizedBox(
                height: 200,
                child: Flow(
                  delegate: ParallaxFlowDelegate(
                    scrollable: Scrollable.of(context),
                    listItemContext: context, // Pass the current item's context
                    parallaxFactor: 0.2,
                  ),
                  children: [
                    Image.network(
                      'https://picsum.photos/id/${100 + index}/600/400',
                      fit: BoxFit.cover,
                      alignment: Alignment.center,
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

Key Considerations and Best Practices

  • Performance: Parallax effects involve frequent widget rebuilding or transformation. Use AnimatedBuilder with an animation property (like a ScrollController) to limit rebuilds to only the widgets that need to react to scroll changes. Consider RepaintBoundary for complex child widgets that don't need to be repainted when only their position changes.
  • Smoothness: Ensure the parallax calculations are simple and efficient. Avoid heavy computations in the scroll listener.
  • Parallax Factor: Experiment with different parallax factors (multipliers) to find the right balance for your design. Too high a factor might make the effect jarring, while too low might make it unnoticeable.
  • Content Alignment: For background parallax, ensure your background content is sufficiently large to fill the viewport even when shifted. For list items, be mindful of how transformations might clip content.
  • Accessibility: While visually appealing, parallax effects can sometimes be distracting. Ensure the core information remains clear and accessible. Provide alternatives or options to disable complex animations if necessary.
  • Responsiveness: Parallax effects should scale gracefully across different screen sizes and orientations. Ensure your calculations adapt to the viewport dimensions.

Conclusion

Flutter offers powerful primitives and widgets to create captivating parallax scroll animations for both backgrounds and individual list items. By leveraging ScrollController, AnimatedBuilder, Stack, Transform, and potentially advanced widgets like Flow, developers can craft highly immersive and visually rich user interfaces that stand out. Implementing these effects thoughtfully, with attention to performance and user experience, will undoubtedly elevate your Flutter applications.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is