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.The named exceptions
Decorative backgrounds: tm-dark-veil and tm-wavy
Decorative backgrounds: tm-dark-veil and tm-wavy
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.tm-shimmer-text, zero-markup path (deprecated)
tm-shimmer-text, zero-markup path (deprecated)
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.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.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.
-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 animatebox-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.
--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 onlytransform, 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 atverify/render-cost.html. Run the local
server and open it:
- Performance panel — record three seconds with a loop running. A
compositor-only loop shows no
PaintorRasterizework after the first frame. - Rendering panel — turn on Paint flashing. A compositor-only loop never flashes green.
- Task Manager — compare the tab’s CPU column with the loop running and with “Pause all motion” pressed.
The data
Every run ofnpm 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.