Building `lensjs` β How I Crafted a Zero-Dependency React Library for Premium Image Effects
Static images are the status quo of the modern web. We wrap them in simple transitions, scale them on hover, or apply basic CSS filters. But what if we could make them feel alive? What if we could overlay glassmorphic reflections, CRT scanlines, interactive neon borders, and complex pixel-level shaders that dynamically react to the userβs cursor?
This is the development log of lensjs (available under @alikaner/lensjs), a zero-dependency React library I built to bridge the gap between GPU-accelerated CSS effects and raw HTML5 canvas shaders.
Here is how I designed it, the engineering hurdles I solved, and how I kept the rendering pipeline locked at 60fps.
π οΈ The Architecture: CSS vs. Canvas
When designing lensjs, I realized early on that interactive image effects fall into two distinct performance layers. I chose to handle them using a hybrid layout:
+-----------------------------+
| <LensImage /> Tag |
+--------------+--------------+
|
+----------------------+----------------------+
| |
[ CSS Rendering Layer ] [ Canvas Shader Layer ]
- GPU hardware acceleration - Real-time CPU buffer loops
- Translates mouse coordinates - Swirls, Glitches, Halftones
- clip-path, filters, opacity - Output drawn to overlay canvas
- The CSS Layer (GPU-Accelerated): Effects like frosted glass, reflection glare, neon spotlights, and 3D tilts. These are driven by CSS transitions, CSS variables, and native filters.
- The Canvas Layer (Pixel Shaders): Complex spatial distortions like vortex swirls, wave patterns, CRT scanlines, and RGB-split glitches. These require reading the raw RGBA buffer of the image, running algebraic pixel transformations, and rendering the result to an overlay canvas.
By separating these concerns, I could run lightweight CSS transitions on the GPU while selectively activating the CPU-bound canvas loop only when needed.
π Deep-Dive: Implementing the Interactive Viewport Lens
One of the most satisfying components to implement was the Viewport Lens effects (magnify, blur-lens, grayscale-lens).
Instead of drawing zoomed or blurred versions of the image onto a heavy canvas overlay frame-by-frame, I implemented this entirely in CSS using dynamic clip-paths and CSS custom variables.
The React Listener
First, we listen to cursor movements over the image wrapper, calculate the coordinates relative to the wrapper bounds, and set them as CSS custom variables:
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
wrapper.style.setProperty('--lens-x', `${x}px`);
wrapper.style.setProperty('--lens-y', `${y}px`);
// Normalized offsets (-1 to 1) relative to center, used for 3D card tilt
wrapper.style.setProperty('--lens-dx', ((x / rect.width) * 2 - 1).toFixed(3));
wrapper.style.setProperty('--lens-dy', ((y / rect.height) * 2 - 1).toFixed(3));
};
The CSS Masking
Using the CSS variables, we position a secondary hidden duplicate viewport layer exactly over the base image, and apply a cursor-following clip-path:
.lens-viewport {
position: absolute;
inset: 0;
display: block;
overflow: hidden;
/* Clip-path follows the cursor position variables */
clip-path: circle(calc(var(--lens-size, 130px) / 2) at var(--lens-x, 50%) var(--lens-y, 50%));
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.lens-image-wrapper:hover .lens-viewport {
opacity: 1;
}
For the magnify effect, we simply scale the duplicate image within the lens viewport using the cursor position as the transform origin:
.lens-image-wrapper[data-lens-effect="magnify"] .lens-viewport img {
transform: scale(calc(1 + var(--lens-intensity, 1)));
transform-origin: var(--lens-x, 50%) var(--lens-y, 50%);
transition: none; /* Instant feedback during cursor movement */
}
This CSS-first approach gives us ultra-smooth, hardware-accelerated magnifying and color-filtering lenses with virtually zero JavaScript overhead during animation ticks!
π¨ The Pixel Engine: Writing the Canvas Shaders
For canvas distortion effects (like vortex swirls or wave ripples), we have to write custom shaders in JavaScript. Since JavaScript is single-threaded, these loops must be highly optimized.
Here is the general execution flow:
- Extract the
ImageDatabuffer from the source image. - Initialize a secondary target buffer.
- Iterate over every pixel coordinate, map the input coordinates to transformed coordinates using trigonometric operations, and write the source pixel color to the destination index.
- Draw the destination buffer back onto the canvas.
Here is a simplified example of the Vortex (swirl) pixel transformer:
export function applyVortex(src: PixelData, dst: PixelData, strength: number): void {
const { width, height, data: srcData } = src;
const dstData = dst.data;
const centerX = width / 2;
const centerY = height / 2;
const maxRadius = Math.min(centerX, centerY);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < maxRadius) {
// Swirl angle scales down to 0 at the radius boundary
const progress = (maxRadius - distance) / maxRadius;
const angle = progress * progress * strength;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
// Map current pixel back to source coordinates
const sourceX = Math.round(centerX + (dx * cos - dy * sin));
const sourceY = Math.round(centerY + (dx * sin + dy * cos));
// Clamp to image bounds
const clampedX = Math.max(0, Math.min(width - 1, sourceX));
const clampedY = Math.max(0, Math.min(height - 1, sourceY));
const srcIdx = (clampedY * width + clampedX) * 4;
const dstIdx = (y * width + x) * 4;
dstData[dstIdx] = srcData[srcIdx]; // Red
dstData[dstIdx + 1] = srcData[srcIdx + 1]; // Green
dstData[dstIdx + 2] = srcData[srcIdx + 2]; // Blue
dstData[dstIdx + 3] = srcData[srcIdx + 3]; // Alpha
} else {
// Outside the vortex radius, copy the pixel directly
const idx = (y * width + x) * 4;
dstData[idx] = srcData[idx];
dstData[idx + 1] = srcData[idx + 1];
dstData[idx + 2] = srcData[idx + 2];
dstData[idx + 3] = srcData[idx + 3];
}
}
}
}
β‘ Crucial Performance Optimizations
Running nested loops over millions of pixels on every animation frame can easily cause frame drops. I applied two critical rules to optimize the canvas pipeline:
1. The 600px Max-Resolution Cap
Interactive canvas overlays are capped at 600px on their longest side. When the image is loaded:
- We create an in-memory thumbnail canvas capped at
600px. - The raw pixel calculations are run on this smaller buffer (which is at most 360,000 pixels instead of millions).
- The overlay
<canvas>is styled to cover the wrapper usingwidth: 100%; height: 100%. - The browser's GPU automatically handles the upscaling using hardware-accelerated interpolation.
This single optimization reduced raw calculation times by up to 85% on large images.
2. Hover-Only RAF Suspension
For animated canvas shaders (like wave, melt, or noise animation grain), we rely on a requestAnimationFrame render loop. To prevent running these loops for idle elements:
- The loop only starts when the wrapper receives the
onMouseEnterevent. - It automatically halts (
cancelAnimationFrame) ononMouseLeave. - Static shaders (like a single vortex swirl) calculate the frame only once when the mouse moves, instead of running continuously.
π¦ Bundling and Packaging
I set up the project as a monorepo using npm workspaces:
/packages/lensjs: Contains the core package written in TypeScript. We bundle ESM (.mjs) and CJS (.js) builds usingtsupalongside automatically generated TypeScript declaration maps (.d.ts)./apps/docs: Our documentation, interactive code sandbox, and component showcase built using React, Vite, and custom layout styling.
π‘ Key Takeaways
Building lensjs taught me the value of choosing the right rendering layer for the job:
- Keep JavaScript simple: If you can achieve an effect using native CSS transitions, filters, clip-paths, and CSS variable updates, use CSS. The GPU will thank you.
- Handle Canvas with care: When dropping down to the pixel level, limit the data scale (like using resolution limits) and immediately suspend event loops the second the element is out of focus.
The library is fully open-source and ready for premium React applications. Let me know what you think!
GitHub: alikaner/lensjs
npm: @alikaner/lensjs