Dev Log Β· Jul 11, 2026

Building `lensjs` β€” How I Crafted a Zero-Dependency React Library for Premium Image Effects

csslensfiltersimageimg

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
  1. 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.
  2. 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:

  1. Extract the ImageData buffer from the source image.
  2. Initialize a secondary target buffer.
  3. 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.
  4. 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 using width: 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 onMouseEnter event.
  • It automatically halts (cancelAnimationFrame) on onMouseLeave.
  • 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 using tsup alongside 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