Skip to content

Commit cfe5e53

Browse files
authored
perf(ui): make DotsCircleSpinner a pure CSS animation (#3071)
1 parent fbca40f commit cfe5e53

2 files changed

Lines changed: 34 additions & 37 deletions

File tree

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,37 @@
1-
import { useSyncExternalStore } from "react";
2-
31
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4-
const INTERVAL = 80;
5-
6-
let globalFrameIndex = 0;
7-
let subscriberCount = 0;
8-
let globalTimer: ReturnType<typeof setInterval> | null = null;
9-
const listeners = new Set<() => void>();
10-
11-
function subscribe(callback: () => void) {
12-
listeners.add(callback);
13-
subscriberCount++;
14-
if (subscriberCount === 1) {
15-
globalTimer = setInterval(() => {
16-
globalFrameIndex = (globalFrameIndex + 1) % FRAMES.length;
17-
for (const listener of listeners) {
18-
listener();
19-
}
20-
}, INTERVAL);
21-
}
22-
return () => {
23-
listeners.delete(callback);
24-
subscriberCount--;
25-
if (subscriberCount === 0 && globalTimer) {
26-
clearInterval(globalTimer);
27-
globalTimer = null;
28-
}
29-
};
30-
}
31-
32-
function getSnapshot() {
33-
return globalFrameIndex;
34-
}
2+
const FRAME_INTERVAL_MS = 80;
353

364
interface DotsCircleSpinnerProps {
375
size?: number;
386
className?: string;
397
}
408

9+
/** Spins via the `ph-dots-frame` CSS animation (globals.css): all frames are
10+
* stacked in one grid cell and staggered delays reveal one at a time. Renders
11+
* once and never updates, so an always-visible spinner costs no JS. */
4112
export function DotsCircleSpinner({
4213
size = 12,
4314
className,
4415
}: DotsCircleSpinnerProps) {
45-
const frameIndex = useSyncExternalStore(subscribe, getSnapshot);
46-
4716
return (
4817
<span
49-
className={`inline-flex items-center justify-center ${className}`}
18+
className={`inline-grid place-items-center ${className}`}
5019
style={{
5120
width: size,
5221
height: size,
5322
fontSize: size,
5423
lineHeight: 1,
5524
}}
5625
>
57-
{FRAMES[frameIndex]}
26+
{FRAMES.map((frame, index) => (
27+
<span
28+
key={frame}
29+
className="ph-dots-frame"
30+
style={{ animationDelay: `${index * FRAME_INTERVAL_MS}ms` }}
31+
>
32+
{frame}
33+
</span>
34+
))}
5835
</span>
5936
);
6037
}

packages/ui/src/styles/globals.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,26 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] {
361361
animation: zen-float 3.5s ease-in-out infinite;
362362
}
363363

364+
/* Braille-dots spinner: 10 stacked frames, each held for 10% of the cycle.
365+
step-end holds each keyframe value so frames swap hard instead of fading.
366+
Pure CSS so spinners cost no JS timers, React renders or DOM mutations
367+
(DOM mutations would otherwise be serialized by session replay). */
368+
@keyframes ph-dots-frame {
369+
0% {
370+
opacity: 1;
371+
}
372+
10%,
373+
100% {
374+
opacity: 0;
375+
}
376+
}
377+
378+
.ph-dots-frame {
379+
grid-area: 1 / 1;
380+
opacity: 0;
381+
animation: ph-dots-frame 800ms step-end infinite;
382+
}
383+
364384
/* Pulse animation for generating indicator */
365385
@keyframes ph-pulse {
366386
0%,

0 commit comments

Comments
 (0)