Drop-in shimmer loading animation for FlutterFlow. Five layout presets, fully customizable colors and speed โ no dependencies.
Live preview
listItem preset
card preset
What's included
listItem, card, profile, text, and custom โ all ready to drop in without writing layout code.
Pass any base and highlight color. Defaults to a clean grey shimmer that works with any app theme.
Control animation speed via shimmerSpeed (ms). Slow it down for emphasis, speed it up for snappiness.
Set lineCount for text and custom presets. Last line auto-shortens for a natural paragraph look.
Set animate: false to freeze the skeleton as a static placeholder โ useful for storyboards or previews.
Enable showAvatar on the custom preset to add a circle bone beside text lines. Great for chat or feed items.
Integration
Open your FlutterFlow project โ Custom Code โ Custom Widgets โ "+ Add Widget". Paste the full Dart code below.
In the FlutterFlow UI, set layoutPreset to one of: listItem, card, profile, text, custom. Adjust colors and speed to match your design.
SkeletonLoader(
width: double.infinity,
height: 80,
layoutPreset: 'listItem',
shimmerSpeed: 1200,
)Wrap in a Conditional widget. Show SkeletonLoader while your data is loading, swap to real content when data arrives.
// Show skeleton while loading
if (isLoading) {
return SkeletonLoader(layoutPreset: 'card');
}
// Show real content when ready
return YourContentWidget();API reference
| Parameter | Type | Default | Description |
|---|---|---|---|
| width | double? | null | Widget width. Defaults to parent width. |
| height | double? | null | Widget height. Defaults to content height. |
| layoutPreset | String | 'listItem' | Layout to render. One of: listItem, card, profile, text, custom. |
| lineCount | int | 3 | Number of text lines. Used by text and custom presets. |
| baseColor | Color? | grey.shade300 | Base shimmer color. |
| highlightColor | Color? | grey.shade100 | Shimmer highlight color (the bright sweep). |
| shimmerSpeed | int | 1500 | Animation duration in milliseconds. |
| borderRadius | double | 8.0 | Corner radius for all rectangular bones. |
| showAvatar | bool | false | Show circle avatar bone. Only used in custom preset. |
| avatarSize | double | 48.0 | Avatar circle diameter in pixels. |
| lineSpacing | double | 12.0 | Vertical gap between text bones. |
| animate | bool | true | Set false to freeze the shimmer animation. |
Ready to copy
Avatar + two text lines. Perfect for user feeds, chat lists, or search results.
SkeletonLoader(
width: double.infinity,
height: 80,
layoutPreset: 'listItem',
avatarSize: 48,
shimmerSpeed: 1200,
)Image block + title + two body lines + action buttons. Great for content cards.
SkeletonLoader(
width: double.infinity,
height: 320,
layoutPreset: 'card',
borderRadius: 12,
shimmerSpeed: 1500,
)Large center avatar + name + bio + stats row. Use on profile screens.
SkeletonLoader(
width: double.infinity,
height: 260,
layoutPreset: 'profile',
avatarSize: 72,
shimmerSpeed: 1800,
)Text lines only. Great for article previews, descriptions, or any text-heavy content.
SkeletonLoader(
width: double.infinity,
height: 100,
layoutPreset: 'text',
lineCount: 4,
lineSpacing: 10,
)Full source
Copy the entire file into FlutterFlow โ Custom Code โ Custom Widgets.
// 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
Use a ListView.builder with 3โ5 SkeletonLoader items to simulate a full page loading. Users perceive this as faster than a single spinner.
Set baseColor to a slightly tinted version of your background. For dark themes: use Colors.grey.shade800 / Colors.grey.shade600.
1200ms feels snappy and modern. 2000ms feels calm and premium. Match the speed to your app's personality.
Set explicit width and height on your SkeletonLoader so it matches the real content dimensions. This prevents jarring jumps when data loads.
Free & open source
Smart text truncation with '...' append. Zero dependencies, pure Dart.
Customizable dotted and dashed borders. Because FlutterFlow doesn't have this built in.
Profile image with initials fallback. Same name always gets the same color.
3D flip animation between front and back content. Tap to reveal.
Building an app?
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