Skip to main content
Motion is cheap or it is not motion you can ship. This page says exactly where every TailMotion class does its work, and which of those answers are guaranteed by the build rather than by intention.

The four tiers

A browser can run an animation in one of four places. Only the first is free of per-frame work on the main thread. A one-shot animation in the paint tier costs a few frames of work once. A loop in the paint tier costs that work for as long as the page is open, on every element carrying the class, whether or not anyone is looking at it. That is the difference this page exists to make visible.

The guarantee

Every continuous effect in the core runs on the compositor. Not “should”, not “usually” — npm run check fails the build if a class whose default iteration count is infinite animates anything outside the compositor tier without a named exception.
Of the 30 looping rules in the full stylesheet, 23 are compositor-only. The seven that are not are listed below by name, each with the reason it is allowed to be.

The named exceptions

Six looping rules across these two families animate background-position, border-radius or filter. Animating gradient stops is the effect; there is no compositor equivalent for a flowing gradient field.These are decorative surfaces, not product motion. Use them on one hero surface, not on a list. tm-dark-veil additionally drops its live blur on coarse-pointer devices, because Safari rasterizes it even on panels that are not visible.
One rule. The original tm-shimmer-text painted a gradient through background-clip: text and animated background-position, which re-rasterizes the glyph clip and repaints the gradient on every frame. It is the most expensive continuous effect the library has ever shipped, and the pattern most text-shimmer implementations still use.It is kept, unchanged, so markup written against 0.9 and 0.10 keeps working, and is scheduled for removal in 1.0. The sweep recipe below replaces it and runs on the compositor.

One-shot work outside the compositor

A one-shot animation may leave the compositor, but only with an entry in the check’s allowlist that names the reason. There are 16, and they fall into three groups.

Blur

No blur radius in the library exceeds 8px, with two named exceptions. A rasterized blur is the most expensive part of any animation, and Safari pays for it even on elements that are not visible yet.
The cap is on the authored value. tm-motion-expressive multiplies --tm-emphasis to 1.3, so a blur written as 8px resolves to 10.4px inside that scope. Set --tm-blur explicitly if you need the cap to hold there too.

tm-shimmer-text

A highlight that follows the shape of the glyphs needs a bright copy of the text and a window that travels across it. Two boxes are the only way to move a window independently of its content using transforms alone: the window sweeps one way and the copy inside counter-translates by the same amount, so the glyphs stay still while the masked window crosses them. A pseudo-element cannot do it, because a pseudo-element cannot hold another one.
Requirements, all of them checked in verify/render-cost.html:
  • The copy is aria-hidden. The text is announced once.
  • The element establishes a box. Block, inline-block or grid — that is Tailwind’s job, not this class’s.
  • The element carries no padding of its own. The sweep is positioned against the padding box, and the two translations only cancel exactly when the window and the copy are the same width. Put padding on a parent.
The resting text is dimmed to 45% through -webkit-text-fill-color so the sweep reads as brighter; the sweep resets that on itself. Under prefers-reduced-motion: reduce the dimming is lifted and the text returns to full contrast, because with no sweep running there is nothing for it to buy. Without :has() support (Firefox before 121) an element with no sweep child renders as ordinary, fully visible text rather than shimmering. Nothing is ever hidden.

tm-glow and tm-ripple

Both used to animate box-shadow, which no engine can composite. Both now paint their halo and their ring once on a pseudo-element and animate only its opacity and scale.
Both classes own the element’s ::after. If your own CSS puts a pseudo-element on the same element, one of the two will win. Both classes also set position: relative on the element, which makes it the containing block for any absolutely positioned descendant.
--tm-glow-size, --tm-glow-spread, --tm-ripple-size and the color tokens all keep their previous meanings.

Every class that leaves the compositor

29 of them. Everything else in the library — every entrance, exit, interaction, presence class, stagger and scroll behaviour — animates only transform, translate, scale, rotate or opacity, and the build fails if that changes. The 5 marked (loops) are the ones that matter: they do that work for as long as the page is open. Each is accounted for above — two decorative background families and the deprecated zero-markup shimmer. Everything else on this list is one-shot, and pays its cost once.
The explorer on the live demo shows the same information as a badge on each class, generated from the same analysis. A class with no badge runs on the compositor.

What it measures

npm run perf traces a real Chromium and counts paint events for 20 copies of one class over three seconds. Paint counts, not timings, are the signal: an animation either takes the browser down the paint path or it does not, and that answer is the same on every machine even though the rate is not. Same text, same page, same twenty copies: the old text shimmer repainted about 126 times a second per element, roughly two paint events per element per frame, and the recipe that replaced it does none. Every other looping class in the core measures zero. The four non-zero rows are the documented exceptions above.
The paint-tier classes double as the run’s control group. They are known to repaint, so if one of them ever reports zero the fixture is not exercising the class and every other zero on that run is meaningless. The script reports that as inconclusive rather than as a pass — which is how the first run of it was caught, when an unlayered fixture style had quietly removed the gradient tm-shimmer-text animates.

Measuring it yourself

The repository ships a harness at verify/render-cost.html. Run the local server and open it:
Then, in Chrome:
  1. Performance panel — record three seconds with a loop running. A compositor-only loop shows no Paint or Rasterize work after the first frame.
  2. Rendering panel — turn on Paint flashing. A compositor-only loop never flashes green.
  3. Task Manager — compare the tab’s CPU column with the loop running and with “Pause all motion” pressed.
In Safari, use Web Inspector’s Timelines and look for Paint entries in Rendering Frames.

The data

Every run of npm run check writes dist/render-cost.json: one record per animated rule with its selector, its keyframes, the properties it animates, its tier, and whether it loops. That file is the source for this page.