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.
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.
Installation
CLI (Recommended)
npx shadcn@latest add https://satisui.xyz/r/proximity-image-row.jsonManual
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
| Prop | Type | Default | Description |
|---|---|---|---|
images | ImageProps[] | Required | Array of image objects to display. |
variant | 'hover' | 'cursor' | 'hover' | Interaction mode: discrete expansion (hover) or fluid mouse tracking (cursor). |
grayscaleEffect | boolean | false | If true, images are grayscale until hovered/active. |
baseWidth | number | 64 | Resting width of an item in pixels. |
targetWidth | number | 180 | Maximum width of an item when fully expanded. |
baseHeight | number | 280 | Resting height of an item in pixels. |
targetHeight | number | 320 | Maximum height of an item when fully expanded. |
proximityIndexDistance | number | 3 | ('hover' variant only) How many neighbors are affected by the hover. |
spread | number | 180 | ('cursor' variant only) The width of the magnification bell curve in pixels. |
lerpFactor | number | 0.1 | ('cursor' variant only) Animation smoothing factor (0.1 = slow, 1 = instant). |



