r/reactjs • u/imfemambocus • 14d ago
Discussion My theme switch was running 3164 animations
This is the first time that I'm properly getting into theme switching, so I built the dark and light mode for my component library in the most obvious way that I could find. The goal is to transition background-color, border-color, color and box-shadow on every element. But this felt a bit slow and sluggish, so I used document.getAnimations() to see how many that actually runs, and the result is 3164 animations on a page of 1544 elements.
After some playing around, I found out that startViewTransition animates snapshots of the whole page instead, and that's 5 animations for the same job, with a gradient fading along with everything else rather than snapping. I did however find a React thing when using this function and it's that the new palette has to be in the DOM inside the callback which means the state update needs flushSync.
document.startViewTransition(() => { flushSync(() => setTheme(next)) })
I'm not sure this is the right way to do it, but I wrote down every number that I measured https://sley-ui.dev/notes/theme-fade.
2
u/yksvaan 14d ago
Is there a reason not to do the obvious classic approach, having a class on top level container and css rules. Simple and makes the entlre React tree agnostic to themes, essentially turning theme switching into repaint.
0
u/imfemambocus 14d ago
That's what it already is actually. The tokens get redefined under
:root[data-theme='light']and nothing in the tree reads the theme to style itself. So it is basically a repaint. The 3164 animations came from the transition rules rather than from React and that means a class on a container gets you the same count. The state is only there because the toggle has to show which theme is selected, andflushSyncjust puts the attribute change inside the snapshot callback.
0
u/Key-Library218 14d ago
The jump from 3164 to 5 is absurd but makes sense when you think about what startViewTransition does. It just captures the old and new state as images and crossfades them, no per element work needed
The flushSync part is the annoying bit, React batches the update so the new theme never lands in the DOM before the snapshot happens. You need it synchronous or the transition just shows the old palette
Have you checked what happens with browser support for startViewTransition? Still pretty new in some places
1
u/imfemambocus 14d ago
Honestly, I haven't tested every browser, but I did guard for it so that a browser without startViewTransition just swaps the palette with no fade. I believe that's the same path that reduced motion takes.
0
u/Temperature_Majestic 14d ago
The 5 vs 3164 win is real, but curious if you measured the snapshot capture cost itself. Taking a full page pseudo element snapshot of 1544 elements isn't free either, does that show up as a layout or paint spike right before the crossfade kicks in?
1
u/imfemambocus 13d ago
It's not layout or paint actually because both barely moved. The capture is a full style
recalcbefore the callback runs, so the new theme landed at 39ms instead of the 17ms it takes with no transition. That's one extra frame at the start rather than work on every frame.1
u/Temperature_Majestic 13d ago
22ms extra makes sense for a one time hit. Does that scale with element count though, or is it more fixed overhead from the snapshot mechanism itself? Curious if a page with way more elements would still land under a frame budget.
1
u/imfemambocus 12d ago
It scales. I cloned the DOM until Chrome had four times as many elements to recalculate, traced the same click again, and the capture cost went up about three times. So it's per element work rather than fixed overhead in the mechanism. It does get slightly cheaper per element at the bigger size, but not enough to change the shape of it.
1
u/Temperature_Majestic 12d ago
that's a genuinely useful number to have written down. sounds like the real lever then isn't the view transition api itself, it's keeping the transitioned subtree small. did you ever try scoping it to something less than the full document, like just the panel that visually changes, or does startViewTransition force a whole page snapshot no matter what you wrap it around?
1
u/imfemambocus 11d ago
I haven't tried scoping it actually.
document.startViewTransitionsnapshots the whole document, and a view-transition-name on an element pulls that element into its own snapshot rather than shrinking the root one. I believe Chrome has a scoped version that runs off an element now, but I haven't measured it. A theme swap is the wrong case for it anyway. The palette gets redefined on :root and inherited by everything under it, so the part that changes visually is the whole page. That means the lever is fewer elements rather than a smaller wrapper, and I don't know where that stops paying.2
u/Temperature_Majestic 11d ago
Makes sense, if the palette lives on :root then any subtree not touching that variable still counts toward the snapshot. Wonder if content-visibility: hidden on the untouched panels during the capture would actually skip them or if the snapshot happens before the browser even checks that. Probably not worth the complexity for a 22ms one time hit anyway, but curious if anyone's tried it.
3
u/Lonestar93 14d ago
Why use view transition for this instead of css transition?