mirror_skeleton 0.3.0
mirror_skeleton: ^0.3.0 copied to clipboard
Render-tree aware shimmer skeletons for Flutter. One-line wrap, zero layout shift, auto-detected widgets, theme-synced colors, and SkeletonIgnore for brand elements.
๐ฆด 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!