Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 24 additions & 198 deletions src/browser/components/ai-elements/shimmer.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,214 +13,42 @@ export interface TextShimmerProps {
colorClass?: string;
}

// ─────────────────────────────────────────────────────────────────────────────
// Worker Management (singleton)
// ─────────────────────────────────────────────────────────────────────────────

let workerAPI: Comlink.Remote<ShimmerWorkerAPI> | null = null;
let workerFailed = false;

function getWorkerAPI(): Comlink.Remote<ShimmerWorkerAPI> | 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<ShimmerWorkerAPI>(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<HTMLCanvasElement>(null);
const instanceIdRef = useRef<number | null>(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 (
<Component className={cn("inline", className)} data-chromatic="ignore">
<canvas ref={canvasRef} className="inline" />
<Component
className={cn("shimmer-text", className)}
data-chromatic="ignore"
style={
{
"--shimmer-duration": `${duration}s`,
"--shimmer-color": colorClass,
} as React.CSSProperties
}
>
{children}
</Component>
);
};

// ─────────────────────────────────────────────────────────────────────────────
// 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);
34 changes: 34 additions & 0 deletions src/browser/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading