Components / feedback
Skeleton
Holds the place of loading content with animated grey shapes, then fades the content in. Includes a single bone and a bone group.
Import
In a Flutter app where fliin init has run:
Examples
Loading
While loading is true, each FlSkeleton.item shows a shimmering placeholder of the size you give it, in place of its child.
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/card/fl_card.dart';
import 'package:your_app/ui/fliin/components/skeleton/fl_skeleton.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';
class SkeletonLoadingExample extends StatelessWidget {
const SkeletonLoadingExample({super.key});
@override
Widget build(BuildContext context) {
final space = FlScope.of(context).space;
return FlCard(
children: [
FlSkeletonGroup(
label: 'Loading post',
loading: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: space.md,
children: [
Row(
spacing: space.md,
children: [
const FlSkeleton.item(
width: 40,
height: 40,
radius: double.infinity,
child: FlAvatar(initials: 'JD', semanticLabel: 'John Doe', color: FlAvatarColor.accent),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: space.xs,
children: const [
FlSkeleton.item(width: 96, height: 14, radius: 6, child: FlText('John Doe', type: FlTextType.h6)),
FlSkeleton.item(
width: 64,
height: 12,
radius: 6,
child: FlText('@johndoe', type: FlTextType.bodySm, tone: FlTextTone.muted),
),
],
),
),
],
),
const FlSkeleton.item(height: 14, radius: 6, child: FlText('This is the first line of the post content.')),
const Align(
alignment: AlignmentDirectional.centerStart,
child: FlSkeleton.item(width: 160, height: 14, radius: 6, child: FlText('Last line is shorter.')),
),
],
),
),
],
);
}
}Loaded
The same post once loaded: with loading: false, the group shows the children it was holding, and the layout does not move.
import 'package:your_app/ui/fliin/components/avatar/fl_avatar.dart';
import 'package:your_app/ui/fliin/components/card/fl_card.dart';
import 'package:your_app/ui/fliin/components/skeleton/fl_skeleton.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';
class SkeletonLoadedExample extends StatelessWidget {
const SkeletonLoadedExample({super.key});
@override
Widget build(BuildContext context) {
final space = FlScope.of(context).space;
return FlCard(
children: [
FlSkeletonGroup(
label: 'Loading post',
loading: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: space.md,
children: [
Row(
spacing: space.md,
children: [
const FlSkeleton.item(
width: 40,
height: 40,
radius: double.infinity,
child: FlAvatar(initials: 'JD', semanticLabel: 'John Doe', color: FlAvatarColor.accent),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: space.xs,
children: const [
FlSkeleton.item(width: 96, height: 14, radius: 6, child: FlText('John Doe', type: FlTextType.h6)),
FlSkeleton.item(
width: 64,
height: 12,
radius: 6,
child: FlText('@johndoe', type: FlTextType.bodySm, tone: FlTextTone.muted),
),
],
),
),
],
),
const FlSkeleton.item(height: 14, radius: 6, child: FlText('This is the first line of the post content.')),
const Align(
alignment: AlignmentDirectional.centerStart,
child: FlSkeleton.item(width: 160, height: 14, radius: 6, child: FlText('Last line is shorter.')),
),
],
),
),
],
);
}
}Shapes
Skeleton items on their own, without a child: a round avatar and lines of decreasing width, for a row whose content is not known yet.
import 'package:your_app/ui/fliin/components/skeleton/fl_skeleton.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';
class SkeletonShapesExample extends StatelessWidget {
const SkeletonShapesExample({super.key});
@override
Widget build(BuildContext context) {
final space = FlScope.of(context).space;
return FlSkeletonGroup(
label: 'Loading',
child: Row(
spacing: space.md,
children: [
const FlSkeleton.item(width: 48, height: 48, radius: double.infinity),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: space.sm,
children: [
for (final factor in const [1.0, 0.75, 0.5])
FractionallySizedBox(widthFactor: factor, child: const FlSkeleton.item(height: 12, radius: 6)),
],
),
),
],
),
);
}
}Text
FlSkeletonText stands in for a paragraph: lines at the rhythm of body text, the last one shorter. Give it a label when it loads on its own.
import 'package:your_app/ui/fliin/components/skeleton/fl_skeleton.dart';
import 'package:flutter/widgets.dart';
class SkeletonTextExample extends StatelessWidget {
const SkeletonTextExample({super.key});
@override
Widget build(BuildContext context) {
return const FlSkeletonText(label: 'Loading article', lines: 5, lastLineWidth: 0.5);
}
}Card
A card while it loads: the image, then three lines of text, in one group that the screen reader announces once.
import 'package:your_app/ui/fliin/components/skeleton/fl_skeleton.dart';
import 'package:your_app/ui/fliin/components/surface/fl_surface.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';
class SkeletonCardExample extends StatelessWidget {
const SkeletonCardExample({super.key});
@override
Widget build(BuildContext context) {
final theme = FlScope.of(context);
return Center(
child: SizedBox(
width: 250,
child: FlSurface(
child: FlSkeletonGroup(
label: 'Loading card',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: theme.space.lg,
children: [
FlSkeleton.item(height: 128, radius: theme.shape.radiusLg),
const FlSkeletonText(),
],
),
),
),
),
);
}
}List
A list that loads: each row an avatar and two lines, one group for the whole list.
import 'package:your_app/ui/fliin/components/skeleton/fl_skeleton.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';
class SkeletonListExample extends StatelessWidget {
const SkeletonListExample({super.key});
@override
Widget build(BuildContext context) {
final theme = FlScope.of(context);
return FlSkeletonGroup(
label: 'Loading messages',
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: theme.space.lg,
children: [
for (var i = 0; i < 3; i++)
Row(
spacing: theme.space.md,
children: [
FlSkeleton.item(width: 40, height: 40, radius: theme.shape.radiusLg),
const Expanded(child: FlSkeletonText(lines: 2, lastLineWidth: 0.8)),
],
),
],
),
);
}
}Animations
The three animations of variant: a shimmer that sweeps across, a pulse of the opacity, or none at all.
import 'package:your_app/ui/fliin/components/skeleton/fl_skeleton.dart';
import 'package:your_app/ui/fliin/components/text/fl_text.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:flutter/widgets.dart';
class SkeletonAnimationsExample extends StatelessWidget {
const SkeletonAnimationsExample({super.key});
@override
Widget build(BuildContext context) {
final theme = FlScope.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: theme.space.lg,
children: [
for (final (variant, name) in const [
(FlSkeletonVariant.shimmer, 'Shimmer'),
(FlSkeletonVariant.pulse, 'Pulse'),
(FlSkeletonVariant.none, 'None'),
])
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: theme.space.sm,
children: [
FlText(name, type: FlTextType.bodyXs, tone: FlTextTone.muted),
FlSkeletonGroup(
label: name,
variant: variant,
child: Row(
spacing: theme.space.md,
children: [
FlSkeleton.item(width: 56, height: 56, radius: theme.shape.radiusLg),
const Expanded(child: FlSkeletonText(lines: 2)),
],
),
),
],
),
],
);
}
}Usage
The code the reference images render:
final example = ColoredBox(
color: FlScope.of(context).colors.surface,
child: Padding(
padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: FlScope.of(context).space.sm,
children: [
FlSkeletonGroup(
label: 'Loading the profile',
child: Row(
spacing: FlScope.of(context).space.md,
children: [
const FlSkeleton.item(
width: 40,
height: 40,
radius: double.infinity,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: FlScope.of(context).space.xs,
children: [
FlSkeleton.item(
width: 128,
height: 12,
radius: FlScope.of(context).shape.radiusMd,
),
FlSkeleton.item(
width: 96,
height: 12,
radius: FlScope.of(context).shape.radiusMd,
),
],
),
),
],
),
),
FlSkeleton(
label: 'Loading the image',
variant: FlSkeletonVariant.pulse,
height: 80,
radius: FlScope.of(context).shape.radiusLg,
),
FlSkeleton(
label: 'Loading the title',
variant: FlSkeletonVariant.none,
height: 16,
radius: FlScope.of(context).shape.radiusMd,
),
FlSkeleton(
label: 'Loading the name',
loading: false,
height: 16,
radius: FlScope.of(context).shape.radiusMd,
child: Text(
'Ada Lovelace',
style: TextStyle(color: FlScope.of(context).colors.onSurface),
),
),
],
),
),
);API
FlSkeletonVariant: enum {shimmer, pulse, none}
FlSkeletonVariant.shimmer: static final FlSkeletonVariant
FlSkeletonVariant.pulse: static final FlSkeletonVariant
FlSkeletonVariant.none: static final FlSkeletonVariant
FlSkeleton: class extends StatefulWidget
FlSkeleton.new: const new({super.key, required String label, bool loading, FlSkeletonVariant variant, double? width, double? height, double radius, Widget? child})
FlSkeleton.item: const new({super.key, bool? loading, FlSkeletonVariant? variant, double? width, double? height, double radius, Widget? child})
FlSkeleton.label: get String?
FlSkeleton.loading: get bool?
FlSkeleton.variant: get FlSkeletonVariant?
FlSkeleton.width: get double?
FlSkeleton.height: get double?
FlSkeleton.radius: get double
FlSkeleton.child: get Widget?
FlSkeleton.createState: State<FlSkeleton>()
FlSkeletonGroup: class extends StatelessWidget
FlSkeletonGroup.new: const new({super.key, required String label, bool loading, FlSkeletonVariant variant, bool skeletonOnly, required Widget child})
FlSkeletonGroup.label: get String
FlSkeletonGroup.loading: get bool
FlSkeletonGroup.variant: get FlSkeletonVariant
FlSkeletonGroup.skeletonOnly: get bool
FlSkeletonGroup.child: get Widget
FlSkeletonGroup.build: Widget(BuildContext context)
FlSkeletonText: class extends StatelessWidget
FlSkeletonText.new: const new({super.key, int lines, double lastLineWidth, String? label, bool? loading, FlSkeletonVariant? variant, Widget? child})
FlSkeletonText.lines: get int
FlSkeletonText.lastLineWidth: get double
FlSkeletonText.label: get String?
FlSkeletonText.loading: get bool?
FlSkeletonText.variant: get FlSkeletonVariant?
FlSkeletonText.child: get Widget?
FlSkeletonText.build: Widget(BuildContext context)Reference renders
The 16 images published with version 0.2.1, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify skeleton compares your copy to them.
















