mirror_skeleton 0.3.1
mirror_skeleton: ^0.3.1 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!







