Back to blog

302 Illustrated Exercises in Open Source: The npm Package That Solves the Workout App Visuals

Hello HaWkers, there is a repository climbing on GitHub this week that solves a very specific and very annoying problem: Workout Guide, by developer Bryl Lim, publishes 302 illustrated physical exercises, three frames per exercise, for a total of 906 transparent 512 × 512 SVGs, plus a typed npm package to query all of it. The project passed 1.2k stars and is already on npm as @bryllim/workout-guide, with no runtime dependencies at all.

If you have never tried to get a workout app off the ground, this may not sound like news. Anyone who has tried knows: the workout sheet code comes out in an afternoon, and the exercise illustrations stall the project for weeks. This article shows what the package delivers, how to actually use it with code, and — the part almost everyone skips — what the license on those drawings demands from you before you publish to the store.

What Workout Guide Is

The project is a monorepo with two pieces. The first is the package published on npm, which carries the assets, a manifest.json with the metadata and a small TypeScript API. The second is an Astro site with a searchable static gallery, where you can browse the 302 exercises before installing anything.

The visual rule is strict, and that is exactly what gives the set its value: every exercise has exactly three ordered frames, transparent, in the same 512 × 512 viewBox. This is not a folder of random drawings with mixed styles that someone scraped off the internet. It is a normalized catalog, and that normalization is precisely the work nobody wants to do.

The original PNGs are still in the repository for compatibility, but the main format is SVG. For an exercise execution screen, that means sharpness at any pixel density, color control through CSS and a per-file weight that usually lands in the few-KB range.

Where the material comes from matters for understanding the license further down: the pose artwork comes from Everkinetic, an open fitness data project created by Greg Priday, published under CC BY-SA 4.0. What Bryl Lim did was expand that base with additional exercises and animation frames, normalize the assets, structure the metadata, write the package API and build the documentation gallery.

Why Exercise Illustration Is an Expensive Problem

It is worth explaining the size of the hole this fills, because anyone who has never run into it thinks it is an exaggeration.

A workout app needs to show how the movement is performed. Without that the product does not exist: a text list saying "bench press, 4 x 10" teaches nobody how to execute the movement. And then you have three paths, all of them bad.

The first is hiring an illustrator. It is expensive and, worse, it is slow: 300 exercises in a consistent style is a project of months, not weeks. The second is licensing a commercial image bank, where the price is usually per asset or per annual subscription, and the license normally restricts redistribution — which gets complicated if you want to cache the files on the client. The third is using video, which solves the explanation but blows up bandwidth costs and turns every exercise screen into a player you have to maintain.

The fourth path is the one almost everybody ends up choosing: scraping images from some fitness site and praying. That is not a licensing strategy, it is a debt waiting for a takedown notice.

An open, normalized catalog distributed through npm changes that math. The visuals stop being the MVP bottleneck and become a line in package.json.

The API in Five Minutes

Installation is what you expect:

npm install @bryllim/workout-guide

The package exposes three functions that solve 90% of the cases. getExercise looks up by id or slug, searchExercises filters the catalog and getAssetUrl returns the path of a specific frame:

import {
  getExercise,
  searchExercises,
  getAssetUrl,
} from '@bryllim/workout-guide'

// Direct lookup by slug
const pushUp = getExercise('push-up')

// Text search combined with an equipment filter
const chestWithoutEquipment = searchExercises('chest', {
  equipment: 'bodyweight',
})

// Path of the first of the three frames
const firstFrame = getAssetUrl('push-up', 1)

getExercise returns null when the slug does not exist, and so does getAssetUrl. That is a typing detail anyone writing TypeScript will appreciate: you are forced to handle the absence instead of discovering the undefined on the user's screen.

Beyond those three, the package exports the full exercises array, the types (Exercise, ExerciseFrame, ExerciseSearchFilters, ExerciseType, AssetUrlOptions, ExerciseAttribution) and two low-level utilities, normalizeSearchText and matchesFilter, useful if you want to build your own search layer on top of the manifest.

The Shape of Each Exercise

Every item in the catalog carries the fields you would use to build UI filters: id, slug, name, equipment, primaryMuscle, secondaryMuscles, exerciseType, isStretch and frames, where each frame has index and path.

With that you can build an exercise picker grouped by muscle group with no backend at all:

