If you’re building a fitness, training, or rehab app, you’ve almost certainly thought about adding a body heatmap. The little silhouette that lights up the chest when the user logs a bench press, the back when they finish a deadlift session, the calves after a run. It’s one of those features that instantly makes an app feel premium — and one of those features that quietly eats two weeks of engineering time if you try to build it from scratch.
I’ve shipped this exact widget in production in Just Liftin’, and I’ve open-sourced the Flutter integration so you don’t have to repeat the work. This post walks through dropping it into your own app — three files, one asset, about ten minutes of integration time. Everything in this guide uses the free version on GitHub; you can try the interactive version in your browser before you write a single line of code.
Why this is hard to build from scratch
Before the how-to, it’s worth being honest about what you’re actually skipping by using a pre-built asset. To build a muscle heatmap yourself, you’d need to:
- Illustrate a clean, anatomically correct human silhouette (front view at minimum, ideally front and back).
- Split that silhouette into roughly 20–40 individual muscle shapes, each a separately addressable layer.
- Build a state machine that can toggle each muscle independently.
- Bind those toggles to your app’s data model through a runtime API.
- Make the whole thing render efficiently on mobile, since you’ll be re-rendering it every time a user taps a new exercise.
Most teams I’ve talked to estimate this at one to two sprints. It’s the kind of work that’s not technically hard, but is exhausting and easy to get wrong on the anatomy side. The repo we’re using takes care of all of it.
What we’re building
By the end of this post you’ll have:
- An
AnatomyHeatmapwidget rendered in your app. - A
Set<Muscle>driving which muscles light up — managed however you already manage state (setState, Riverpod, Bloc, signals, all fine). - A worked example wiring an exercise picker to the heatmap, so tapping “Push-Up” lights up the chest and front delts.
The free version covers the front-view artboard with 19 muscles across 11 groups: chest, shoulders, traps, neck, biceps, forearms, abs, obliques, quads, glutes, and calves. That’s enough to ship a real product — Just Liftin’ ran on a similar setup for months before we built out the back view.
The tech stack
The asset is a Rive file (.riv). Rive is a real-time vector animation tool with an excellent Flutter runtime — every muscle in the asset is bound to a boolean property on a view model, and toggling that boolean is what makes the muscle light up. You don’t need to know Rive to use this; you just need to install the package and pass it a set of enum values.
Step 1: Install the dependency
Add the Rive Flutter package to your pubspec.yaml:
dependencies:
rive: ^0.14.5
flutter:
assets:
- assets/animations/human_anatomy_free_basic_v1.0.rivThen copy human_anatomy_free_basic_v1.0.riv from the repo’s assets/animations/ folder into the same path in your own project, and run:
flutter pub getStep 2: Initialize Rive at app startup
Rive’s Flutter runtime needs a one-time native initialization before any Rive widget can render. Do this in main(), before runApp:
import 'package:flutter/material.dart';
import 'package:rive/rive.dart' show RiveNative;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await RiveNative.init(); // must complete before any Rive widget builds
runApp(const MyApp());
}Forget this step and you’ll get a runtime error the first time the heatmap tries to render. That’s the only gotcha in the whole setup.
Step 3: Copy in the two source files
Grab two files from the repo’s lib/ folder and drop them into your own project:
anatomy_heatmap.dart— the widget itself.anatomy_data.dart— theMuscleenum, theMuscleGroupenum, and anExercisecatalog you can use as a starting point.
Both are MIT-licensed, so you can edit them freely. The Muscleenum is the public API of the widget — anywhere your app talks to the heatmap, it’s passing around Set<Muscle>:
enum Muscle {
pectoralisMajor('pectoralisMajor', 'Pectoralis Major'),
deltoidsAnterior('deltoids', 'Anterior Deltoid'),
trapezius('trapezius', 'Trapezius'),
// ...19 total
}Each enum value carries a riveProperty(the string the asset’s view model expects) and a displayName (a human-readable label you can show in your UI). The widget handles the string-mapping internally, so callers never deal with stringly-typed names.
Step 4: Render the widget
The integration itself is genuinely one line:
import 'package:flutter/material.dart';
import 'anatomy_heatmap.dart';
import 'anatomy_data.dart';
class MyHeatmapPage extends StatelessWidget {
const MyHeatmapPage({super.key});
@override
Widget build(BuildContext context) {
return const AnatomyHeatmap(
activeMuscles: {
Muscle.pectoralisMajor,
Muscle.biceps,
Muscle.brachialis,
},
);
}
}Run that and you’ll get the silhouette rendered with the chest and biceps highlighted. That’s the entire integration. The hard part — anatomy, state machine, view-model bindings — is all baked into the asset and the widget.
Step 5: Wire it to real state
In a real app, activeMuscles won’t be a hardcoded set. It’ll be derived from whatever the user is doing — picking an exercise, scrubbing through a workout, viewing soreness data. Here’s a minimal example that swaps the highlighted muscles when the user picks an exercise:
class ExerciseDemo extends StatefulWidget {
const ExerciseDemo({super.key});
@override
State<ExerciseDemo> createState() => _ExerciseDemoState();
}
class _ExerciseDemoState extends State<ExerciseDemo> {
Set<Muscle> _active = const {};
void _selectExercise(Exercise e) {
setState(() => _active = e.activated);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(child: AnatomyHeatmap(activeMuscles: _active)),
SizedBox(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: Exercise.catalog.length,
itemBuilder: (_, i) {
final e = Exercise.catalog[i];
return Padding(
padding: const EdgeInsets.all(8),
child: ActionChip(
avatar: Icon(e.icon),
label: Text(e.name),
onPressed: () => _selectExercise(e),
),
);
},
),
),
],
);
}
}Tap “Push-Up” and the chest, front delts, and abs light up. Tap “Bodyweight Squat” and the quads and glutes light up instead. The Exercise.catalog shipped in anatomy_data.dart covers eleven common movements — use it as-is for a prototype, or replace it with whatever exercise model your backend already uses. The only contract is that something somewhere has to produce a Set<Muscle>.
How it actually works under the hood
You don’t need to know this to use the widget, but it’s helpful for debugging. The asset has a root view model with one nested view model per muscle. Each nested view model has a single boolean property called isOn. When the widget loads, it caches a handle to every isOn boolean it can find:
for (final muscle in Muscle.values) {
final nested = bodyVm.viewModel(muscle.riveProperty);
final isOn = nested?.boolean('isOn');
if (isOn != null) _muscleProperties[muscle] = isOn;
}Then on every rebuild, it walks the cached map and writes each boolean to match the current activeMuscles set:
for (final entry in _muscleProperties.entries) {
entry.value.value = widget.activeMuscles.contains(entry.key);
}Two things worth noticing:
- Handle caching matters.Resolving the view model and its properties is comparatively expensive; doing it once at load and then just toggling booleans is what keeps the widget cheap to rebuild. You can rebuild the parent every frame and it’ll be fine.
- The widget diffs the set. If
activeMuscleshasn’t changed between rebuilds, the widget skips the toggle pass entirely. So passing the sameconst {}set on every build is free.
If you’re curious, the full implementation is about 130 lines and lives in anatomy_heatmap.dart. It’s worth a read if you want to understand the lifecycle or extend it.
Common pitfalls
A few things I see people trip on:
- Forgetting
RiveNative.init(). Mentioned above, but worth repeating because the error message isn’t obvious. - Mismatched asset paths. The path in
pubspec.yaml, the path on disk, and the path passed toFileLoader.fromAssetall have to match exactly. The widget hardcodes'assets/animations/human_anatomy_free_basic_v1.0.riv'— if you put the file somewhere else, edit that string. - Hot reload after enum changes. The widget caches view-model handles in
onLoaded, which doesn’t re-fire on hot reload. If you add a new value to theMuscleenum during development, the widget self-heals on the next state change, but a full restart is cleaner. - Trying to change the highlight color.The free asset bakes in a single color. If you need user-themeable colors, you’ll want the Advanced pack (more on that below).
What you can’t do with the free version
A few limits you’ll hit eventually:
- Front view only. No back, so no lats, no triceps, no hamstrings, no posterior chain. Fine for arm-and-chest-day demos; not enough for a complete strength app.
- Binary on/off, no intensity.Every active muscle renders the same. You can’t show “primary” muscles brighter than “secondary” muscles.
- Single baked-in color.You can’t theme it per user, per workout type, or per intensity tier.
- No tap-to-identify.Tapping a muscle on the silhouette doesn’t emit an event. The interaction is one-way: your state drives the visual.
These are all by design — the free version is the front door, not the whole house. The full back-view artboard, runtime-themable colors, intensity tiers, and tap-to-identify all live in the paid packs at fitnessvisuals.com, which start at $30 one-time. If you’re building anything beyond a prototype, you’ll want them; the Advanced pack also ships with production-tested integration code for SwiftUI, Android native, and React Native alongside Flutter.
But for a prototype, a demo, or a fitness app that’s genuinely just upper-body focused, the free version is the whole thing. Ship it, validate the feature, then upgrade when you need more.
Wrapping up
The full integration is three steps: install the Rive package, copy two Dart files, pass a Set<Muscle>. The widget handles the rest — asset loading, view-model resolution, handle caching, lifecycle, dispose. Total integration time once you know the steps is under ten minutes.
If you want to play with it before pulling code, the interactive playground lets you tap muscles and exercises in your browser. The GitHub repo has the full source, the showcase app with three interaction modes, and the .riv asset. And if it saves you a week of integration work, the paid packs are how we keep doing this.