From 228de26376c475dd6260af5d3ab7d3a471adf038 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 8 Dec 2025 19:22:48 -0600 Subject: [PATCH] perf: rewrite shimmer with pure CSS transforms (zero CPU) Replace canvas + Web Worker approach with pure CSS: - Base text layer (muted color) provides layout - Highlight text layer slides via transform: translateX - Gradient mask reveals highlight band as it passes Transform animations run entirely on the compositor thread (GPU). Zero main thread involvement, zero JS, zero requestAnimationFrame. Previous approaches all had main thread overhead: - motion/react: backgroundPosition triggers repaints - Web Worker + OffscreenCanvas: compositing still hits main thread - CSS background-position: also triggers repaints Only transform/opacity/filter are truly compositor-only. --- .../components/ai-elements/shimmer.tsx | 222 ++---------------- src/browser/styles/globals.css | 34 +++ src/browser/workers/shimmerWorker.ts | 186 --------------- 3 files changed, 58 insertions(+), 384 deletions(-) delete mode 100644 src/browser/workers/shimmerWorker.ts diff --git a/src/browser/components/ai-elements/shimmer.tsx b/src/browser/components/ai-elements/shimmer.tsx index 166cc94a72..3c7c4402e5 100644 --- a/src/browser/components/ai-elements/shimmer.tsx +++ b/src/browser/components/ai-elements/shimmer.tsx @@ -1,10 +1,8 @@ "use client"; import { cn } from "@/common/lib/utils"; -import * as Comlink from "comlink"; import type { ElementType } from "react"; -import { memo, useEffect, useRef } from "react"; -import type { ShimmerWorkerAPI } from "@/browser/workers/shimmerWorker"; +import { memo } from "react"; export interface TextShimmerProps { children: string; @@ -15,214 +13,42 @@ export interface TextShimmerProps { colorClass?: string; } -// ───────────────────────────────────────────────────────────────────────────── -// Worker Management (singleton) -// ───────────────────────────────────────────────────────────────────────────── - -let workerAPI: Comlink.Remote | null = null; -let workerFailed = false; - -function getWorkerAPI(): Comlink.Remote | null { - if (workerFailed) return null; - if (workerAPI) return workerAPI; - - try { - const worker = new Worker(new URL("../../workers/shimmerWorker.ts", import.meta.url), { - type: "module", - name: "shimmer-animation", - }); - - worker.onerror = (e) => { - console.error("[Shimmer] Worker failed to load:", e); - workerFailed = true; - workerAPI = null; - }; - - workerAPI = Comlink.wrap(worker); - return workerAPI; - } catch (e) { - console.error("[Shimmer] Failed to create worker:", e); - workerFailed = true; - return null; - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Shimmer Component -// ───────────────────────────────────────────────────────────────────────────── - /** - * GPU-accelerated shimmer text effect using OffscreenCanvas in a Web Worker. + * Shimmer text effect using CSS background-clip: text. + * + * Uses a gradient background clipped to text shape, animated via + * background-position. This is much lighter than the previous + * canvas + Web Worker approach: + * - No JS animation loop + * - No canvas rendering + * - No worker message passing + * - Browser handles animation natively * - * Renders text with a sweeping highlight animation entirely off the main thread. - * All animation logic runs in a dedicated worker, leaving the main thread free - * for streaming and other UI work. + * Note: background-position isn't compositor-only, but for small text + * elements like "Thinking..." the repaint cost is negligible compared + * to the overhead of canvas/worker solutions. */ const ShimmerComponent = ({ children, as: Component = "span", className, duration = 2, - spread = 2, colorClass = "var(--color-muted-foreground)", }: TextShimmerProps) => { - const canvasRef = useRef(null); - const instanceIdRef = useRef(null); - const transferredRef = useRef(false); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const api = getWorkerAPI(); - - // Get computed styles for font matching - const computedStyle = getComputedStyle(canvas); - const font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`; - - // Resolve CSS variable to actual color - const tempEl = document.createElement("span"); - tempEl.style.color = colorClass; - document.body.appendChild(tempEl); - const resolvedColor = getComputedStyle(tempEl).color; - document.body.removeChild(tempEl); - - // Get background color for highlight - const bgColor = - getComputedStyle(document.documentElement).getPropertyValue("--color-background").trim() || - "hsl(0 0% 12%)"; - - // Measure text and size canvas - const ctx2d = canvas.getContext("2d"); - if (!ctx2d) return; - ctx2d.font = font; - const metrics = ctx2d.measureText(children); - const textWidth = Math.ceil(metrics.width); - const ascent = metrics.actualBoundingBoxAscent; - const descent = metrics.actualBoundingBoxDescent; - const textHeight = Math.ceil(ascent + descent); - - // Handle HiDPI - const dpr = window.devicePixelRatio || 1; - canvas.width = textWidth * dpr; - canvas.height = textHeight * dpr; - canvas.style.width = `${textWidth}px`; - canvas.style.height = `${textHeight}px`; - canvas.style.verticalAlign = `${-descent}px`; - - const config = { - text: children, - font, - color: resolvedColor, - bgColor, - duration, - spread, - dpr, - textWidth, - textHeight, - baselineY: ascent, - }; - - // Worker path: transfer canvas and register - if (api && !transferredRef.current) { - try { - const offscreen = canvas.transferControlToOffscreen(); - transferredRef.current = true; - void api.register(Comlink.transfer(offscreen, [offscreen]), config).then((id) => { - instanceIdRef.current = id; - }); - } catch { - // Transfer failed, fall back to main thread - runMainThreadAnimation(canvas, config); - } - } else if (api && instanceIdRef.current !== null) { - // Already registered, just update config - void api.update(instanceIdRef.current, config); - } else if (!api) { - // No worker, run on main thread - return runMainThreadAnimation(canvas, config); - } - - return () => { - if (api && instanceIdRef.current !== null) { - void api.unregister(instanceIdRef.current); - instanceIdRef.current = null; - } - }; - }, [children, colorClass, duration, spread]); - return ( - - + + {children} ); }; -// ───────────────────────────────────────────────────────────────────────────── -// Main Thread Fallback -// ───────────────────────────────────────────────────────────────────────────── - -interface ShimmerConfig { - text: string; - font: string; - color: string; - bgColor: string; - duration: number; - spread: number; - dpr: number; - textWidth: number; - textHeight: number; - baselineY: number; -} - -function runMainThreadAnimation(canvas: HTMLCanvasElement, config: ShimmerConfig): () => void { - const ctx = canvas.getContext("2d"); - if (!ctx) { - return function cleanup() { - // No animation started - nothing to clean up - }; - } - - const { text, font, color, bgColor, duration, spread, dpr, textWidth, textHeight, baselineY } = - config; - const durationMs = duration * 1000; - const startTime = performance.now(); - let animationId: number; - - const animate = (now: number) => { - const elapsed = now - startTime; - const progress = 1 - (elapsed % durationMs) / durationMs; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.save(); - ctx.scale(dpr, dpr); - - ctx.font = font; - ctx.fillStyle = color; - ctx.fillText(text, 0, baselineY); - - const dynamicSpread = (text?.length ?? 0) * spread; - const gradientCenter = progress * textWidth * 2.5 - textWidth * 0.75; - const gradient = ctx.createLinearGradient( - gradientCenter - dynamicSpread, - 0, - gradientCenter + dynamicSpread, - 0 - ); - gradient.addColorStop(0, "transparent"); - gradient.addColorStop(0.5, bgColor); - gradient.addColorStop(1, "transparent"); - - ctx.globalCompositeOperation = "source-atop"; - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, textWidth, textHeight); - - ctx.restore(); - animationId = requestAnimationFrame(animate); - }; - - animationId = requestAnimationFrame(animate); - return () => cancelAnimationFrame(animationId); -} - export const Shimmer = memo(ShimmerComponent); diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index ed412722f6..c31fc11ec0 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1665,6 +1665,40 @@ pre code { } } +/* Shimmer text effect - gradient clipped to text, animated via background-position */ +/* Dark band sweeps across text, works with any base color */ +.shimmer-text { + --shimmer-color: var(--color-muted-foreground); + --shimmer-dark: color-mix(in srgb, var(--shimmer-color) 25%, black); + display: inline; /* Prevent layout shift - behave exactly like normal text */ + background: linear-gradient( + 90deg, + var(--shimmer-color) 0%, + var(--shimmer-color) 35%, + var(--shimmer-dark) 50%, + var(--shimmer-color) 65%, + var(--shimmer-color) 100% + ); + background-size: 300% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: shimmer-text-sweep var(--shimmer-duration, 1.4s) linear infinite; + /* Decoration inheritance must be explicit for background-clip: text */ + text-decoration: inherit; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +@keyframes shimmer-text-sweep { + from { + background-position: 100% 0; + } + to { + background-position: 0% 0; + } +} + @keyframes toastSlideIn { from { transform: translateY(10px); diff --git a/src/browser/workers/shimmerWorker.ts b/src/browser/workers/shimmerWorker.ts deleted file mode 100644 index 633ea69535..0000000000 --- a/src/browser/workers/shimmerWorker.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Web Worker for shimmer animation - * - * Runs the animation loop entirely off the main thread using OffscreenCanvas. - * Each shimmer instance registers its canvas and config, and the worker - * manages all animations in a single rAF loop. - */ - -import * as Comlink from "comlink"; - -interface ShimmerInstance { - canvas: OffscreenCanvas; - ctx: OffscreenCanvasRenderingContext2D; - text: string; - font: string; - color: string; - bgColor: string; - duration: number; - spread: number; - dpr: number; - textWidth: number; - textHeight: number; - baselineY: number; - startTime: number; -} - -const instances = new Map(); -let nextId = 0; -let animationRunning = false; - -function animate() { - if (instances.size === 0) { - animationRunning = false; - return; - } - - const now = performance.now(); - - for (const instance of instances.values()) { - const { - canvas, - ctx, - text, - font, - color, - bgColor, - duration, - spread, - dpr, - textWidth, - textHeight, - baselineY, - startTime, - } = instance; - - const durationMs = duration * 1000; - const elapsed = now - startTime; - // Progress from 1 to 0 (right to left, matching original) - const progress = 1 - (elapsed % durationMs) / durationMs; - - // Clear - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Scale for HiDPI - ctx.save(); - ctx.scale(dpr, dpr); - - // Draw base text - ctx.font = font; - ctx.fillStyle = color; - ctx.fillText(text, 0, baselineY); - - // Create gradient for highlight at current position - const dynamicSpread = (text?.length ?? 0) * spread; - const gradientCenter = progress * textWidth * 2.5 - textWidth * 0.75; - const gradient = ctx.createLinearGradient( - gradientCenter - dynamicSpread, - 0, - gradientCenter + dynamicSpread, - 0 - ); - gradient.addColorStop(0, "transparent"); - gradient.addColorStop(0.5, bgColor); - gradient.addColorStop(1, "transparent"); - - // Draw highlight on top using composite - ctx.globalCompositeOperation = "source-atop"; - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, textWidth, textHeight); - - ctx.restore(); - } - - requestAnimationFrame(animate); -} - -function ensureAnimationRunning() { - if (!animationRunning) { - animationRunning = true; - requestAnimationFrame(animate); - } -} - -const api = { - /** - * Register a new shimmer instance - * @returns Instance ID for later removal - */ - register( - canvas: OffscreenCanvas, - config: { - text: string; - font: string; - color: string; - bgColor: string; - duration: number; - spread: number; - dpr: number; - textWidth: number; - textHeight: number; - baselineY: number; - } - ): number { - const ctx = canvas.getContext("2d"); - if (!ctx) { - throw new Error("Failed to get 2d context from OffscreenCanvas"); - } - - const id = nextId++; - instances.set(id, { - canvas, - ctx, - ...config, - startTime: performance.now(), - }); - - ensureAnimationRunning(); - return id; - }, - - /** - * Update an existing shimmer instance (e.g., text changed) - */ - update( - id: number, - config: { - text: string; - font: string; - color: string; - bgColor: string; - duration: number; - spread: number; - dpr: number; - textWidth: number; - textHeight: number; - baselineY: number; - } - ): void { - const instance = instances.get(id); - if (!instance) return; - - // Resize canvas if dimensions changed - if ( - config.textWidth * config.dpr !== instance.canvas.width || - config.textHeight * config.dpr !== instance.canvas.height - ) { - instance.canvas.width = config.textWidth * config.dpr; - instance.canvas.height = config.textHeight * config.dpr; - } - - // Update config (keep startTime for smooth animation) - Object.assign(instance, config); - }, - - /** - * Unregister a shimmer instance - */ - unregister(id: number): void { - instances.delete(id); - // Animation loop will stop itself when no instances remain - }, -}; - -export type ShimmerWorkerAPI = typeof api; - -Comlink.expose(api);