Components

Proximity Image Row

A macOS Dock-style interactive image row. Supports both a discrete CSS-based hover effect and a fluid, high-performance JS-driven cursor tracking effect.

Fluid Cursor Tracking

The cursor variant uses a JavaScript animation loop to calculate distance using Gaussian math. We've tuned the lerpFactor to 0.2 here for a snappier, more responsive feel, perfect for the compact 100px base size.

Cursor Mode

Fluid tracking with tuned responsiveness (lerpFactor 0.2)

Image 6
Image 7
Image 8
Image 9

Discrete Hover

The hover variant relies on CSS transitions and index distance. It provides a more structured, "stepped" expansion effect that is lighter on resources and ideal for standard navigation patterns.

Hover Mode

Discrete index-based expansion

Image 6
Image 7
Image 8
Image 9

Installation

npx shadcn@latest add https://satisui.xyz/r/proximity-image-row.json

Manual

No external dependencies are required.

Copy and paste the following code into components/satisui/proximity-image-row.tsx:

'use client';

import React, { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { cn } from '@/lib/utils';

interface ImageProps {
  src: string;
  alt: string;
}

/**
 * Props for the ProximityImageRow component.
 */
export interface ProximityImageRowProps
  extends React.HTMLAttributes<HTMLDivElement> {
  /** Array of image objects to display. */
  images: ImageProps[];
  /**
   * Interaction mode:
   * - 'hover': Discrete expansion based on item index (CSS transitions).
   * - 'cursor': Fluid, pixel-perfect expansion based on mouse position (JS animation).
   */
  variant?: 'hover' | 'cursor';
  /** If true, images are grayscale until hovered/active. */
  grayscaleEffect?: boolean;
  /** Resting width of an item in pixels. */
  baseWidth?: number;
  /** Maximum width of an item when fully expanded. */
  targetWidth?: number;
  /** Resting height of an item in pixels. */
  baseHeight?: number;
  /** Maximum height of an item when fully expanded. */
  targetHeight?: number;
  /** ('hover' variant only) How many neighbors are affected by the hover. */
  proximityIndexDistance?: number;
  /** ('cursor' variant only) The width of the magnification bell curve in pixels. */
  spread?: number;
  /** ('cursor' variant only) Animation smoothing factor (0.1 = slow, 1 = instant). */
  lerpFactor?: number;
}

const lerp = (start: number, end: number, factor: number) =>
  start + (end - start) * factor;

const getGaussianFactor = (distance: number, spread: number) => {
  return Math.exp(-Math.pow(distance, 2) / (2 * Math.pow(spread, 2)));
};

/**
 * A macOS Dock-style interactive image row.
 * Supports both a discrete CSS-based hover effect and a fluid, high-performance
 * JS-driven cursor tracking effect.
 */
const ProximityImageRow = React.forwardRef<
  HTMLDivElement,
  ProximityImageRowProps
>(
  (
    {
      images,
      className,
      variant = 'hover',
      grayscaleEffect = false,
      baseWidth = 64,
      targetWidth = 180,
      baseHeight = 280,
      targetHeight = 320,
      proximityIndexDistance = 3,
      spread = 180,
      lerpFactor = 0.1,
      ...props
    },
    ref
  ) => {
    const containerRef = useRef<HTMLDivElement>(null);
    const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);

    const mouseXRef = useRef<number | null>(null);
    const rafIdRef = useRef<number | null>(null);

    // PERFORMANCE: Direct DOM manipulation loop for 'cursor' variant.
    // React state updates are too slow for fluid 60fps mouse tracking.
    useEffect(() => {
      const container = containerRef.current;
      if (!container || variant !== 'cursor') return;

      const handleMouseMove = (e: MouseEvent) => {
        const rect = container.getBoundingClientRect();
        mouseXRef.current = e.clientX - rect.left;
      };

      const handleMouseLeave = () => {
        mouseXRef.current = null;
      };

      const animate = () => {
        const cards = Array.from(container.children) as HTMLDivElement[];

        cards.forEach((card) => {
          // Read current DOM dimensions to seed the LERP for continuous physics
          const currentWidth = parseFloat(card.style.width) || baseWidth;
          const currentHeight = parseFloat(card.style.height) || baseHeight;

          let targetW = baseWidth;
          let targetH = baseHeight;
          let targetGrayscale = grayscaleEffect ? 1 : 0;

          if (mouseXRef.current !== null) {
            const rect = card.getBoundingClientRect();
            const containerRect = container.getBoundingClientRect();
            const cardCenterX = rect.left - containerRect.left + rect.width / 2;
            const distance = Math.abs(mouseXRef.current - cardCenterX);

            const growthFactor = getGaussianFactor(distance, spread);

            targetW = baseWidth + (targetWidth - baseWidth) * growthFactor;
            targetH = baseHeight + (targetHeight - baseHeight) * growthFactor;

            if (grayscaleEffect) {
              targetGrayscale = 1 - growthFactor;
            }
          }

          const newWidth = lerp(currentWidth, targetW, lerpFactor);
          const newHeight = lerp(currentHeight, targetH, lerpFactor);

          card.style.width = `${newWidth}px`;
          card.style.height = `${newHeight}px`;

          if (grayscaleEffect) {
            const imgWrapper = card.querySelector(
              '.img-wrapper'
            ) as HTMLElement;
            if (imgWrapper) {
              imgWrapper.style.filter = `grayscale(${targetGrayscale})`;
            }
          }
        });

        rafIdRef.current = requestAnimationFrame(animate);
      };

      animate();

      container.addEventListener('mousemove', handleMouseMove);
      container.addEventListener('mouseleave', handleMouseLeave);

      return () => {
        if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
        container.removeEventListener('mousemove', handleMouseMove);
        container.removeEventListener('mouseleave', handleMouseLeave);
      };
    }, [
      variant,
      baseWidth,
      targetWidth,
      baseHeight,
      targetHeight,
      grayscaleEffect,
      spread,
      lerpFactor,
    ]);

    return (
      <div
        ref={ref}
        className={cn('flex items-center justify-center', className)}
        {...props}
      >
        <div
          ref={containerRef}
          className='relative flex items-end justify-center gap-4'
          style={{ height: `${targetHeight}px` }}
          onMouseLeave={
            variant === 'hover' ? () => setHoveredIndex(null) : undefined
          }
        >
          {images.map((image, index) => {
            let indexStyle = {};
            let indexGrayscale = 0;

            // Logic for 'hover' variant (calculated during render)
            if (variant === 'hover') {
              let growthFactor = 0;
              if (hoveredIndex !== null) {
                const indexDistance = Math.abs(hoveredIndex - index);
                if (indexDistance <= proximityIndexDistance) {
                  growthFactor = Math.pow(0.5, indexDistance);
                }
              }
              const width =
                baseWidth + (targetWidth - baseWidth) * growthFactor;
              const height =
                baseHeight + (targetHeight - baseHeight) * growthFactor;

              indexStyle = { width: `${width}px`, height: `${height}px` };
              indexGrayscale =
                grayscaleEffect && hoveredIndex !== null ? 1 - growthFactor : 0;
            }

            return (
              <div
                key={index}
                className={cn(
                  'will-change-[width,height]',
                  // CSS transitions fight the JS LERP loop, so they are exclusive to 'hover'
                  variant === 'hover' &&
                    'transition-[width,height] duration-300 ease-out'
                )}
                style={
                  variant === 'hover'
                    ? indexStyle
                    : { width: `${baseWidth}px`, height: `${baseHeight}px` }
                }
                onMouseEnter={
                  variant === 'hover' ? () => setHoveredIndex(index) : undefined
                }
              >
                <div
                  className={cn(
                    'img-wrapper relative h-full w-full overflow-hidden rounded-lg bg-neutral-800 shadow-lg',
                    variant === 'hover' &&
                      'transition-[filter] duration-300 ease-out'
                  )}
                  style={{
                    filter:
                      variant === 'hover'
                        ? grayscaleEffect
                          ? `grayscale(${indexGrayscale})`
                          : undefined
                        : undefined,
                  }}
                >
                  <Image
                    src={image.src}
                    alt={image.alt}
                    fill
                    className='object-cover'
                    sizes={`${targetWidth}px`}
                    priority={index < 3}
                    draggable={false}
                  />
                </div>
              </div>
            );
          })}
        </div>
      </div>
    );
  }
);

