Cards

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.

Futuristic Tokyo cityscape
Shanghai skyline at night
A city with cyberpunk aesthetics
A city in the style of Blade Runner
Singapore gardens by the bay
New York City aerial view

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.

A majestic mountain range
A lush green forest
A powerful ocean wave
Vast desert sand dunes
The northern lights
An Icelandic waterfall

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.

Abstract colorful painting
A modern marble sculpture
A classical painted portrait
Vibrant street art graffiti
An impressionist landscape
A surrealist digital art piece
A minimalist architectural photo
Art in the bauhaus style

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

PropTypeDefaultDescription
childrenReactNodeRequiredThe animatable elements. Each direct child is treated as a separate card.
classNamestringundefinedOptional classes for custom styling of the container element.
tiltIntensitynumber0.2The intensity of the tilt effect on the Y-axis.
easingPowernumber2The power of the easing curve for the proximity effect, creating a non-linear falloff.
proximitynumber0.25The horizontal range of the proximity effect, as a factor of the container's width.
maxLiftnumber90The maximum "lift" (translateZ) applied to a card when the mouse is directly over it.
maxScalenumber1.05The maximum scale applied to a card when the mouse is directly over it.
gapnumber16The gap in pixels between each card element.
perspectivenumber1000The CSS perspective value, which affects the intensity of the 3D effect.
transitionDurationnumber300The duration of the CSS transitions in milliseconds.
transitionTimingFunctionCSSProperties['transitionTimingFunction']'ease-out'The CSS transition timing function for the animation effects.
enableGrayscalebooleantrueToggles the grayscale effect, where cards become fully colored on hover.