All widgets
Widget๐Ÿ† #1 on r/FlutterFlowDecember 2024

Skeleton Loader Shimmer

Drop-in shimmer loading animation for FlutterFlow. Five layout presets, fully customizable colors and speed โ€” no dependencies.

FlutterDartFlutterFlow
View on GitHub

Live preview

listItem preset

card preset

What's included

Features

5 layout presets

listItem, card, profile, text, and custom โ€” all ready to drop in without writing layout code.

Fully customizable colors

Pass any base and highlight color. Defaults to a clean grey shimmer that works with any app theme.

Adjustable shimmer speed

Control animation speed via shimmerSpeed (ms). Slow it down for emphasis, speed it up for snappiness.

Flexible line count

Set lineCount for text and custom presets. Last line auto-shortens for a natural paragraph look.

Pause animation

Set animate: false to freeze the skeleton as a static placeholder โ€” useful for storyboards or previews.

Optional avatar

Enable showAvatar on the custom preset to add a circle bone beside text lines. Great for chat or feed items.

Integration

How to use it

1

Copy the widget code

Open your FlutterFlow project โ†’ Custom Code โ†’ Custom Widgets โ†’ "+ Add Widget". Paste the full Dart code below.

2

Set your parameters

In the FlutterFlow UI, set layoutPreset to one of: listItem, card, profile, text, custom. Adjust colors and speed to match your design.

dart
SkeletonLoader(
  width: double.infinity,
  height: 80,
  layoutPreset: 'listItem',
  shimmerSpeed: 1200,
)
3

Show conditionally

Wrap in a Conditional widget. Show SkeletonLoader while your data is loading, swap to real content when data arrives.

dart
// Show skeleton while loading
if (isLoading) {
  return SkeletonLoader(layoutPreset: 'card');
}
// Show real content when ready
return YourContentWidget();

API reference

Parameters

ParameterTypeDefaultDescription
widthdouble?nullWidget width. Defaults to parent width.
heightdouble?nullWidget height. Defaults to content height.
layoutPresetString'listItem'Layout to render. One of: listItem, card, profile, text, custom.
lineCountint3Number of text lines. Used by text and custom presets.
baseColorColor?grey.shade300Base shimmer color.
highlightColorColor?grey.shade100Shimmer highlight color (the bright sweep).
shimmerSpeedint1500Animation duration in milliseconds.
borderRadiusdouble8.0Corner radius for all rectangular bones.
showAvatarboolfalseShow circle avatar bone. Only used in custom preset.
avatarSizedouble48.0Avatar circle diameter in pixels.
lineSpacingdouble12.0Vertical gap between text bones.
animatebooltrueSet false to freeze the shimmer animation.

Ready to copy

Preset examples

List Item

Avatar + two text lines. Perfect for user feeds, chat lists, or search results.

dart
SkeletonLoader(
  width: double.infinity,
  height: 80,
  layoutPreset: 'listItem',
  avatarSize: 48,
  shimmerSpeed: 1200,
)

Card

Image block + title + two body lines + action buttons. Great for content cards.

dart
SkeletonLoader(
  width: double.infinity,
  height: 320,
  layoutPreset: 'card',
  borderRadius: 12,
  shimmerSpeed: 1500,
)

Profile

Large center avatar + name + bio + stats row. Use on profile screens.

dart
SkeletonLoader(
  width: double.infinity,
  height: 260,
  layoutPreset: 'profile',
  avatarSize: 72,
  shimmerSpeed: 1800,
)

Text Placeholder

Text lines only. Great for article previews, descriptions, or any text-heavy content.

dart
SkeletonLoader(
  width: double.infinity,
  height: 100,
  layoutPreset: 'text',
  lineCount: 4,
  lineSpacing: 10,
)

Full source

Full widget code

Copy the entire file into FlutterFlow โ†’ Custom Code โ†’ Custom Widgets.

dart
// Automatic FlutterFlow imports
import '/flutter_flow/flutter_flow_theme.dart';
import '/flutter_flow/flutter_flow_util.dart';
import '/custom_code/widgets/index.dart'; // Imports other custom widgets
import '/flutter_flow/custom_functions.dart'; // Imports custom functions
import 'package:flutter/material.dart';
// Begin custom widget code
// DO NOT REMOVE OR MODIFY THE CODE ABOVE!

class SkeletonLoader extends StatefulWidget {
  const SkeletonLoader({
    super.key,
    this.width,
    this.height,
    this.layoutPreset = 'listItem',
    this.lineCount = 3,
    this.baseColor,
    this.highlightColor,
    this.shimmerSpeed = 1500,
    this.borderRadius = 8.0,
    this.showAvatar = false,
    this.avatarSize = 48.0,
    this.lineSpacing = 12.0,
    this.animate = true,
  });

  final double? width;
  final double? height;
  final String layoutPreset;
  final int lineCount;
  final Color? baseColor;
  final Color? highlightColor;
  final int shimmerSpeed;
  final double borderRadius;
  final bool showAvatar;
  final double avatarSize;
  final double lineSpacing;
  final bool animate;

  @override
  State<SkeletonLoader> createState() => _SkeletonLoaderState();
}

