Skip to content

Commit d2f335a

Browse files
authored
🤖 perf: rewrite shimmer with pure CSS (no JS animation) (#998)
## Problem The previous shimmer implementation using Web Worker + OffscreenCanvas still showed 16% CPU usage on the main thread. Even though the animation loop ran in a worker, canvas compositing still hits the main thread. ## Solution Replace with pure CSS using `background-clip: text`: - Gradient background clipped to text shape - Dark band (75% darker via `color-mix`) sweeps across - Animated via `background-position` - no JS involved - Works with any base color including white ### Why this is better - **No JS animation loop** - browser handles it natively - **No canvas rendering** - just CSS - **No worker overhead** - no message passing, no Comlink - **~50 lines** vs ~230 lines before ### Trade-off `background-position` isn't compositor-only so it does trigger repaints, but for small text elements like "Thinking..." this is negligible compared to the canvas/worker overhead. ## Files changed - `src/browser/components/ai-elements/shimmer.tsx` - Pure CSS implementation - `src/browser/styles/globals.css` - Added `.shimmer-text` class and keyframe - Deleted `src/browser/workers/shimmerWorker.ts` - No longer needed --- _Generated with `mux`_
1 parent 53f5ee8 commit d2f335a

File tree

3 files changed

+58
-384
lines changed

3 files changed

+58
-384
lines changed
Lines changed: 24 additions & 198 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
"use client";
22

33
import { cn } from "@/common/lib/utils";
4-
import * as Comlink from "comlink";
54
import type { ElementType } from "react";
6-
import { memo, useEffect, useRef } from "react";
7-
import type { ShimmerWorkerAPI } from "@/browser/workers/shimmerWorker";
5+
import { memo } from "react";
86

97
export interface TextShimmerProps {
108
children: string;
@@ -15,214 +13,42 @@ export interface TextShimmerProps {
1513
colorClass?: string;
1614
}
1715

18-
// ─────────────────────────────────────────────────────────────────────────────
19-
// Worker Management (singleton)
20-
// ─────────────────────────────────────────────────────────────────────────────
21-
22-
let workerAPI: Comlink.Remote<ShimmerWorkerAPI> | null = null;
23-
let workerFailed = false;
24-
25-
function getWorkerAPI(): Comlink.Remote<ShimmerWorkerAPI> | null {
26-
if (workerFailed) return null;
27-
if (workerAPI) return workerAPI;
28-
29-
try {
30-
const worker = new Worker(new URL("../../workers/shimmerWorker.ts", import.meta.url), {
31-
type: "module",
32-
name: "shimmer-animation",
33-
});
34-
35-
worker.onerror = (e) => {
36-
console.error("[Shimmer] Worker failed to load:", e);
37-
workerFailed = true;
38-
workerAPI = null;
39-
};
40-
41-
workerAPI = Comlink.wrap<ShimmerWorkerAPI>(worker);
42-
return workerAPI;
43-
} catch (e) {
44-
console.error("[Shimmer] Failed to create worker:", e);
45-
workerFailed = true;
46-
return null;
47-
}
48-
}
49-
50-
// ─────────────────────────────────────────────────────────────────────────────
51-
// Shimmer Component
52-
// ─────────────────────────────────────────────────────────────────────────────
53-
5416
/**
55-
* GPU-accelerated shimmer text effect using OffscreenCanvas in a Web Worker.
17+
* Shimmer text effect using CSS background-clip: text.
18+
*
19+
* Uses a gradient background clipped to text shape, animated via
20+
* background-position. This is much lighter than the previous
21+
* canvas + Web Worker approach:
22+
* - No JS animation loop
23+
* - No canvas rendering
24+
* - No worker message passing
25+
* - Browser handles animation natively
5626
*
57-
* Renders text with a sweeping highlight animation entirely off the main thread.
58-
* All animation logic runs in a dedicated worker, leaving the main thread free
59-
* for streaming and other UI work.
27+
* Note: background-position isn't compositor-only, but for small text
28+
* elements like "Thinking..." the repaint cost is negligible compared
29+
* to the overhead of canvas/worker solutions.
6030
*/
6131
const ShimmerComponent = ({
6232
children,
6333
as: Component = "span",
6434
className,
6535
duration = 2,
66-
spread = 2,
6736
colorClass = "var(--color-muted-foreground)",
6837
}: TextShimmerProps) => {
69-
const canvasRef = useRef<HTMLCanvasElement>(null);
70-
const instanceIdRef = useRef<number | null>(null);
71-
const transferredRef = useRef(false);
72-
73-
useEffect(() => {
74-
const canvas = canvasRef.current;
75-
if (!canvas) return;
76-
77-
const api = getWorkerAPI();
78-
79-
// Get computed styles for font matching
80-
const computedStyle = getComputedStyle(canvas);
81-
const font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
82-
83-
// Resolve CSS variable to actual color
84-
const tempEl = document.createElement("span");
85-
tempEl.style.color = colorClass;
86-
document.body.appendChild(tempEl);
87-
const resolvedColor = getComputedStyle(tempEl).color;
88-
document.body.removeChild(tempEl);
89-
90-
// Get background color for highlight
91-
const bgColor =
92-
getComputedStyle(document.documentElement).getPropertyValue("--color-background").trim() ||
93-
"hsl(0 0% 12%)";
94-
95-
// Measure text and size canvas
96-
const ctx2d = canvas.getContext("2d");
97-
if (!ctx2d) return;
98-
ctx2d.font = font;
99-
const metrics = ctx2d.measureText(children);
100-
const textWidth = Math.ceil(metrics.width);
101-
const ascent = metrics.actualBoundingBoxAscent;
102-
const descent = metrics.actualBoundingBoxDescent;
103-
const textHeight = Math.ceil(ascent + descent);
104-
105-
// Handle HiDPI
106-
const dpr = window.devicePixelRatio || 1;
107-
canvas.width = textWidth * dpr;
108-
canvas.height = textHeight * dpr;
109-
canvas.style.width = `${textWidth}px`;
110-
canvas.style.height = `${textHeight}px`;
111-
canvas.style.verticalAlign = `${-descent}px`;
112-
113-
const config = {
114-
text: children,
115-
font,
116-
color: resolvedColor,
117-
bgColor,
118-
duration,
119-
spread,
120-
dpr,
121-
textWidth,
122-
textHeight,
123-
baselineY: ascent,
124-
};
125-
126-
// Worker path: transfer canvas and register
127-
if (api && !transferredRef.current) {
128-
try {
129-
const offscreen = canvas.transferControlToOffscreen();
130-
transferredRef.current = true;
131-
void api.register(Comlink.transfer(offscreen, [offscreen]), config).then((id) => {
132-
instanceIdRef.current = id;
133-
});
134-
} catch {
135-
// Transfer failed, fall back to main thread
136-
runMainThreadAnimation(canvas, config);
137-
}
138-
} else if (api && instanceIdRef.current !== null) {
139-
// Already registered, just update config
140-
void api.update(instanceIdRef.current, config);
141-
} else if (!api) {
142-
// No worker, run on main thread
143-
return runMainThreadAnimation(canvas, config);
144-
}
145-
146-
return () => {
147-
if (api && instanceIdRef.current !== null) {
148-
void api.unregister(instanceIdRef.current);
149-
instanceIdRef.current = null;
150-
}
151-
};
152-
}, [children, colorClass, duration, spread]);
153-
15438
return (
155-
<Component className={cn("inline", className)} data-chromatic="ignore">
156-
<canvas ref={canvasRef} className="inline" />
39+
<Component
40+
className={cn("shimmer-text", className)}
41+
data-chromatic="ignore"
42+
style={
43+
{
44+
"--shimmer-duration": `${duration}s`,
45+
"--shimmer-color": colorClass,
46+
} as React.CSSProperties
47+
}
48+
>
49+
{children}
15750
</Component>
15851
);
15952
};
16053

161-
// ─────────────────────────────────────────────────────────────────────────────
162-
// Main Thread Fallback
163-
// ─────────────────────────────────────────────────────────────────────────────
164-
165-
interface ShimmerConfig {
166-
text: string;
167-
font: string;
168-
color: string;
169-
bgColor: string;
170-
duration: number;
171-
spread: number;
172-
dpr: number;
173-
textWidth: number;
174-
textHeight: number;
175-
baselineY: number;
176-
}
177-
178-
function runMainThreadAnimation(canvas: HTMLCanvasElement, config: ShimmerConfig): () => void {
179-
const ctx = canvas.getContext("2d");
180-
if (!ctx) {
181-
return function cleanup() {
182-
// No animation started - nothing to clean up
183-
};
184-
}
185-
186-
const { text, font, color, bgColor, duration, spread, dpr, textWidth, textHeight, baselineY } =
187-
config;
188-
const durationMs = duration * 1000;
189-
const startTime = performance.now();
190-
let animationId: number;
191-
192-
const animate = (now: number) => {
193-
const elapsed = now - startTime;
194-
const progress = 1 - (elapsed % durationMs) / durationMs;
195-
196-
ctx.clearRect(0, 0, canvas.width, canvas.height);
197-
ctx.save();
198-
ctx.scale(dpr, dpr);
199-
200-
ctx.font = font;
201-
ctx.fillStyle = color;
202-
ctx.fillText(text, 0, baselineY);
203-
204-
const dynamicSpread = (text?.length ?? 0) * spread;
205-
const gradientCenter = progress * textWidth * 2.5 - textWidth * 0.75;
206-
const gradient = ctx.createLinearGradient(
207-
gradientCenter - dynamicSpread,
208-
0,
209-
gradientCenter + dynamicSpread,
210-
0
211-
);
212-
gradient.addColorStop(0, "transparent");
213-
gradient.addColorStop(0.5, bgColor);
214-
gradient.addColorStop(1, "transparent");
215-
216-
ctx.globalCompositeOperation = "source-atop";
217-
ctx.fillStyle = gradient;
218-
ctx.fillRect(0, 0, textWidth, textHeight);
219-
220-
ctx.restore();
221-
animationId = requestAnimationFrame(animate);
222-
};
223-
224-
animationId = requestAnimationFrame(animate);
225-
return () => cancelAnimationFrame(animationId);
226-
}
227-
22854
export const Shimmer = memo(ShimmerComponent);

src/browser/styles/globals.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,6 +1665,40 @@ pre code {
16651665
}
16661666
}
16671667

1668+
/* Shimmer text effect - gradient clipped to text, animated via background-position */
1669+
/* Dark band sweeps across text, works with any base color */
1670+
.shimmer-text {
1671+
--shimmer-color: var(--color-muted-foreground);
1672+
--shimmer-dark: color-mix(in srgb, var(--shimmer-color) 25%, black);
1673+
display: inline; /* Prevent layout shift - behave exactly like normal text */
1674+
background: linear-gradient(
1675+
90deg,
1676+
var(--shimmer-color) 0%,
1677+
var(--shimmer-color) 35%,
1678+
var(--shimmer-dark) 50%,
1679+
var(--shimmer-color) 65%,
1680+
var(--shimmer-color) 100%
1681+
);
1682+
background-size: 300% 100%;
1683+
background-clip: text;
1684+
-webkit-background-clip: text;
1685+
color: transparent;
1686+
animation: shimmer-text-sweep var(--shimmer-duration, 1.4s) linear infinite;
1687+
/* Decoration inheritance must be explicit for background-clip: text */
1688+
text-decoration: inherit;
1689+
box-decoration-break: clone;
1690+
-webkit-box-decoration-break: clone;
1691+
}
1692+
1693+
@keyframes shimmer-text-sweep {
1694+
from {
1695+
background-position: 100% 0;
1696+
}
1697+
to {
1698+
background-position: 0% 0;
1699+
}
1700+
}
1701+
16681702
@keyframes toastSlideIn {
16691703
from {
16701704
transform: translateY(10px);

0 commit comments

Comments
 (0)