ProximityImageRow.displayName = 'ProximityImageRow';

export default ProximityImageRow;

Usage

The ProximityImageRow works best with a set of vertical images. You can choose between a discrete "hover" mode or a fluid "cursor" mode that mimics the macOS Dock.

import ProximityImageRow from '@/components/proximity-image-row';

const images = [
  { src: '/img1.jpg', alt: 'Image 1' },
  { src: '/img2.jpg', alt: 'Image 2' },
  { src: '/img3.jpg', alt: 'Image 3' },
  { src: '/img4.jpg', alt: 'Image 4' },
  { src: '/img5.jpg', alt: 'Image 5' },
];

export default function Example() {
  return (
    <div className='flex flex-col gap-12 p-10'>
      {/* Default Hover Variant */}
      <ProximityImageRow images={images} variant='hover' />

      {/* Fluid Cursor Variant with Grayscale Effect */}
      <ProximityImageRow
        images={images}
        variant='cursor'
        grayscaleEffect={true}
        spread={200}
      />
    </div>
  );
}

Props

PropTypeDefaultDescription
imagesImageProps[]RequiredArray of image objects to display.
variant'hover' | 'cursor''hover'Interaction mode: discrete expansion (hover) or fluid mouse tracking (cursor).
grayscaleEffectbooleanfalseIf true, images are grayscale until hovered/active.
baseWidthnumber64Resting width of an item in pixels.
targetWidthnumber180Maximum width of an item when fully expanded.
baseHeightnumber280Resting height of an item in pixels.
targetHeightnumber320Maximum height of an item when fully expanded.
proximityIndexDistancenumber3('hover' variant only) How many neighbors are affected by the hover.
spreadnumber180('cursor' variant only) The width of the magnification bell curve in pixels.
lerpFactornumber0.1('cursor' variant only) Animation smoothing factor (0.1 = slow, 1 = instant).