class _SkeletonLoaderState extends State<SkeletonLoader>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  Color get _baseColor => widget.baseColor ?? Colors.grey.shade300;

  Color get _highlightColor => widget.highlightColor ?? Colors.grey.shade100;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: widget.shimmerSpeed),
    );
    _animation = Tween<double>(begin: -2, end: 2).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine),
    );
    if (widget.animate) {
      _controller.repeat();
    }
  }

  @override
  void didUpdateWidget(SkeletonLoader oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.animate && !_controller.isAnimating) {
      _controller.repeat();
    } else if (!widget.animate && _controller.isAnimating) {
      _controller.stop();
    }
    if (widget.shimmerSpeed != oldWidget.shimmerSpeed) {
      _controller.duration = Duration(milliseconds: widget.shimmerSpeed);
      if (_controller.isAnimating) {
        _controller.repeat();
      }
    }
  }

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

  Widget _buildBone({
    double? width,
    double height = 16,
    bool isCircle = false,
    double? radius,
  }) {
    final effectiveRadius = radius ?? widget.borderRadius;
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Container(
          width: isCircle ? height : width,
          height: height,
          decoration: BoxDecoration(
            shape: isCircle ? BoxShape.circle : BoxShape.rectangle,
            borderRadius:
                isCircle ? null : BorderRadius.circular(effectiveRadius),
            gradient: widget.animate
                ? LinearGradient(
                    begin: Alignment(_animation.value - 1, 0),
                    end: Alignment(_animation.value + 1, 0),
                    colors: [
                      _baseColor,
                      _highlightColor,
                      _baseColor,
                    ],
                    stops: const [0.0, 0.5, 1.0],
                  )
                : null,
            color: widget.animate ? null : _baseColor,
          ),
        );
      },
    );
  }

  Widget _buildTextLines() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: List.generate(widget.lineCount, (index) {
        final isLast = index == widget.lineCount - 1;
        return Padding(
          padding: EdgeInsets.only(
            bottom: index < widget.lineCount - 1 ? widget.lineSpacing : 0,
          ),
          child: _buildBone(
            width: isLast ? 150 : double.infinity,
            height: 14,
          ),
        );
      }),
    );
  }

  Widget _buildListItem() {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        _buildBone(height: widget.avatarSize, isCircle: true),
        const SizedBox(width: 16),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              _buildBone(width: 180, height: 16),
              SizedBox(height: widget.lineSpacing),
              _buildBone(width: 120, height: 12),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildCard() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        _buildBone(
          width: double.infinity,
          height: 180,
          radius: widget.borderRadius,
        ),
        const SizedBox(height: 16),
        _buildBone(width: 200, height: 18),
        SizedBox(height: widget.lineSpacing),
        _buildBone(width: double.infinity, height: 13),
        SizedBox(height: widget.lineSpacing * 0.6),
        _buildBone(width: 250, height: 13),
        const SizedBox(height: 16),
        Row(
          children: [
            _buildBone(width: 80, height: 32, radius: 16),
            const SizedBox(width: 12),
            _buildBone(width: 80, height: 32, radius: 16),
          ],
        ),
      ],
    );
  }

  Widget _buildProfile() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.center,
      mainAxisSize: MainAxisSize.min,
      children: [
        _buildBone(height: widget.avatarSize * 1.8, isCircle: true),
        const SizedBox(height: 16),
        _buildBone(width: 160, height: 20),
        SizedBox(height: widget.lineSpacing),
        _buildBone(width: 220, height: 13),
        SizedBox(height: widget.lineSpacing * 0.6),
        _buildBone(width: 180, height: 13),
        const SizedBox(height: 20),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            _buildStatBlock(),
            const SizedBox(width: 32),
            _buildStatBlock(),
            const SizedBox(width: 32),
            _buildStatBlock(),
          ],
        ),
      ],
    );
  }

  Widget _buildStatBlock() {
    return Column(
      children: [
        _buildBone(width: 40, height: 18),
        const SizedBox(height: 6),
        _buildBone(width: 56, height: 11),
      ],
    );
  }

  Widget _buildCustom() {
    if (widget.showAvatar) {
      return Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildBone(height: widget.avatarSize, isCircle: true),
          const SizedBox(width: 16),
          Expanded(child: _buildTextLines()),
        ],
      );
    }
    return _buildTextLines();
  }

  @override
  Widget build(BuildContext context) {
    Widget content;

    switch (widget.layoutPreset.toLowerCase()) {
      case 'listitem':
        content = _buildListItem();
        break;
      case 'card':
        content = _buildCard();
        break;
      case 'profile':
        content = _buildProfile();
        break;
      case 'text':
        content = _buildTextLines();
        break;
      case 'custom':
      default:
        content = _buildCustom();
        break;
    }

    return Container(
      width: widget.width,
      height: widget.height,
      padding: const EdgeInsets.all(16),
      child: content,
    );
  }
}

From the trenches

Pro tips

Stack multiple skeletons

Use a ListView.builder with 3โ€“5 SkeletonLoader items to simulate a full page loading. Users perceive this as faster than a single spinner.

Match your app's colors

Set baseColor to a slightly tinted version of your background. For dark themes: use Colors.grey.shade800 / Colors.grey.shade600.

Tune shimmer speed

1200ms feels snappy and modern. 2000ms feels calm and premium. Match the speed to your app's personality.

Avoid layout shifts

Set explicit width and height on your SkeletonLoader so it matches the real content dimensions. This prevents jarring jumps when data loads.

Building an app?

Need a full MVP, not just widgets?

I build complete apps for founders โ€” fixed prices, fast delivery. Book a free 30-minute call and let's talk about your idea.

Book a free call