Skeleton
A decorative loading placeholder that preserves the geometry of the content it replaces
A decorative loading placeholder that preserves the geometry of the content it replaces.
When to use this
- Content-shaped loading: Stand in for text lines, avatars, or cards while data arrives, without the layout jumping when it lands.
- Wrapping real content: Keep the child mounted so its measured size — and its local state — survive the loading toggle.
- Perceived performance: Show the shape of the page immediately instead of an empty region.
When not to use this: do not use a skeleton to report determinate progress. It is decorative and exposes no progress semantics. Use RemixProgress for a measurable percentage, or RemixSpinner with a semanticsLabel for an indeterminate operation that should be announced.
Basic implementation
Wrap the real content. The skeleton takes the child's size, so the loading and loaded states occupy exactly the same space.
Basic implementation
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
class SkeletonExample extends StatelessWidget {
const SkeletonExample({super.key, required this.loading});
final bool loading;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
RemixSkeleton(
loading: loading,
style: placeholder,
child: const Text('Jane Appleseed'),
),
RemixSkeleton(
loading: loading,
style: placeholder,
child: const Text('jane@example.com'),
),
],
);
}
SkeletonStyler get placeholder {
return SkeletonStyler()
.container(
BoxStyler()
.color(const Color(0x14000000))
.borderRadius(.circular(4)),
)
.pulseColor(const Color(0x29000000));
}
}Controlled loading state
loading is an ordinary input, so the caller owns the state. Toggling it never
remounts the child: a keyed stateful child keeps its identity and local state
across the transition.
Controlled loading state
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
class ProfileCard extends StatefulWidget {
const ProfileCard({super.key});
@override
State<ProfileCard> createState() => _ProfileCardState();
}
class _ProfileCardState extends State<ProfileCard> {
bool _loading = true;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
RemixSkeleton(
loading: _loading,
style: SkeletonStyler().container(
BoxStyler()
.color(const Color(0x14000000))
.shape(.circle()),
),
child: const RemixAvatar(label: 'Jane Appleseed'),
),
RemixButton(
label: _loading ? 'Finish loading' : 'Reload',
onPressed: () => setState(() => _loading = !_loading),
),
],
);
}
}Placeholder shapes
Without a child the container's own constraints decide the size. That covers standalone rectangles, text lines, and circles.
Placeholder shapes
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
class SkeletonShapes extends StatelessWidget {
const SkeletonShapes({super.key});
@override
Widget build(BuildContext context) {
return Row(
spacing: 16,
children: [
RemixSkeleton(style: circle),
RemixSkeleton(style: textLine),
RemixSkeleton(style: block),
],
);
}
SkeletonStyler get base {
return SkeletonStyler()
.container(BoxStyler().color(const Color(0x14000000)))
.pulseColor(const Color(0x29000000));
}
SkeletonStyler get circle =>
base.container(BoxStyler().size(40, 40).shape(.circle()));
SkeletonStyler get textLine =>
base.container(BoxStyler().size(200, 12).borderRadius(.circular(6)));
SkeletonStyler get block =>
base.container(BoxStyler().size(160, 80).borderRadius(.circular(8)));
}Announcing loading
A skeleton is deliberately silent. When an application needs to announce that a region is loading, render a separate labelled status region beside it.
Announcing loading
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:remix/remix.dart';
class AnnouncedLoading extends StatelessWidget {
const AnnouncedLoading({super.key, required this.loading});
final bool loading;
@override
Widget build(BuildContext context) {
return Stack(
children: [
RemixSkeleton(
loading: loading,
style: SkeletonStyler().container(
BoxStyler().color(const Color(0x14000000)),
),
child: const Text('Monthly revenue'),
),
if (loading)
Semantics(
role: SemanticsRole.status,
label: 'Loading monthly revenue',
child: const SizedBox.shrink(),
),
],
);
}
}Styling
SkeletonStyler keeps the placeholder's Box API nested under container, so
geometry, fill, radius, borders, and widget modifiers are configured with a
BoxStyler. pulseColor and duration configure the animation itself.
Styling
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
SkeletonStyler skeletonStyle() {
return SkeletonStyler()
.container(
BoxStyler()
.size(240, 16)
.color(const Color(0x14000000))
.borderRadius(.circular(4)),
)
.pulseColor(const Color(0x29000000))
.duration(const Duration(milliseconds: 1000));
}
SkeletonSpec skeletonSpec() {
return const SkeletonSpec(
container: StyleSpec(
spec: BoxSpec(
constraints: BoxConstraints.tightFor(width: 240, height: 16),
),
),
pulseColor: Color(0x29000000),
duration: Duration(milliseconds: 1000),
);
}Fortal recipe
FortalSkeleton applies the owned neutral surface: grayA3 to
grayA4, radius1, a 1000 ms pulse leg, and a scalable space3 childless
minimum height. The wrapper deliberately leaves width and height to its child
or parent constraints.
Fortal skeleton
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
import 'ui/ui.dart';
Widget profileSkeleton(bool loading) {
return FortalSkeleton(
loading: loading,
child: const Text('Jane Appleseed'),
);
}
Widget fixedSkeleton() {
return RemixSkeleton(
style: fortalSkeletonStyle().container(BoxStyler().size(200, 12)),
);
}Behavior
Interaction and focus
While loading is true the child stays mounted for its size and local state,
but it is inert: pointer input is ignored, keyboard traversal skips it, its
animations stop ticking, and it is painted at zero opacity. Focus already held
inside the child is released as the skeleton enters the loading state.
Semantics
The loading subtree contributes nothing to the semantics tree — no label, no
role, no actions. Set loading to false and the child's original semantics
return unchanged. No loading or progress role is fabricated.
Reduced motion
The pulse honours the user's reduced-motion preference, read from
MediaQuery.disableAnimations and the platform's reduceMotion (see
Reduced motion). When motion is
reduced the placeholder paints its base frame and schedules no further frames,
and it resumes pulsing if the preference changes back while it is mounted.
Text direction
The placeholder has no directional content, so it renders identically in LTR and RTL. Directionality comes from whatever the child itself needs.
Rendering and motion
- Wrapped text is covered by one rectangle over the child's whole box. Compose one skeleton per line for a striped placeholder.
- A childless skeleton needs a size; the Fortal recipe supplies a scalable minimum height and leaves width to its constraints.
- Fortal pulses gray alpha 3–4 with a 1000 ms leg and honors
MediaQuery.disableAnimationsOf. - A raw Remix style interpolates
colorandpulseColorwhen there is an unmasked fill. With nocolor, or when a gradient, image, orforegroundDecorationmasks the fill, it fades the whole container between full and half opacity instead. This composes with any opacity the caller already applied and leaves the decoration intact. - Loading content is excluded from focus, semantics, and ticking. There is no shimmer gradient or fabricated progress semantics.
Constructor
Constructor
import 'package:flutter/material.dart';
import 'package:remix/remix.dart';
RemixSkeleton remixSkeletonConstructor({
Key? key,
Widget? child,
bool loading = true,
Style<SkeletonSpec> style = const SkeletonStyler.create(),
StyleSpec<SkeletonSpec>? styleSpec,
}) => throw UnimplementedError();Properties
Widget Properties
child → Widget?
Optional. The content the placeholder stands in for. When present it sizes the
skeleton in both states, so toggling loading never changes the layout. When
absent, the container's own constraints decide the size and a non-loading
skeleton collapses to nothing.
loading → bool
Optional, defaults to true. Whether to show the placeholder instead of the
child.
style → Style<SkeletonSpec>
Optional. The style configuration for the skeleton. A SkeletonStyler is the
generated fluent implementation.
styleSpec → StyleSpec<SkeletonSpec>?
Optional. A pre-resolved style spec that bypasses style resolution. Wrap a raw
SkeletonSpec with const StyleSpec(spec: SkeletonSpec(...)).
key → Key?
Optional. Controls how one widget replaces another widget in the tree.
Style Methods
pulseColor(Color value)
Sets the alternate fill the pulse animates toward. Requires a plain container
color with nothing painted over it; when a gradient, image, or
foregroundDecoration would mask the fill, the pulse fades the whole container
instead so the animation stays visible.
duration(Duration value)
Sets the length of one forward pulse leg. Defaults to 1000 ms; the reverse leg takes the same time.
container(BoxStyler value)
Sets the placeholder container directly. Every Box style method — size,
color, borderRadius, shape, padding, and the rest — stays on the
nested BoxStyler; these methods are not exposed directly on SkeletonStyler.
animate(AnimationConfig value)
Configures implicit animation for style transitions.
wrap(WidgetModifierConfig value)
Applies widget modifiers such as clipping, opacity, or scaling.
call({Key? key, Widget? child, bool loading = true})
Creates a RemixSkeleton widget with this style applied.