11"use client" ;
22
33import { cn } from "@/common/lib/utils" ;
4- import * as Comlink from "comlink" ;
54import 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
97export 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 */
6131const 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-
22854export const Shimmer = memo ( ShimmerComponent ) ;
0 commit comments