๐ฆด Mirror Skeleton
Render-tree aware shimmer skeletons for Flutter. Wrap any widget tree in one line โ MirrorSkeleton walks the actual RenderObject tree and generates pixel-matched bones for every text run, image, container, button, form control, and progress indicator it finds. Zero layout shift when your data arrives.
โจ The 30-Second Pitch
MirrorSkeleton(
isLoading: loading,
child: YourPage(),
);
That's it. No parallel placeholder tree to maintain, no per-widget shimmer config. Edit your real UI and the skeleton stays in sync โ automatically.
๐ฌ Demo
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
๐ฏ Why Mirror Skeleton
Most skeleton libraries make you hand-craft a parallel widget tree that mirrors your real UI, doubling maintenance every time the design shifts. mirror_skeleton inspects the laid-out render tree at paint time and projects a shape for every visible element it finds.
| Feature | Hand-crafted skeleton | mirror_skeleton |
|---|---|---|
| Match your real UI | manual | โ automatic |
| Survives design changes | rewrite | โ free |
| Zero layout shift | only if pixel-perfect | โ always |
| Multi-line text wrapping | manual line-count math | โ automatic |
| Theme-tinted color | manual | โ automatic |
| Excluded brand elements | custom logic | โ
SkeletonIgnore |
| Buttons / chips / form controls | manual placeholders | โ auto-detected |
| Hides children from screen readers | manual ExcludeSemantics |
โ built in |
| Blocks taps during loading | manual IgnorePointer |
โ built in |
| Smooth crossfade when loaded | manual AnimatedSwitcher |
โ built in |
| Honors reduced-motion setting | manual | โ built in |
| Multiple animation styles | manual | โ shimmer / pulse / fade / wave |
๐ Quick Start (2 Steps!)
Step 1: Add to Dependencies
# pubspec.yaml
dependencies:
mirror_skeleton: ^0.3.0
Then run:
flutter pub get
Step 2: Wrap Your Widget Tree
import 'package:mirror_skeleton/mirror_skeleton.dart';
MirrorSkeleton(
isLoading: _loading,
child: ProfileBody(user: _user ?? User.placeholder()),
);
That's it! ๐ The widget tree under child renders as-is when isLoading is false. While isLoading is true, the same tree is walked at paint time and replaced with shimmering bones matching every visible element โ at the exact same positions and sizes.
๐ Complete Usage Guide
๐ฆด Basic Skeleton Wrap
class ProfileScreen extends StatefulWidget {
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
bool _loading = true;
User? _user;
@override
void initState() {
super.initState();
_fetchUser();
}
Future<void> _fetchUser() async {
final user = await api.getUser();
setState(() {
_user = user;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return MirrorSkeleton(
isLoading: _loading,
child: ProfileBody(user: _user ?? User.placeholder()),
);
}
}
When _loading flips off, the real content fades in over a smooth 250ms crossfade. No visible pop, no jump.
๐ก๏ธ Keep Brand Elements Visible with SkeletonIgnore
Wrap any subtree in SkeletonIgnore to opt it out of skeletonization:
MirrorSkeleton(
isLoading: _loading,
child: Column(
children: [
SkeletonIgnore(
child: Image.asset('assets/logo.png'), // stays visible & tappable
),
ProfileBody(user: user), // shimmers
],
),
);
The logo stays fully painted while everything around it shimmers. Useful for:
- Brand logos and hero illustrations
- Navigation rails / app bars you want to keep functional
- Background patterns and decorative imagery
- Any element that should stay visible during loading
๐จ Customize the Look
MirrorSkeleton(
isLoading: loading,
shimmerColor: Colors.indigo.shade100, // optional โ bone color
shimmerDuration: Duration(seconds: 2), // sweep duration (default 1500ms)
transitionDuration: Duration(milliseconds: 200), // crossfade out (default 250ms)
adaptiveSpeed: true, // slow shimmer on jank
shimmerHighlightColor: Colors.white, // moving highlight color
shimmerHighlightIntensity: 0.35, // peak alpha 0.0โ1.0
shimmerDirection: ShimmerDirection.leftToRight,
style: MirrorSkeletonStyle.shimmer, // shimmer / pulse / fade / wave
child: YourPage(),
);
When shimmerColor is omitted, MirrorSkeleton derives a tone from Theme.of(context).colorScheme.primary so the loading state always feels native to your brand โ light or dark mode.
๐ Animation Styles
Pick the loading vibe that fits your app:
// Default โ sweeping highlight
MirrorSkeleton(
isLoading: loading,
style: MirrorSkeletonStyle.shimmer,
child: YourPage(),
);
// Calm in-place opacity oscillation (~55% โ 100%)
MirrorSkeleton(
isLoading: loading,
style: MirrorSkeletonStyle.pulse,
child: YourPage(),
);
// Deeper blink (~20% โ 100%) โ more pronounced
MirrorSkeleton(
isLoading: loading,
style: MirrorSkeletonStyle.fade,
child: YourPage(),
);
// Per-bone wave that travels along the shimmer axis
MirrorSkeleton(
isLoading: loading,
style: MirrorSkeletonStyle.wave,
child: YourPage(),
);
โ๏ธ Shimmer Direction (4 Options)
ShimmerDirection.leftToRight // โก๏ธ Default
ShimmerDirection.rightToLeft // โฌ
๏ธ
ShimmerDirection.topToBottom // โฌ๏ธ
ShimmerDirection.bottomToTop // โฌ๏ธ
MirrorSkeleton(
isLoading: loading,
shimmerDirection: ShimmerDirection.topToBottom,
child: YourPage(),
);
๐ฏ Auto-Detected Widgets
Anything in this list becomes a properly-shaped bone with no extra code:
๐ Text & Icons
Text,RichTextโ multi-line text emits one bone per visual line at the actual wrapped widthIconโ chunky rounded-square bone matching the icon's footprint
๐ผ๏ธ Images
Image.asset,Image.network,Image.file,Image.memoryClipRRect/ClipOvalshapes propagate to descendant images (aClipRRect(borderRadius: 16)wrapping an image gives a bone with radius 16)
๐ฆ Containers & Surfaces
Container(color: ...),Container(decoration: ...),ColoredBox,DecoratedBoxBoxShape.circlebecomes a circle bone; rectangular shapes preserveborderRadiusCard,Materialโ emits a low-opacity backdrop for the surface, then layers inner content bones on top (the same pattern production skeletons use for gradient cards, hero artwork, analytics panels, etc.)
๐ค Avatars
CircleAvatarof any size
๐ Buttons & Chips
ElevatedButton,FilledButton,OutlinedButton,TextButtonIconButton,FloatingActionButtonChip,ActionChip,InputChip
โ๏ธ Form Controls (look like the real widget)
TextField,TextFormFieldโ full-field rounded rectSwitchโ pill track + thumb circleSliderโ thin track + thumbRadioโ stroked ringCheckboxโ outlined rounded square
โณ Progress (intentionally not skeletonised)
- A real
CircularProgressIndicator,LinearProgressIndicator, orRefreshProgressIndicatoris a different kind of loading affordance โ stamping a pill on top would be misleading. Their subtree is skipped entirely. Third-party widgets whose render-object class name containsProgress,Loader, orSpinnerare also skipped.
๐ Custom Charts (shape-aware)
Leaf CustomPaint widgets are detected by aspect:
- Square small (donut, pie, gauge) โ circle bone
- Wide (sparkline, bar, line, area) โ row of varying-height bar bones, so the loading state actually signals "chart"
- Anything else โ rounded-rect fallback
โ Dividers
- Thin border-only decorations render as a hairline bone
๐ Layout Widgets (traversed)
Row, Column, Stack, Padding, SizedBox, AspectRatio, Expanded, Flexible, ListView, GridView, SingleChildScrollView, CustomScrollView โ traversed and used to compute exact bone positions via RenderBox.localToGlobal.
๐ ๏ธ Built-in Polish
Things you don't have to remember to wire up:
- ๐ซ Hit testing absorbed during loading. Taps don't leak through to underlying widgets โ
onPressedhandlers won't fire while the skeleton is up. - ๐ Semantics excluded during loading. Screen readers see a single
Loadinglive region instead of reading placeholder text. - ๐ฌ Smooth crossfade when
isLoadingflips off. Real content fades in overtransitionDuration(250ms by default) โ no visible pop. - โฟ Reduced motion respected. When
MediaQuery.of(context).disableAnimationsistrue, the shimmer stops sweeping and the bones stay static. - โก Adaptive shimmer speed. Frame timings are sampled and the shimmer slows automatically on devices that drop frames.
- ๐ค TickerMode-aware. A
MirrorSkeletoninside an off-screenTabBarViewpage (or anyTickerMode(enabled: false)subtree) stops its shimmer ticker so it doesn't burn frames when invisible. - ๐ Zero layout shift. Bones are derived from the laid-out subtree, so the real content slots into exactly the same coordinates.
- ๐งน Memory released after fade-out. When the crossfade completes, the bone list, ignored-region list, and descendant cache are cleared so the render object stops pinning descendants in memory.
๐ก Real-World Examples
Profile Page with Avatar, Bio & Stats
class ProfilePage extends StatefulWidget {
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
bool _loading = true;
User? _user;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await Future.delayed(Duration(seconds: 2));
setState(() {
_user = await api.getUser();
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Profile')),
body: MirrorSkeleton(
isLoading: _loading,
child: Column(
children: [
CircleAvatar(radius: 50, backgroundImage: NetworkImage(user.avatar)),
Text(user.name, style: TextStyle(fontSize: 24)),
Text(user.bio, maxLines: 3),
Row(children: [
StatChip(label: 'Followers', value: user.followers),
StatChip(label: 'Following', value: user.following),
StatChip(label: 'Posts', value: user.posts),
]),
],
),
),
);
}
}
Feed List with Hero Image Excluded
MirrorSkeleton(
isLoading: _loading,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
// Brand hero stays visible the whole time
child: SkeletonIgnore(
child: Image.asset('assets/hero.png'),
),
),
SliverList.builder(
itemBuilder: (_, i) => ArticleTile(article: articles[i]),
itemCount: articles.length,
),
],
),
);
Tab View with Per-Tab Loading
TabBarView(
children: [
MirrorSkeleton(isLoading: _loadingFeed, child: FeedPage()),
MirrorSkeleton(isLoading: _loadingTrending, child: TrendingPage()),
MirrorSkeleton(isLoading: _loadingSaved, child: SavedPage()),
],
);
Each tab shimmers independently, and off-screen tabs pause their shimmer ticker automatically (TickerMode-aware) โ no wasted frames.
Pull-to-Refresh
RefreshIndicator(
onRefresh: _load,
child: MirrorSkeleton(
isLoading: _loading,
child: ListView.builder(
itemBuilder: (_, i) => ArticleTile(article: articles[i]),
itemCount: articles.length,
),
),
);
The RefreshProgressIndicator is intentionally skipped during skeletonization, so the spinner remains visible above the shimmering list โ exactly like production apps.
๐ API Reference
MirrorSkeleton
| Parameter | Type | Default | Notes |
|---|---|---|---|
isLoading |
bool |
required | Show skeleton vs. real child |
child |
Widget? |
โ | The tree to skeletonize |
shimmerColor |
Color? |
derived from theme | Bone color. null โ derived from Theme.of(context).colorScheme.primary |
shimmerDuration |
Duration |
1500ms |
One full sweep |
transitionDuration |
Duration |
250ms |
Crossfade duration. Duration.zero to disable |
adaptiveSpeed |
bool |
true |
Slow shimmer on frame drops |
shimmerHighlightColor |
Color |
Colors.white |
Color of the moving highlight |
shimmerHighlightIntensity |
double |
0.35 |
Peak alpha of the highlight, 0.0โ1.0 |
shimmerDirection |
ShimmerDirection |
leftToRight |
Sweep direction |
style |
MirrorSkeletonStyle |
shimmer |
shimmer / pulse / fade / wave |
SkeletonIgnore
Wraps a subtree and renders it normally over the skeleton. Useful for brand logos, hero illustrations, or anything that should remain visible during loading.
SkeletonIgnore(child: YourBrandWidget())
ShimmerDirection
enum ShimmerDirection { leftToRight, rightToLeft, topToBottom, bottomToTop }
MirrorSkeletonStyle
enum MirrorSkeletonStyle { shimmer, pulse, fade, wave }
| Style | Effect |
|---|---|
shimmer |
Sweeping highlight gradient โ the classic skeleton look |
pulse |
Calm in-place opacity oscillation (~55% โ 100%) |
fade |
Pronounced blink (~20% โ 100%) |
wave |
Per-bone alpha wave traveling along the shimmer axis โ gentler than shimmer, shader-free |
๐ง Best Practices
โ Do's
- โ Wrap the smallest meaningful subtree (the body of a screen, a list, a card) โ not the whole
MaterialApp - โ Render real widgets with placeholder data while loading โ
User.placeholder(),List.filled(6, Article.placeholder())โ so layout matches the loaded state - โ Use
SkeletonIgnorefor brand logos, hero images, navigation chrome - โ Trust the auto-detection โ it covers virtually all standard Flutter widgets
- โ Test with dark mode โ theme-derived colors adapt automatically
โ Don'ts
- โ Don't toggle
isLoadingrapidly โ let the crossfade finish for a polished feel - โ Don't wrap a
Scaffolddirectly โ wrap thebody, not the wholeScaffold(so the AppBar stays interactive) - โ Don't worry about progress indicators inside the tree โ they're auto-skipped, not skeletonized
- โ Don't forget to provide placeholder data to your widgets while loading;
MirrorSkeletonshapes bones from what's actually laid out
๐ Troubleshooting
| Issue | Solution |
|---|---|
| Skeleton shows nothing | Make sure isLoading: true and that the child actually lays out (provide placeholder data so widgets get a real size) |
| Real content "pops" in | Increase transitionDuration for a softer crossfade, or set it to Duration.zero if you want a hard cut |
| Shimmer feels laggy | adaptiveSpeed: true is on by default โ try release mode (flutter run --release) for true performance |
| Bones at wrong positions | Wrap a smaller subtree closer to the actual content, not the whole app |
| Logo gets shimmered | Wrap it in SkeletonIgnore |
| Custom widget not detected | Wrap it in a sized Container(color: ...) during loading, or use SkeletonIgnore to keep it visible |
| Rotated bone has no shimmer overlay | Known: Transform.rotate / RotatedBox bones are drawn at the correct rotated position but the shimmer highlight skips them โ they still receive the bone color |
โ ๏ธ Limitations
Transform.rotate/RotatedBoxbones are drawn at the correct rotated position, but the moving shimmer highlight skips them โ they still receive the bone color, just without the sweep overlay.- Truly bespoke
RenderBoxsubclasses outside the patterns above: fall back to wrapping in aContainer(color: ...)of equivalent size during loading, or wrap inSkeletonIgnoreto keep them visible.
๐ Quality & Testing
- โ 30/30 tests passing (100%)
- โ 0 code analysis issues
- โ Full null safety
- โ Dartdoc on all public API
- โ Flutter 3.27.0+
- โ Dart 3.11.3+
๐ Project Structure
mirror_skeleton/
โโโ lib/
โ โโโ mirror_skeleton.dart # Public entrypoint
โ โโโ src/
โ โโโ render_mirror_skeleton.dart # Render object & owner element
โ โโโ bone_detection.dart # Render-tree โ bones
โ โโโ bone_painting.dart # Bones โ canvas
โ โโโ bones.dart # Bone, BoneType, ShimmerDirection, MirrorSkeletonStyle
โโโ test/
โ โโโ mirror_skeleton_test.dart # 30 widget tests
โโโ example/ # 14-page demo gallery
โ โโโ lib/
โ โโโ pages/ # Profile, Feed, Grid, Article, Chat, Dashboard, Controls, โฆ
โโโ pubspec.yaml
๐ฌ Example App
A complete demo gallery is in example/ covering:
- Profile โ Avatar, multi-line bio, stat row
- Feed โ
ListViewof article tiles with images - Product Grid โ
GridViewwith cards, prices, ratings - Article โ Hero image, paragraphs, author row
- Messages / Conversations โ Chat list with avatars and unread badges
- Dashboard โ Mixed layout with
SkeletonIgnorebrand banner - Controls Gallery โ Buttons, chips, switches, sliders, progress, text fields
- Login โ
TextFields, password toggle,Checkbox, social buttons - Settings โ Sectioned
ListTiles withSwitch,Checkbox,Slider,Divider - Music Player โ Hero artwork, sliders, transport row, queue list
- Wallet โ Gradient balance card, action chips, transaction list
- Analytics โ Sparkline + bar + donut
CustomPaintcharts and stats - Shimmer Styles โ Live playground for direction, style, color, intensity
Run it:
cd example
flutter pub get
flutter run
๐ค Contributing
Contributions are welcome! Please:
- Report issues on GitHub
- Submit pull requests with improvements
- Help improve documentation
๐ License
MIT License โ see LICENSE for details.
๐ Resources
- Example App โ Complete demo gallery with 14+ screens
- Changelog โ Version history
- GitHub โ Source code
- Pub.dev โ Package registry
Made with ๐ฆด for the Flutter community
โญ Star on GitHub if mirror_skeleton saves you a Sunday afternoon!
Libraries
- mirror_skeleton
- Render-tree aware shimmer skeletons for Flutter.