Use it to
- show a placeholder while content is loading
- show grey shapes while a list loads
- show a ghost profile before data arrives
- show an animated shape instead of a loading image
- keep the layout stable while loading
- show a shimmering card placeholder
- Not for
- waiting with no known shape: use a spinner (spinner)
- measurable progress: use a progress bar (progress)
- States
Source
The files fliin add skeleton copies into your app, before your prefix and imports are applied.
skeleton/fl_skeleton.dart
// Modified from the Skeleton and SkeletonGroup of HeroUI Native 1.0.10,
// Copyright 2025 NextUI Inc., licensed under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
import 'dart:ui' show lerpDouble;
import 'package:flutter/semantics.dart' show SemanticsRole;
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/core/fliin.dart';
/// The animation of an [FlSkeleton] while loading.
enum FlSkeletonVariant {
/// A `skeletonShimmer` highlight sweeps across the bone in reading
/// direction, endlessly, over `motion.duration.shimmer`, at constant speed.
shimmer,
/// The opacity goes from 0.5 to 1 and back, `motion.duration.pulse` each
/// way, along `motion.curve.easeInOut`.
pulse,
/// A still bone, which appears and disappears without a fade.
none,
}
/// A loading placeholder: a bone of the `skeleton` color, sized and rounded
/// by the caller, in place of [child] while [loading] is true. When
/// [loading] turns false, [child] replaces the bone with a
/// `motion.duration.medium2` fade (no fade for [FlSkeletonVariant.none]).
///
/// Under reduced motion, neither shimmer nor pulse: the bone stays still, at
/// opacity 0.5 for [FlSkeletonVariant.pulse], and fades are
/// instant.
///
/// While loading, the bone announces itself as a loading indicator named
/// [label]; inside an [FlSkeletonGroup], the group announces itself instead,
/// only once.
class FlSkeleton extends StatefulWidget {
/// A standalone bone, [FlSkeletonVariant.shimmer] and loading by default.
const FlSkeleton({
super.key,
required String this.label,
bool this.loading = true,
FlSkeletonVariant this.variant = FlSkeletonVariant.shimmer,
this.width,
this.height,
this.radius = 0,
this.child,
});
/// A bone of an [FlSkeletonGroup]: without [loading] or [variant], it takes
/// the group's; it does not announce itself, the group does.
const FlSkeleton.item({super.key, this.loading, this.variant, this.width, this.height, this.radius = 0, this.child})
: label = null;
/// What is loading, read by the screen reader; null for a group bone.
final String? label;
/// True while loading: the bone in place of [child].
final bool? loading;
/// The bone's animation.
final FlSkeletonVariant? variant;
/// The bone's width; when null, the one the parent imposes.
final double? width;
/// The bone's height; when null, the one the parent imposes.
final double? height;
/// The radius of the bone's continuous corners. Beyond half the shorter
/// side, the bone becomes a capsule, or a circle if it is square.
final double radius;
/// The loaded content, shown when [loading] is false.
final Widget? child;
@override
State<FlSkeleton> createState() => _FlSkeletonState();
}
class _FlSkeletonState extends State<FlSkeleton> with TickerProviderStateMixin {
late final AnimationController _loop = AnimationController(vsync: this);
late final AnimationController _enter = AnimationController(vsync: this);
FlSkeletonVariant? _running;
var _entered = false;
({bool loading, FlSkeletonVariant variant}) _resolve() {
final group = context.dependOnInheritedWidgetOfExactType<_FlSkeletonGroupScope>();
assert(widget.label != null || group != null, 'FlSkeleton.item outside an FlSkeletonGroup');
return (
loading: widget.loading ?? group?.loading ?? true,
variant: widget.variant ?? group?.variant ?? FlSkeletonVariant.shimmer,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final motion = FlMotion.of(context);
if (!_entered) {
_entered = true;
// The fade-in entrance, which `none` and reduced motion skip.
if (motion.reduced || _resolve().variant == FlSkeletonVariant.none) {
_enter.value = 1;
} else {
_enter.animateTo(1, duration: motion.tokens.medium2, curve: motion.tokens.easeInOutQuad);
}
}
_sync();
}
@override
void didUpdateWidget(FlSkeleton oldWidget) {
super.didUpdateWidget(oldWidget);
_sync();
}
void _sync() {
final motion = FlMotion.of(context);
final (:loading, :variant) = _resolve();
final running = loading && !motion.reduced && variant != FlSkeletonVariant.none ? variant : null;
if (running == _running) return;
_running = running;
_loop.value = 0;
switch (running) {
case null || FlSkeletonVariant.none:
_loop.stop();
case FlSkeletonVariant.shimmer:
_loop
..duration = motion.tokens.shimmer
..repeat();
case FlSkeletonVariant.pulse:
_loop
..duration = motion.tokens.pulse
..repeat(reverse: true);
}
}
@override
void dispose() {
_loop.dispose();
_enter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = FlScope.of(context);
final motion = FlMotion.of(context);
final (:loading, :variant) = _resolve();
final shown = loading ? _bone(theme, motion, variant) : (widget.child ?? const SizedBox.shrink());
return FadeTransition(
opacity: _enter,
alwaysIncludeSemantics: true,
child: AnimatedSwitcher(
duration: motion.reduced || variant == FlSkeletonVariant.none ? Duration.zero : motion.tokens.medium2,
switchInCurve: motion.tokens.easeInOutQuad,
switchOutCurve: motion.tokens.easeInOutQuad,
// A bone without a width takes the parent's: constraints pass
// through unchanged, where the default Stack loosens them.
layoutBuilder: (current, previous) => Stack(
fit: StackFit.passthrough,
alignment: AlignmentDirectional.topStart,
children: [...previous, ?current],
),
child: KeyedSubtree(key: ValueKey(loading), child: shown),
),
);
}
Widget _bone(FlTheme theme, FlMotion motion, FlSkeletonVariant variant) {
Widget bone = FlPixelSnap(
child: SizedBox(
width: widget.width,
height: widget.height,
child: CustomPaint(
painter: _BonePainter(
radius: widget.radius,
fill: theme.colors.skeleton,
highlight: theme.colors.skeletonShimmer,
sweep: _running == FlSkeletonVariant.shimmer ? _loop : null,
travel: MediaQuery.sizeOf(context).width,
direction: Directionality.of(context),
),
),
),
);
if (variant == FlSkeletonVariant.pulse) {
bone = FadeTransition(
opacity: _loop.drive(CurveTween(curve: motion.tokens.easeInOut)).drive(Tween(begin: 0.5, end: 1)),
child: bone,
);
}
return switch (widget.label) {
final label? => Semantics(
container: true,
role: SemanticsRole.loadingSpinner,
label: label,
child: ExcludeSemantics(child: bone),
),
null => ExcludeSemantics(child: bone),
};
}
}
/// The bone's fill, and the highlight that sweeps across it: a horizontal
/// transparent, [highlight], transparent gradient, as wide as the bone, that
/// starts just before the bone and ends [travel] (the screen width) past its
/// start edge, clipped to the bone's shape.
class _BonePainter extends CustomPainter {
_BonePainter({
required this.radius,
required this.fill,
required this.highlight,
required this.sweep,
required this.travel,
required this.direction,
}) : super(repaint: sweep);
final double radius;
final Color fill;
final Color highlight;
final Animation<double>? sweep;
final double travel;
final TextDirection direction;
@override
void paint(Canvas canvas, Size size) {
final path = FlContinuousBorder.pathFor(Offset.zero & size, radius);
canvas.drawPath(path, Paint()..color = fill);
final progress = sweep?.value;
// The ends are transparent: no color outside the tokens, the highlight
// fades into its own.
final clear = highlight.withValues(alpha: 0);
if (progress == null) return;
final (from, to) = direction == TextDirection.ltr ? (-size.width, travel) : (travel, -size.width);
final band = Offset(lerpDouble(from, to, progress)!, 0) & size;
canvas
..save()
..clipPath(path)
..drawRect(band, Paint()..shader = LinearGradient(colors: [clear, highlight, clear]).createShader(band))
..restore();
}
@override
bool shouldRepaint(_BonePainter oldDelegate) =>
radius != oldDelegate.radius ||
fill != oldDelegate.fill ||
highlight != oldDelegate.highlight ||
sweep != oldDelegate.sweep ||
travel != oldDelegate.travel ||
direction != oldDelegate.direction;
}
/// A group of bones that load together: each [FlSkeleton.item] in [child]
/// takes the group's [loading] and [variant], unless it gives its own. The
/// group announces itself once as a loading indicator named [label].
class FlSkeletonGroup extends StatelessWidget {
/// A loading group, [FlSkeletonVariant.shimmer] by default.
const FlSkeletonGroup({
super.key,
required this.label,
this.loading = true,
this.variant = FlSkeletonVariant.shimmer,
this.skeletonOnly = false,
required this.child,
});
/// What is loading, read by the screen reader.
final String label;
/// True while loading, for each bone of the group.
final bool loading;
/// The animation of each bone of the group.
final FlSkeletonVariant variant;
/// True when [child] exists only for loading: the group renders nothing
/// once [loading] is false.
final bool skeletonOnly;
/// The layout of the bones and what surrounds them.
final Widget child;
@override
Widget build(BuildContext context) {
if (skeletonOnly && !loading) return const SizedBox.shrink();
final scope = _FlSkeletonGroupScope(loading: loading, variant: variant, child: child);
if (!loading) return scope;
return Semantics(container: true, role: SemanticsRole.loadingSpinner, label: label, child: scope);
}
}
/// Lines of text not loaded yet: [lines] bones of `space.md` high, `space.sm`
/// apart (the 20-point rhythm of `textSm`), with `shape.radiusXs` corners,
/// the full width of the parent, the last one [lastLineWidth] of it.
///
/// With [label], it is a group of its own, announced once; without, it
/// belongs to the enclosing [FlSkeletonGroup], whose loading state and
/// [variant] it takes unless it gives its own. Once loaded, [child] takes
/// its place.
class FlSkeletonText extends StatelessWidget {
/// Three lines, the last at 60 %.
const FlSkeletonText({
super.key,
this.lines = 3,
this.lastLineWidth = 0.6,
this.label,
this.loading,
this.variant,
this.child,
}) : assert(lines > 0, 'at least one line'),
assert(lastLineWidth > 0 && lastLineWidth <= 1, 'a share of the width, from 0 to 1');
/// The number of lines.
final int lines;
/// The width of the last line, as a share of the parent's; a single line
/// takes the full width.
final double lastLineWidth;
/// What is loading, read by the screen reader; null inside a group.
final String? label;
/// True while loading; the group's, otherwise true, when null.
final bool? loading;
/// The animation of the lines; the group's, otherwise
/// [FlSkeletonVariant.shimmer], when null.
final FlSkeletonVariant? variant;
/// The loaded text, shown when loading is over.
final Widget? child;
@override
Widget build(BuildContext context) {
final theme = FlScope.of(context);
final group = context.dependOnInheritedWidgetOfExactType<_FlSkeletonGroupScope>();
final label = this.label;
assert(label != null || group != null, 'FlSkeletonText without a label outside an FlSkeletonGroup');
final loading = this.loading ?? (label == null ? group?.loading : null) ?? true;
if (!loading) return child ?? const SizedBox.shrink();
final variant = this.variant ?? group?.variant ?? FlSkeletonVariant.shimmer;
final bones = _FlSkeletonGroupScope(
loading: true,
variant: variant,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: theme.space.sm,
children: [
for (var i = 0; i < lines; i++)
FractionallySizedBox(
widthFactor: i == lines - 1 && lines > 1 ? lastLineWidth : 1,
child: FlSkeleton.item(height: theme.space.md, radius: theme.shape.radiusXs),
),
],
),
);
if (label == null) return bones;
return Semantics(container: true, role: SemanticsRole.loadingSpinner, label: label, child: bones);
}
}
class _FlSkeletonGroupScope extends InheritedWidget {
const _FlSkeletonGroupScope({required this.loading, required this.variant, required super.child});
final bool loading;
final FlSkeletonVariant variant;
@override
bool updateShouldNotify(_FlSkeletonGroupScope oldWidget) =>
loading != oldWidget.loading || variant != oldWidget.variant;
}