Proximity Cards
A container that applies a 3D proximity-based hover effect to its children, creating a dynamic "wave" effect as the user's mouse moves.
Balanced Effect
This demo showcases the default, out-of-the-box settings with a collection of portrait-style images. The animation is balanced to be noticeable and engaging without being distracting, making it a great starting point for any project.
Subtle & Wide
Create a gentler, more atmospheric effect by reducing lift and tilt intensity while increasing the proximity range. This configuration is ideal for larger galleries or when you want the interaction to feel more ambient and less pronounced.
Intense & Focused
For a high-impact, dramatic presentation, increase the lift, scale, and tilt while narrowing the proximity. This creates a highly responsive and focused "spotlight" effect that draws maximum attention to the card directly under the cursor.
Installation
CLI
Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.
Manual
No external dependencies are required for this component.
Copy and paste the following code into components/satis-ui/proximity-cards.tsx
:
'use client';
import React, { useRef, useCallback, Children, ReactNode } from 'react';
import type { CSSProperties } from 'react';
import { cn } from '@/lib/utils';
interface ProximityCardsProps {
/** The animatable elements. Each direct child is treated as a separate card. */
children: ReactNode;
/** Optional classes for custom styling of the container element. */
className?: string;
/** The intensity of the tilt effect on the Y-axis. Higher values result in more dramatic tilting. */
tiltIntensity?: number;
/** The power of the easing curve for the proximity effect, creating a non-linear falloff. */
easingPower?: number;
/** The horizontal range of the proximity effect, as a factor of the container's width. */
proximity?: number;
/** The maximum "lift" (translateZ) applied to a card when the mouse is directly over it. */
maxLift?: number;
/** The maximum scale applied to a card when the mouse is directly over it. */
maxScale?: number;
/** The gap in pixels between each card element. */
gap?: number;
/** The CSS perspective value, which affects the intensity of the 3D effect. */
perspective?: number;
/** The duration of the CSS transitions in milliseconds. */
transitionDuration?: number;
/** The CSS transition timing function for the animation effects. */
transitionTimingFunction?: CSSProperties['transitionTimingFunction'];
/** Toggles the grayscale effect, where cards become fully colored on hover. */
enableGrayscale?: boolean;
}
/**
* A container component that applies a 3D proximity-based hover effect to its children.
* As the user moves their mouse across the container, child elements react by tilting,
* lifting, and scaling, creating a dynamic and interactive "wave" effect.
*/
const ProximityCards = ({
children,
className,
tiltIntensity = 0.2,
easingPower = 2,
proximity = 0.25,
maxLift = 90,
maxScale = 1.05,
gap = 16,
perspective = 1000,
transitionDuration = 300,
transitionTimingFunction = 'ease-out',
enableGrayscale = true,
}: ProximityCardsProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef(new Map<number, HTMLDivElement>());
const initialFilter = enableGrayscale ? 'grayscale(1)' : 'none';
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
for (const [index, card] of cardRefs.current.entries()) {
if (!card) continue;
const cardRect = card.getBoundingClientRect();
const cardCenterX = cardRect.left - rect.left + cardRect.width / 2;
const distance = mouseX - cardCenterX;
const absoluteDistance = Math.abs(distance);
const maxDistance = rect.width * proximity;
const normalizedProximity = Math.min(1, absoluteDistance / maxDistance);
// DECISION: Use Math.pow with an easingPower to create a non-linear falloff.
// This makes the effect feel more natural and less robotic than a linear calculation.
const proximityFactor = 1 - Math.pow(normalizedProximity, easingPower);
const lift = proximityFactor * maxLift;
const rotation = distance * proximityFactor * -tiltIntensity;
const scale = 1 + (maxScale - 1) * proximityFactor;
const grayscale = enableGrayscale ? 1 - proximityFactor : 0;
card.style.transform = `perspective(${perspective}px) rotateY(${rotation}deg) translateZ(${lift}px) scale(${scale})`;
card.style.filter = `grayscale(${grayscale})`;
// Dynamically adjust z-index to ensure the most "lifted" card is visually on top.
card.style.zIndex = `${Math.floor(lift)}`;
}
},
[
tiltIntensity,
easingPower,
proximity,
maxLift,
maxScale,
perspective,
enableGrayscale,
]
);
const handleMouseLeave = useCallback(() => {
for (const card of cardRefs.current.values()) {
if (card) {
card.style.transform = `perspective(${perspective}px) rotateY(0deg) translateZ(0px) scale(1)`;
card.style.filter = initialFilter;
card.style.zIndex = '1';
}
}
}, [perspective, initialFilter]);
return (
<div
ref={containerRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className={cn('flex flex-row py-12 items-center', className)}
style={{ perspective: `${perspective}px`, gap: `${gap}px` }}
>
{Children.map(children, (child, index) => (
<div
key={index}
ref={(el) => {
// This callback handles dynamic children by correctly adding/removing their refs from our Map.
if (el) {
cardRefs.current.set(index, el);
} else {
cardRefs.current.delete(index);
}
}}
style={{
transformOrigin: 'center',
filter: initialFilter,
transition: `all ${transitionDuration}ms ${transitionTimingFunction}`,
}}
>
{child}
</div>
))}
</div>
);
};
export default ProximityCards;
Usage
Wrap any set of elements with the ProximityCards
component to apply the effect.
import { ProximityCards } from '@/components/ui/proximity-cards';
// A simple card component for demonstration
const Card = ({ children }: { children: React.ReactNode }) => (
<div className='flex h-48 w-32 items-center justify-center rounded-lg border bg-card text-card-foreground shadow-sm'>
{children}
</div>
);
export default function ProximityCardsDemo() {
return (
<div className='flex flex-col items-center justify-center gap-16 p-4'>
{/* Default Usage */}
<ProximityCards>
<Card>Card 1</Card>
<Card>Card 2</Card>
<Card>Card 3</Card>
<Card>Card 4</Card>
</ProximityCards>
{/* Customized Usage with more intense effects */}
<ProximityCards
tiltIntensity={0.5}
maxLift={120}
maxScale={1.1}
gap={24}
enableGrayscale={false}
>
<Card>Custom 1</Card>
<Card>Custom 2</Card>
<Card>Custom 3</Card>
<Card>Custom 4</Card>
</ProximityCards>
</div>
);
}
Props
Prop | Type | Default | Description |
---|---|---|---|
children | ReactNode | Required | The animatable elements. Each direct child is treated as a separate card. |
className | string | undefined | Optional classes for custom styling of the container element. |
tiltIntensity | number | 0.2 | The intensity of the tilt effect on the Y-axis. |
easingPower | number | 2 | The power of the easing curve for the proximity effect, creating a non-linear falloff. |
proximity | number | 0.25 | The horizontal range of the proximity effect, as a factor of the container's width. |
maxLift | number | 90 | The maximum "lift" (translateZ) applied to a card when the mouse is directly over it. |
maxScale | number | 1.05 | The maximum scale applied to a card when the mouse is directly over it. |
gap | number | 16 | The gap in pixels between each card element. |
perspective | number | 1000 | The CSS perspective value, which affects the intensity of the 3D effect. |
transitionDuration | number | 300 | The duration of the CSS transitions in milliseconds. |
transitionTimingFunction | CSSProperties['transitionTimingFunction'] | 'ease-out' | The CSS transition timing function for the animation effects. |
enableGrayscale | boolean | true | Toggles the grayscale effect, where cards become fully colored on hover. |