import { searchExercises, type Exercise } from '@bryllim/workout-guide'

type Group = { muscle: string; items: Exercise[] }

// Groups the catalog by primary muscle, skipping stretches
function groupByMuscle(): Group[] {
  const map = new Map<string, Exercise[]>()

  for (const exercise of searchExercises()) {
    if (exercise.isStretch) continue

    const key = exercise.primaryMuscle
    const list = map.get(key) ?? []
    list.push(exercise)
    map.set(key, list)
  }

  return [...map.entries()]
    .map(([muscle, items]) => ({ muscle, items }))
    .sort((a, b) => b.items.length - a.items.length)
}

Note that searchExercises() with no argument returns the entire catalog, which spares you from importing the array and the function separately.

The Three Frames: Animation Without Video

The smartest design decision in Workout Guide is having exactly three frames per exercise. Three is few enough to fit on any screen without blowing up bandwidth, and enough to communicate a movement: starting position, mid execution, final position.

In practice, you have two usage choices. The static one shows the middle frame, which is almost always the most readable of the series. The animated one cycles through all three in a loop and becomes a movement demonstration without a single byte of video.

In React, the cycle is a five-line useEffect:

import { useEffect, useState } from 'react'
import { getExercise, getAssetUrl } from '@bryllim/workout-guide'

export function ExerciseDemo({ slug, interval = 700 }) {
  const exercise = getExercise(slug)
  const [frame, setFrame] = useState(1)

  useEffect(() => {
    // Cycles through frames 1, 2 and 3 while the component stays mounted
    const timer = setInterval(() => {
      setFrame((current) => (current % 3) + 1)
    }, interval)

    return () => clearInterval(timer)
  }, [interval])

  if (!exercise) return null

  return (
    <figure>
      <img
        src={getAssetUrl(exercise.slug, frame)}
        alt={`Execution of the ${exercise.name} exercise, frame ${frame} of 3`}
        width={512}
        height={512}
        loading="lazy"
      />
      <figcaption>
        {exercise.name}{exercise.primaryMuscle}
      </figcaption>
    </figure>
  )
}

Two things in that snippet are not decoration. The alt describes the movement and the frame number, because a sequence of images with no alternative text is an invisible screen for anyone using a screen reader. And the explicit width/height reserves the space before loading, avoiding the layout jump that tanks your CLS.

If you want to go beyond swapping images and animate the stroke of the SVG itself, the path is inline instead of <img> — and then the same techniques I already detailed in the article on animating SVG with CSS and giving life to vector graphics apply. With the SVG in the DOM you control color, thickness and transition through CSS variables, which solves light and dark themes for free:

.exercise-demo svg {
  /* The stroke inherits the theme color instead of being hardcoded in the file */
  color: var(--text-color);
  transition: opacity 180ms ease-in-out;
}

@media (prefers-reduced-motion: reduce) {
  /* Respects anyone who asked for less motion: shows only the middle frame */
  .exercise-demo [data-frame='1'],
  .exercise-demo [data-frame='3'] {
    display: none;
  }
}

The prefers-reduced-motion block is not fussiness. An infinite loop animation filling the screen is exactly the kind of motion that bothers people with vestibular sensitivity, and the cost of respecting that is six lines of CSS.

The License: Where the Trap Lives

Here is the part that decides whether you can use this in your product, and it is where I see most people get it wrong.

The repository has two different licenses. The code and the documentation ship under MIT, which is permissive and asks for almost nothing. The visual assets — that is, the 906 SVGs, the part you actually want — ship under CC BY-SA 4.0, inherited from Everkinetic. It is not the same thing, and treating both as if they were is the classic mistake.

CC BY-SA 4.0 means two obligations. The first is BY: attribution. You need to credit the authorship, indicate the license and flag whether there were modifications. The second is SA, share-alike: if you adapt the material, the adaptation has to be distributed under the same license.

The point that usually gets misunderstood is the reach of share-alike. It falls on the adapted work, not on every piece of software that displays the image. Using the SVGs as they are inside your app does not turn your code into CC BY-SA — the code is still yours, under whatever license you want. Now, if you redraw the poses, recolor, crop or generate derived frames, those derived files are born CC BY-SA 4.0 and have to be distributed that way.

