11"use client" ;
22
33import { cn } from "@/common/lib/utils" ;
4- import { motion } from "motion/react" ;
5- import { type CSSProperties , type ElementType , type JSX , memo , useMemo } from "react" ;
4+ import * as Comlink from "comlink" ;
5+ import type { ElementType } from "react" ;
6+ import { memo , useEffect , useRef } from "react" ;
7+ import type { ShimmerWorkerAPI } from "@/browser/workers/shimmerWorker" ;
68
79export interface TextShimmerProps {
810 children : string ;
@@ -13,43 +15,214 @@ export interface TextShimmerProps {
1315 colorClass ?: string ;
1416}
1517
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+
54+ /**
55+ * GPU-accelerated shimmer text effect using OffscreenCanvas in a Web Worker.
56+ *
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.
60+ */
1661const ShimmerComponent = ( {
1762 children,
18- as : Component = "p " ,
63+ as : Component = "span " ,
1964 className,
2065 duration = 2 ,
2166 spread = 2 ,
2267 colorClass = "var(--color-muted-foreground)" ,
2368} : TextShimmerProps ) => {
24- const MotionComponent = motion . create ( Component as keyof JSX . IntrinsicElements ) ;
69+ const canvasRef = useRef < HTMLCanvasElement > ( null ) ;
70+ const instanceIdRef = useRef < number | null > ( null ) ;
71+ const transferredRef = useRef ( false ) ;
2572
26- const dynamicSpread = useMemo ( ( ) => ( children ?. length ?? 0 ) * spread , [ children , spread ] ) ;
73+ useEffect ( ( ) => {
74+ const canvas = canvasRef . current ;
75+ if ( ! canvas ) return ;
2776
28- return (
29- < MotionComponent
30- animate = { { backgroundPosition : "0% center" } }
31- className = { cn (
32- "relative bg-[length:250%_100%,auto] bg-clip-text text-transparent" ,
33- "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]" ,
34- className
35- ) }
36- data-chromatic = "ignore"
37- initial = { { backgroundPosition : "100% center" } }
38- style = {
39- {
40- "--spread" : `${ dynamicSpread } px` ,
41- backgroundImage : `var(--bg), linear-gradient(${ colorClass } , ${ colorClass } )` ,
42- } as CSSProperties
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 ) ;
43137 }
44- transition = { {
45- repeat : Number . POSITIVE_INFINITY ,
46- duration,
47- ease : "linear" ,
48- } }
49- >
50- { children }
51- </ MotionComponent >
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+
154+ return (
155+ < Component className = { cn ( "inline" , className ) } data-chromatic = "ignore" >
156+ < canvas ref = { canvasRef } className = "inline" />
157+ </ Component >
52158 ) ;
53159} ;
54160
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+
55228export const Shimmer = memo ( ShimmerComponent ) ;
0 commit comments