mcpbeat Sign in

Shimmer Skeleton Loaders Agent Skill

A self-measuring shimmer modifier plus skeleton composables that stand in for a list while it loads — how the modifier learns its own size, why the base colour under the sweep is load-bearing, why the modifier order between clip and background changes what gets rounded, and why the skeleton's lazy lists must have scrolling switched off. Use when building loading placeholders, or when a shimmer renders as a flat block, has square corners under a rounded design, or pauses visibly between passes.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill shimmer-skeleton-loaders

The instruction itself

3 sections, as written by the author

Shimmer placeholders

The shimmer is one modifier. It measures itself, so a caller never passes a size — a placeholder is

just a Box with a width, a height, a shape and this on the end:

// adapted
fun Modifier.shimmer(): Modifier = composed {
    val colors = LocalAppColors.current
    var size by remember { mutableStateOf(IntSize.Zero) }
    val transition = rememberInfiniteTransition(label = "Shimmer")
    val startOffsetX by transition.animateFloat(
        initialValue = -2 * size.width.toFloat(),
        targetValue  =  2 * size.width.toFloat(),
        animationSpec = infiniteRepeatable(animation = tween(1000)),
        label = "Shimmer",
    )
    background(
        brush = Brush.linearGradient(
            colors = listOf(colors.shimmerBackground, colors.shimmerLine, colors.shimmerBackground),
            start = Offset(startOffsetX, 0f),
            end   = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat()),
        ),
    ).onGloballyPositioned { size = it.size }
}

Skeletons built on it are ordinary layout — no framework, no library. Each is a hand-laid stack of

boxes at the sizes the real row will occupy, wrapped in the same list shape the real content uses.

Traps

Put clip before background, not after. clip is a graphics layer that clips what is drawn

*after* it; a background to its left has already been painted, square. So

.background(base).clip(shape).shimmer() gives you a square base colour under a rounded sweep —

and looks fine on the day you write it, because the sweep is the visible half. The other order,

.clip(shape).background(base).shimmer(), rounds both. It is easy to end up with both orders in

one file; the verify grep below is exactly for that.

Keep a solid base colour under the sweep — the first frame has no size. size starts at

IntSize.Zero, so on the composition before the first layout pass start and end are the same

point and the gradient is degenerate: what it resolves to is an engine detail, not something to

reason about or rely on. The solid .background(colors.shimmerBackground) every call site paints

first is what *guarantees* a painted first frame, whatever the degenerate gradient does over it.

Each call site animates independently. composed { } materialises per use, so every placeholder

box holds its own rememberInfiniteTransition and measures its own width. Twenty boxes are twenty

unsynchronised animations — the sweeps will not line up across a row, and a wide box and a narrow

one beside it travel at different speeds because the travel range is a multiple of each box's own

width. If a screen needs one coherent wave across several boxes, hoist a single transition and pass

the phase down instead.

The sweep is off the box for roughly half of each cycle. Travel runs from -2 × width to

+2 × width while the band itself is one width across, so the band overlaps the box for about half

the cycle; a linear gradient clamps outside its stops, and both outer stops are the base colour, so

the rest of the cycle is a flat pause. That pause is the design here, not a bug — but if you want a

continuous ripple, narrow the travel range rather than speeding up the tween, which only makes the

same pause arrive sooner.

The sweep is diagonal, and its angle follows the box's aspect ratio. The gradient ends at

(startOffsetX + width, height), so it runs corner-to-corner: near-horizontal across a short wide

title bar, steep down a tall square thumbnail. Inside one skeleton those angles differ visibly. If

the design calls for one angle everywhere, fix the end offset's y to a constant instead of the

measured height.

Switch scrolling off on every lazy list inside a skeleton. A skeleton holds an arbitrary,

invented number of fake rows standing in for a count nobody knows yet; there is nothing further down

to reach, and any scroll offset the user establishes is discarded the moment the real list — a

different list, with different keys — replaces it. Nested lazy lists also compete for the drag with

whatever scrolls outside them. LazyColumn(userScrollEnabled = false) on all of them.

Derive placeholder sizes from the real row, not by eye. The point of a skeleton is that nothing

moves when the data lands. Read the real row's thumbnail size, text line heights and paddings and

reuse those numbers; a placeholder that is 4 dp short produces a visible settle on every load, which

reads as jank rather than as a wrong constant.

Shimmer colours do not belong in the material colour scheme. The base and the highlight line are

two tokens with no scheme slot to sit in. Carry them in your own immutable colour holder exposed

through a composition local, with a light and a dark instance, so the placeholder tracks the theme

without a if (isDark) at every call site.

Verifying it

Run these from the repository root against your skeleton file and the file holding the modifier.

  • Find mismatched clip/background order. List both and compare line numbers per box:

grep -n 'clip(\|background(' <skeletonFile>

→ observed: two placeholders had background( before clip( — at 33/35 and again at 136/138

— so their base colours are square, while every other pair in the file (56/58, 67/69, 78/80, …)

puts clip( first. One file, both orders, and the second offender is far enough down that reading

only the first few hits would have missed it: read the whole listing.

  • Every lazy list in the skeleton has scrolling disabled. Compare the two counts:
   grep -c 'LazyColumn(\|LazyRow(' <skeletonFile>
   grep -c 'userScrollEnabled = false' <skeletonFile>

→ observed: 3 and 3. Any gap is a skeleton the user can scroll.

  • The modifier measures itself rather than taking a size parameter.

grep -n 'fun Modifier.shimmer\|composed {\|onGloballyPositioned\|size.width' <modifierFile>

→ observed: the composed { factory, the onGloballyPositioned write, and the reads of

size.width in both the animation bounds and the gradient end point. A shared extensions file

will surface unrelated hits from its other modifiers as well; match on the line numbers between

fun Modifier.shimmer and the next declaration.

  • Screenshot the skeleton and the loaded screen at the same scroll position and flip between them.

Anything that shifts is a placeholder measured by eye.

How to use it

Copy the folder

Take maxrave-dev/shimmer-skeleton-loaders from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.