That has a practical consequence in a commercial app: CC BY-SA is not a "free and done" license, it is a license with a trade-off. If your plan was to grab the drawings, change the style to match your brand and treat the result as proprietary art, the plan does not work.

What does work is the simple path: use the assets unmodified, credit them properly and keep the license files bundled in your distribution. When in doubt about your specific case, that is one of those moments to talk to a lawyer instead of a blog.

A credits screen generated from the manifest itself solves the BY part without becoming maintenance debt:

import { exercises } from '@bryllim/workout-guide'

// Builds the credits text from what is actually in use in the app
export function assetCredits(usedSlugs) {
  const used = exercises.filter((ex) => usedSlugs.includes(ex.slug))

  return {
    total: used.length,
    source: 'Workout Guide, by Bryl Lim',
    originalArt: 'Everkinetic',
    license: 'CC BY-SA 4.0',
    modified: false,
    url: 'https://github.com/bryllim/workout-guide',
  }
}

Generating that from the catalog, and not from a hand-written string, guarantees the credit stays correct the day someone adds new exercises to the app.

Production Concerns

The package has no runtime dependencies and exposes both ESM and CJS through the exports map, on top of giving direct access to manifest.json and the assets/ folder. That is great for bundlers, but it opens a size trap: importing the whole catalog on the client to show a single exercise means throwing metadata for 302 items into the bundle.

The way out is deciding where the catalog lives. In an app with a server build — Nuxt, Next, Astro — the cheapest option is querying the package at build time or on the server and sending the client only the subset the screen needs:

// server/api/exercises.get.ts — Nuxt 3
import { searchExercises } from '@bryllim/workout-guide'

export default defineEventHandler((event) => {
  const { muscle = '', equipment } = getQuery(event)

  // Filters on the server and returns only what the UI renders
  return searchExercises(String(muscle), {
    equipment: equipment ? String(equipment) : undefined,
  }).map(({ slug, name, primaryMuscle, equipment }) => ({
    slug,
    name,
    primaryMuscle,
    equipment,
  }))
})

About the files themselves: 906 transparent SVGs are not a bandwidth problem if you serve them with a long, immutable Cache-Control, since the assets are versioned along with the package. In React Native, where there is no such thing as a public URL, the path is copying the used assets into the app's resources folder at build time and resolving the local path — getAssetUrl accepts options precisely to accommodate different prefixes.

And, as always when a new dependency lands in a project, it is worth pinning the version and looking at what is being installed. An assets package with zero dependencies is a small target, but the care is the same as with any installation: I detailed the full playbook in the article on npm publishing and malicious packages.

What This Project Says About Open Data

There is a bigger reading here, and it is not about fitness.

Everkinetic published open exercise data years ago. It sat there, useful but raw: PNG images, irregular styles, simply structured metadata. What happened now was someone taking that base, doing the thankless normalization work — same viewBox, same number of frames, same metadata schema — and packaging it in a format the ecosystem consumes without thinking, which is npm install.

That second step is underrated. Open data that requires three days of cleanup before first use has niche reach. The same data, normalized, typed and published to a registry, becomes infrastructure. The difference between those two states is not the license nor the original quality of the material: it is the packaging.

It is worth looking at this with a bit of skepticism too. A package maintained by one person is a single point of failure, and the sensible answer is the usual one: pin the version, mirror the assets you use and do not build the core product experience on the assumption that the repository will still be getting commits in 2028. With an open license, the worst case is you maintaining a fork — which is a lot better than the worst case of a commercial image bank, where the license simply expires.

If you have a workout app sitting in a drawer because the visual part looked unfeasible, the excuse is gone. It is 302 exercises, three frames each, a three-function API and a license that asks for credit, not money. The work that remains is the one that is actually yours: the workout sheet, the load progression, the history — the part that differentiates the product and that no package will hand you ready-made.

Let's go! 🦅

📚 Want to Keep Up With What Is Coming?

This article covered Workout Guide and how to use 302 open source illustrated exercises in your app, but the ecosystem changes every week and not everything turns into an article here.

On X I share what I am testing, the behind the scenes of my projects and the news that shows up before it becomes a post.

Follow Me There

👉 Follow @jeffbruchado on X

💡 Daily content about development, career and the tools I actually use

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments