Fliin
On this page

Components / data-display

Scroll shadow

Fades the edges of a scrolling list into the page to show there is more to scroll, vertically or horizontally.

16 of 16 variants pass
Scroll shadow, vertical state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add scroll-shadowAlso installs: core

Examples

Vertical

Shadows fade in at the top and bottom edges while there is more to read; wrap any scrolling list in FlScrollShadow.

Scroll shadow, vertical state
import 'package:your_app/ui/fliin/components/scroll_shadow/fl_scroll_shadow.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ScrollShadowVerticalExample> createState() => _ScrollShadowVerticalExampleState();
}

class _ScrollShadowVerticalExampleState extends State<ScrollShadowVerticalExample> {
  final _scroll = ScrollController(initialScrollOffset: 60);

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

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 200,
      child: FlScrollShadow(
        child: ListView(
          controller: _scroll,
          children: [
            const FlText('Long Content', type: FlTextType.h5),
            for (var i = 0; i < 3; i++)
              Padding(
                padding: EdgeInsetsDirectional.only(top: FlScope.of(context).space.sm),
                child: const FlText(
                  'Scroll to see the shadows fade in at the top and bottom edges. They tell the reader there is more '
                  'to read above or below the visible part.',
                ),
              ),
          ],
        ),
      ),
    );
  }
}

Horizontal

On a horizontal list, the shadows sit at the start and end edges, and follow the reading direction.

Scroll shadow, horizontal state
import 'package:your_app/ui/fliin/components/scroll_shadow/fl_scroll_shadow.dart';
import 'package:your_app/ui/fliin/components/surface/fl_surface.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 96,
      child: FlScrollShadow(
        child: ListView(
          scrollDirection: Axis.horizontal,
          children: [
            for (var i = 1; i <= 10; i++)
              Padding(
                padding: EdgeInsetsDirectional.only(end: FlScope.of(context).space.sm),
                child: SizedBox(
                  width: 128,
                  child: FlSurface(
                    variant: FlSurfaceVariant.secondary,
                    child: Center(child: FlText('Card $i')),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

With card

Terms to read inside a card: with mask, the text itself fades out at the edges, whatever the card's color, instead of a shadow painted in the page color.

Scroll shadow, with-card state
import 'package:your_app/ui/fliin/components/button/fl_button.dart';
import 'package:your_app/ui/fliin/components/card/fl_card.dart';
import 'package:your_app/ui/fliin/components/scroll_shadow/fl_scroll_shadow.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ScrollShadowWithCardExample> createState() =>
      _ScrollShadowWithCardExampleState();
}

class _ScrollShadowWithCardExampleState
    extends State<ScrollShadowWithCardExample> {
  final _scroll = ScrollController(initialScrollOffset: 80);

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

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return FlCard(
      children: [
        const FlCardBody(
          children: [
            FlCardTitle('Terms and Conditions'),
            FlCardDescription('Please review before proceeding'),
          ],
        ),
        SizedBox(
          height: 180,
          child: FlScrollShadow(
            mask: true,
            size: 64,
            child: ListView.separated(
              controller: _scroll,
              itemCount: 6,
              separatorBuilder: (context, index) => SizedBox(height: space.md),
              itemBuilder: (context, index) => const FlText(
                'By using the service you agree to these terms. We may update '
                'them; we will tell you before they change.',
                type: FlTextType.bodySm,
              ),
            ),
          ),
        ),
        FlCardFooter(
          spacing: space.sm,
          children: [
            FlButton(label: 'Accept', onPressed: () {}),
            FlButton(
              label: 'Cancel',
              variant: FlButtonVariant.secondary,
              onPressed: () {},
            ),
          ],
        ),
      ],
    );
  }
}

Visibility change

onVisibilityChange tells which edges hide content as the user scrolls: top, bottom, both or none. Show a "back to top" button from it, or mark a text as read.

Scroll shadow, visibility-change state
import 'package:your_app/ui/fliin/components/scroll_shadow/fl_scroll_shadow.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';

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

  @override
  State<ScrollShadowVisibilityChangeExample> createState() =>
      _ScrollShadowVisibilityChangeExampleState();
}

class _ScrollShadowVisibilityChangeExampleState
    extends State<ScrollShadowVisibilityChangeExample> {
  var _visibility = FlScrollShadowVisibility.none;

  @override
  Widget build(BuildContext context) {
    final space = FlScope.of(context).space;
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: space.md,
      children: [
        FlText(
          'Hidden content: ${_visibility.name}',
          type: FlTextType.bodySm,
          tone: FlTextTone.muted,
        ),
        SizedBox(
          height: 160,
          child: FlScrollShadow(
            onVisibilityChange: (visibility) =>
                setState(() => _visibility = visibility),
            child: ListView.separated(
              itemCount: 6,
              separatorBuilder: (context, index) => SizedBox(height: space.md),
              itemBuilder: (context, index) => FlText(
                'Paragraph ${index + 1}. Scroll to the end and the label '
                'above changes.',
              ),
            ),
          ),
        ),
      ],
    );
  }
}

Usage

The code the reference images render:

final example = ColoredBox(
  color: FlScope.of(context).colors.surface,
  child: Padding(
    padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: FlScope.of(context).space.sm,
      children: [
        SizedBox(
          height: 160,
          child: FlScrollShadow(
            child: ListView(
              controller: ScrollController(initialScrollOffset: 80),
              children: [
                for (var i = 0; i < 8; i++)
                  Padding(
                    padding: EdgeInsetsDirectional.only(
                      bottom: FlScope.of(context).space.sm,
                    ),
                    child: SizedBox(
                      width: null,
                      height: 40,
                      child: DecoratedBox(
                        decoration: BoxDecoration(
                          color: FlScope.of(context).colors.surfaceSecondary,
                          borderRadius: BorderRadius.all(
                            Radius.circular(FlScope.of(context).shape.radiusMd),
                          ),
                        ),
                      ),
                    ),
                  ),
              ],
            ),
          ),
        ),
        SizedBox(
          height: 40,
          child: FlScrollShadow(
            child: ListView(
              scrollDirection: Axis.horizontal,
              children: [
                for (var i = 0; i < 8; i++)
                  Padding(
                    padding: EdgeInsetsDirectional.only(
                      end: FlScope.of(context).space.sm,
                    ),
                    child: SizedBox(
                      width: 96,
                      height: 40,
                      child: DecoratedBox(
                        decoration: BoxDecoration(
                          color: FlScope.of(context).colors.surfaceSecondary,
                          borderRadius: BorderRadius.all(
                            Radius.circular(FlScope.of(context).shape.radiusMd),
                          ),
                        ),
                      ),
                    ),
                  ),
              ],
            ),
          ),
        ),
      ],
    ),
  ),
);

API

FlScrollShadowVisibility: enum {auto, both, top, bottom, start, end, none}
FlScrollShadowVisibility.auto: static final FlScrollShadowVisibility
FlScrollShadowVisibility.both: static final FlScrollShadowVisibility
FlScrollShadowVisibility.top: static final FlScrollShadowVisibility
FlScrollShadowVisibility.bottom: static final FlScrollShadowVisibility
FlScrollShadowVisibility.start: static final FlScrollShadowVisibility
FlScrollShadowVisibility.end: static final FlScrollShadowVisibility
FlScrollShadowVisibility.none: static final FlScrollShadowVisibility
FlScrollShadow: class extends StatefulWidget
FlScrollShadow.new: const new({super.key, double size, FlScrollShadowVisibility visibility, bool enabled, Color? color, bool mask, ValueChanged<FlScrollShadowVisibility>? onVisibilityChange, required Widget child})
FlScrollShadow.size: get double
FlScrollShadow.visibility: get FlScrollShadowVisibility
FlScrollShadow.enabled: get bool
FlScrollShadow.color: get Color?
FlScrollShadow.mask: get bool
FlScrollShadow.onVisibilityChange: get ValueChanged<FlScrollShadowVisibility>?
FlScrollShadow.child: get Widget
FlScrollShadow.createState: State<FlScrollShadow>()

Reference renders

The 16 images published with version 0.2.1, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify scroll-shadow compares your copy to them.

iOS · Light
Scroll shadow, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Scroll shadow, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Scroll shadow, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Scroll shadow, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Scroll shadow, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Scroll shadow, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Scroll shadow, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Scroll shadow, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Scroll shadow, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Scroll shadow, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Scroll shadow, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Scroll shadow, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Scroll shadow, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Scroll shadow, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Scroll shadow, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Scroll shadow, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • show that a list continues past its edge
  • hide the edges of a scrolling area behind a fade
  • show users there is more to scroll
  • add a gradient at the top and bottom of a scroll view
  • make a horizontal carousel hint at more items
Not for
  • separating two fixed blocks: use a separator (separator)

Source

The files fliin add scroll-shadow copies into your app, before your prefix and imports are applied.

scroll_shadow/fl_scroll_shadow.dart

// Modified from the ScrollShadow of HeroUI Native 1.0.10, Copyright 2025 NextUI
// Inc., licensed under Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
// The eased gradient follows easeGradient from react-native-easing-gradient
// (@phamfoo, MIT).
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';

/// The edges of an [FlScrollShadow] that can carry a shadow. `top` and
/// `start` name the same edge: the top of a vertical scroll, the start (on
/// the right in RTL) of a horizontal scroll; `bottom` and `end`, the
/// opposite edge.
enum FlScrollShadowVisibility {
  /// Both edges, each when content overflows on its side.
  auto,

  /// Same as [auto].
  both,

  /// The top, or the start: the edge where already-scrolled content
  /// overflows.
  top,

  /// The bottom, or the end: the edge where upcoming content overflows.
  bottom,

  /// Same as [top], for a horizontal scroll.
  start,

  /// Same as [bottom], for a horizontal scroll.
  end,

  /// No edge.
  none,
}

/// Fades the edges of a scrollable into the page color: a gradient of
/// [size] points at each edge where content overflows, opaque at the edge
/// and transparent toward the inside, along `motion.curve.easeInOut`. Its
/// opacity follows the scroll: zero when the edge is reached, full once a
/// quarter of [size] of content is hidden on that side.
///
/// [child] is the scrollable (`ListView`, `SingleChildScrollView`…), whose
/// axis gives the orientation. A shadow removed by [visibility] or
/// [enabled] fades out over `motion.duration.short4`, at once under reduced
/// motion.
class FlScrollShadow extends StatefulWidget {
  /// Shadows on both edges, 50 points deep, `surface` colored.
  const FlScrollShadow({
    super.key,
    this.size = 50,
    this.visibility = FlScrollShadowVisibility.auto,
    this.enabled = true,
    this.color,
    this.mask = false,
    this.onVisibilityChange,
    required this.child,
  });

  /// The depth of each gradient, in points: 50 by default.
  final double size;

  /// The edges that can carry a shadow.
  final FlScrollShadowVisibility visibility;

  /// False, both shadows fade out.
  final bool enabled;

  /// The shadow color; null, `surface`, the page background. Unused with
  /// [mask].
  final Color? color;

  /// True: the content itself fades out at the edges, as a mask, instead of
  /// a color painted over it, so the shadow works over a gradient, an image
  /// or a tinted card.
  final bool mask;

  /// Called with the edges where content is hidden, whenever they change
  /// while scrolling: [FlScrollShadowVisibility.top], `bottom`, `both` or
  /// `none` for a vertical scroll, `start`, `end`, `both` or `none` for a
  /// horizontal one.
  final ValueChanged<FlScrollShadowVisibility>? onVisibilityChange;

  /// The scrollable.
  final Widget child;

  @override
  State<FlScrollShadow> createState() => _FlScrollShadowState();
}

class _FlScrollShadowState extends State<FlScrollShadow> with TickerProviderStateMixin {
  late final AnimationController _leading = AnimationController(vsync: this);
  late final AnimationController _trailing = AnimationController(vsync: this);
  ScrollMetrics? _metrics;
  FlScrollShadowVisibility? _reported;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _sync();
  }

  @override
  void didUpdateWidget(FlScrollShadow oldWidget) {
    super.didUpdateWidget(oldWidget);
    _sync();
  }

  @override
  void dispose() {
    _leading.dispose();
    _trailing.dispose();
    super.dispose();
  }

  bool _onScroll(int depth, ScrollMetrics metrics) {
    if (depth != 0) return false;
    final axisChanged = metrics.axis != _metrics?.axis;
    _metrics = metrics;
    if (axisChanged) setState(() {});
    _sync();
    _report(metrics);
    return false;
  }

  void _report(ScrollMetrics metrics) {
    final onVisibilityChange = widget.onVisibilityChange;
    if (onVisibilityChange == null) return;
    final reading = switch (metrics.axis) {
      Axis.vertical => AxisDirection.down,
      Axis.horizontal => textDirectionToAxisDirection(Directionality.of(context)),
    };
    final forward = metrics.axisDirection == reading;
    final before = (forward ? metrics.extentBefore : metrics.extentAfter) > 0;
    final after = (forward ? metrics.extentAfter : metrics.extentBefore) > 0;
    final vertical = metrics.axis == Axis.vertical;
    final visibility = switch ((before, after)) {
      (true, true) => FlScrollShadowVisibility.both,
      (true, false) => vertical ? FlScrollShadowVisibility.top : FlScrollShadowVisibility.start,
      (false, true) => vertical ? FlScrollShadowVisibility.bottom : FlScrollShadowVisibility.end,
      (false, false) => FlScrollShadowVisibility.none,
    };
    if (visibility == _reported) return;
    _reported = visibility;
    // The first metrics arrive during layout, where the parent cannot
    // rebuild yet.
    if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
      SchedulerBinding.instance.addPostFrameCallback((_) {
        if (mounted) onVisibilityChange(visibility);
      });
    } else {
      onVisibilityChange(visibility);
    }
  }

  void _sync() {
    final metrics = _metrics;
    if (metrics == null) return;
    final reading = switch (metrics.axis) {
      Axis.vertical => AxisDirection.down,
      Axis.horizontal => textDirectionToAxisDirection(Directionality.of(context)),
    };
    final forward = metrics.axisDirection == reading;
    final (leading, trailing) = switch (widget.visibility) {
      _ when !widget.enabled => (false, false),
      FlScrollShadowVisibility.auto || FlScrollShadowVisibility.both => (true, true),
      FlScrollShadowVisibility.top || FlScrollShadowVisibility.start => (true, false),
      FlScrollShadowVisibility.bottom || FlScrollShadowVisibility.end => (false, true),
      FlScrollShadowVisibility.none => (false, false),
    };
    final motion = FlMotion.of(context);
    void drive(AnimationController shade, {required bool shown, required double hidden}) {
      if (shown) {
        shade.value = (hidden / (widget.size / 4)).clamp(0.0, 1.0);
      } else if (motion.reduced) {
        shade.value = 0;
      } else if (shade.value > 0 && !shade.isAnimating) {
        shade.animateTo(0, duration: motion.tokens.short4, curve: motion.tokens.easeInOutQuad);
      }
    }

    drive(_leading, shown: leading, hidden: forward ? metrics.extentBefore : metrics.extentAfter);
    drive(_trailing, shown: trailing, hidden: forward ? metrics.extentAfter : metrics.extentBefore);
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final color = widget.color ?? theme.colors.surface;
    final ease = theme.motion.easeInOut;
    // An eased gradient: 14 stops, opacity eased from the edge toward
    // the inside.
    final steps = [for (var i = 0; i <= 13; i++) i / 13];
    final colors = [for (final t in steps) color.withValues(alpha: 1 - ease.transform(t))];
    Widget shade(AnimationController opacity, AlignmentGeometry edge, AlignmentGeometry inside) => IgnorePointer(
      child: FadeTransition(
        opacity: opacity,
        child: DecoratedBox(
          decoration: BoxDecoration(
            gradient: LinearGradient(begin: edge, end: inside, colors: colors, stops: steps),
          ),
        ),
      ),
    );
    final size = widget.size;
    final axis = _metrics?.axis;
    Widget listen(Widget child) => NotificationListener<ScrollMetricsNotification>(
      onNotification: (n) => _onScroll(n.depth, n.metrics),
      child: NotificationListener<ScrollNotification>(
        onNotification: (n) => _onScroll(n.depth, n.metrics),
        child: child,
      ),
    );
    if (widget.mask) {
      return listen(
        AnimatedBuilder(
          animation: Listenable.merge([_leading, _trailing]),
          builder: (context, child) => axis == null
              ? child!
              : ShaderMask(
                  blendMode: BlendMode.dstIn,
                  shaderCallback: (bounds) {
                    final extent = axis == Axis.vertical ? bounds.height : bounds.width;
                    final reach = extent <= 0 ? 0.0 : (size / extent).clamp(0.0, 0.5);
                    // The content's opacity, eased from the edge inward as
                    // the colored shadow's.
                    final stops = [for (final t in steps) t * reach, for (final t in steps.reversed) 1 - t * reach];
                    final alphas = [
                      for (final t in steps) 1 - _leading.value * (1 - ease.transform(t)),
                      for (final t in steps.reversed) 1 - _trailing.value * (1 - ease.transform(t)),
                    ];
                    final opaque = theme.colors.onSurface;
                    return LinearGradient(
                      begin: axis == Axis.vertical ? Alignment.topCenter : AlignmentDirectional.centerStart,
                      end: axis == Axis.vertical ? Alignment.bottomCenter : AlignmentDirectional.centerEnd,
                      colors: [for (final a in alphas) opaque.withValues(alpha: a)],
                      stops: stops,
                    ).createShader(bounds, textDirection: Directionality.of(context));
                  },
                  child: child,
                ),
          child: widget.child,
        ),
      );
    }
    return listen(
      Stack(
        children: [
          widget.child,
          ...switch (axis) {
            null => const <Widget>[],
            Axis.vertical => [
              PositionedDirectional(
                top: 0,
                start: 0,
                end: 0,
                height: size,
                child: shade(_leading, Alignment.topCenter, Alignment.bottomCenter),
              ),
              PositionedDirectional(
                bottom: 0,
                start: 0,
                end: 0,
                height: size,
                child: shade(_trailing, Alignment.bottomCenter, Alignment.topCenter),
              ),
            ],
            Axis.horizontal => [
              PositionedDirectional(
                start: 0,
                top: 0,
                bottom: 0,
                width: size,
                child: shade(_leading, AlignmentDirectional.centerStart, AlignmentDirectional.centerEnd),
              ),
              PositionedDirectional(
                end: 0,
                top: 0,
                bottom: 0,
                width: size,
                child: shade(_trailing, AlignmentDirectional.centerEnd, AlignmentDirectional.centerStart),
              ),
            ],
          },
        ],
      ),
    );
  }
}

Arrows to move, Enter to open, Escape to close.