Kanso UI LogoKanso UI
Components/interactive card
cards

Interactive Card

An all-in-one premium card component combining 3D perspective tilt, cursor spotlight, and edge conic mesh gradient border glows.

card3dspotlightborderglowmesh-gradientinteractive

Preview

Supreme Effect
All-in-One
Zen Interaction
Moves in 3D perspective, tracks cursor coordinates via spotlight, and glows along the conic mesh gradient edges.
Interactive Preview
Real-time Config Kit (Props)
Glow Intensity1
Spotlight Radius300px
Border Radius24px
Glow Radius40px

🖲Usage

example-usage.tsx
import { InteractiveCard, CardBody, CardItem } from "@/components/kanso/interactive-card"

export default function InteractiveCardDemo() {
  return (
    <InteractiveCard animated glowColor="280 80 70">
      <CardBody className="p-6">
        <CardItem translateZ={40} className="text-lg font-bold text-white">
          All-in-one Card
        </CardItem>
        <CardItem translateZ={20} className="text-zinc-400 mt-2">
          Features 3D tilt, spotlight, and border glows.
        </CardItem>
      </CardBody>
    </InteractiveCard>
  )
}

Installation

1

Create folder & copy source

Create a folder named kanso inside your project's components directory (i.e. components/kanso/). Copy the source code shown in the next section, and paste it into a file named interactive-card.tsx inside it.

2

Install dependencies

pnpm add framer-motion
3

Required helper files

Ensure your project has the following helper files configured:

  • lib/utils

<>Source Code

interactive-card.tsx
'use client';

import * as React from 'react';
import { motion, useMotionValue, useTransform, animate } from 'framer-motion';
import { cn } from '@/lib/utils';

const MouseEnterContext = React.createContext<
  [boolean, React.Dispatch<React.SetStateAction<boolean>>] | undefined
>(undefined);

// --- Helpers for HSL Color and Shadow generation ---
function parseHSL(hslStr: string): { h: number; s: number; l: number } {
  const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/);
  if (!match) return { h: 40, s: 80, l: 80 };
  return {
    h: parseFloat(match[1]),
    s: parseFloat(match[2]),
    l: parseFloat(match[3]),
  };
}

function buildBoxShadow(glowColor: string, intensity: number): string {
  const { h, s, l } = parseHSL(glowColor);
  const base = `${h}deg ${s}% ${l}%`;
  const layers: [number, number, number, number, number, boolean][] = [
    [0, 0, 0, 1, 100, true],
    [0, 0, 1, 0, 60, true],
    [0, 0, 3, 0, 50, true],
    [0, 0, 6, 0, 40, true],
    [0, 0, 15, 0, 30, true],
    [0, 0, 25, 2, 20, true],
    [0, 0, 50, 2, 10, true],
    [0, 0, 1, 0, 60, false],
    [0, 0, 3, 0, 50, false],
    [0, 0, 6, 0, 40, false],
    [0, 0, 15, 0, 30, false],
    [0, 0, 25, 2, 20, false],
    [0, 0, 50, 2, 10, false],
  ];
  return layers
    .map(([x, y, blur, spread, alpha, inset]) => {
      const a = Math.min(alpha * intensity, 100);
      return `${inset ? 'inset ' : ''}${x}px ${y}px ${blur}px ${spread}px hsl(${base} / ${a}%)`;
    })
    .join(', ');
}

const GRADIENT_POSITIONS = [
  '80% 55%',
  '69% 34%',
  '8% 6%',
  '41% 38%',
  '86% 85%',
  '82% 18%',
  '51% 4%',
];
const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1];

function buildMeshGradients(colors: string[]): string[] {
  const gradients: string[] = [];
  for (let i = 0; i < 7; i++) {
    const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)];
    gradients.push(
      `radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`
    );
  }
  gradients.push(`linear-gradient(${colors[0]} 0 100%)`);
  return gradients;
}

interface InteractiveCardProps extends React.HTMLAttributes<HTMLDivElement> {
  children?: React.ReactNode;
  containerClassName?: string;
  // Border Glow Props
  edgeSensitivity?: number;
  glowColor?: string;
  backgroundColor?: string;
  borderRadius?: number;
  glowRadius?: number;
  glowIntensity?: number;
  coneSpread?: number;
  animated?: boolean;
  colors?: string[];
  fillOpacity?: number;
  // Spotlight Props
  spotlightColor?: string;
  spotlightSize?: number;
}

/**
 * InteractiveCard — A premium card that combines 3D perspective tilting, cursor-following spotlights,
 * and edge-sensitive conic mesh gradient borders into a single, unified client wrapper.
 *
 * Fully optimized with hardware-accelerated style assignments and Framer Motion values to ensure
 * smooth 60fps tracking without triggering React component re-renders.
 */
function InteractiveCard({
  children,
  className,
  containerClassName,
  edgeSensitivity = 30,
  glowColor = '40 80 80',
  backgroundColor = '#120F17',
  borderRadius = 28,
  glowRadius = 40,
  glowIntensity = 1.0,
  coneSpread = 25,
  animated = false,
  colors = ['#c084fc', '#f472b6', '#38bdf8'],
  fillOpacity = 0.5,
  spotlightColor,
  spotlightSize = 300,
  ...props
}: InteractiveCardProps) {
  const cardRef = React.useRef<HTMLDivElement>(null);
  const [isHovered, setIsHovered] = React.useState(false);

  // Framer Motion values for tracking cursor and proximity smoothly
  const mouseAngle = useMotionValue(45);
  const edgeProximity = useMotionValue(0);

  // Map values to opacities using linear scaling equations
  const colorSensitivity = edgeSensitivity + 20;

  const borderOpacity = useTransform(edgeProximity, (v) => {
    const scale = v * 100;
    if (scale <= colorSensitivity) return 0;
    return Math.min(1, (scale - colorSensitivity) / (100 - colorSensitivity));
  });

  const glowOpacity = useTransform(edgeProximity, (v) => {
    const scale = v * 100;
    if (scale <= edgeSensitivity) return 0;
    return Math.min(1, (scale - edgeSensitivity) / (100 - edgeSensitivity));
  });

  const getCenterOfElement = React.useCallback((el: HTMLElement) => {
    const { width, height } = el.getBoundingClientRect();
    return [width / 2, height / 2];
  }, []);

  const getEdgeProximityValue = React.useCallback(
    (el: HTMLElement, x: number, y: number) => {
      const [cx, cy] = getCenterOfElement(el);
      const dx = x - cx;
      const dy = y - cy;
      let kx = Infinity;
      let ky = Infinity;
      if (dx !== 0) kx = cx / Math.abs(dx);
      if (dy !== 0) ky = cy / Math.abs(dy);
      return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1);
    },
    [getCenterOfElement]
  );

  const getCursorAngleValue = React.useCallback(
    (el: HTMLElement, x: number, y: number) => {
      const [cx, cy] = getCenterOfElement(el);
      const dx = x - cx;
      const dy = y - cy;
      if (dx === 0 && dy === 0) return 0;
      const radians = Math.atan2(dy, dx);
      let degrees = radians * (180 / Math.PI) + 90;
      if (degrees < 0) degrees += 360;
      return degrees;
    },
    [getCenterOfElement]
  );

  // Unified pointer handler for Spotlight, Border Glow, and 3D Tilt
  const handlePointerMove = React.useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      const card = cardRef.current;
      if (!card) return;
      const rect = card.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      // 1. Spotlight updates (update CSS variables directly on DOM node)
      card.style.setProperty('--mouse-x', `${x}px`);
      card.style.setProperty('--mouse-y', `${y}px`);

      // 2. Border Glow updates (update Motion Values)
      const proximity = getEdgeProximityValue(card, x, y);
      const angle = getCursorAngleValue(card, x, y);
      edgeProximity.set(proximity);
      mouseAngle.set(angle);

      // 3. 3D Tilt updates (disable transitions for zero-latency tracking)
      const tiltX = (e.clientX - rect.left - rect.width / 2) / 25;
      const tiltY = (e.clientY - rect.top - rect.height / 2) / 25;
      card.style.transition = 'none';
      card.style.transform = `rotateY(${tiltX}deg) rotateX(${-tiltY}deg)`;
    },
    [getEdgeProximityValue, getCursorAngleValue, edgeProximity, mouseAngle]
  );

  const handlePointerEnter = React.useCallback(() => {
    setIsHovered(true);
    const card = cardRef.current;
    if (card) {
      card.style.transition = 'transform 0.3s ease-out';
    }
    animate(edgeProximity, 1, { duration: 0.2, ease: 'easeOut' });
  }, [edgeProximity]);

  const handlePointerLeave = React.useCallback(() => {
    setIsHovered(false);
    const card = cardRef.current;
    if (card) {
      // Snaps back smoothly
      card.style.transition =
        'transform 0.5s cubic-bezier(0.03, 0.98, 0.52, 0.99)';
      card.style.transform = 'rotateY(0deg) rotateX(0deg)';
    }
    animate(edgeProximity, 0, { duration: 0.5, ease: 'easeInOut' });
  }, [edgeProximity]);

  // Mount animation sweep logic
  React.useEffect(() => {
    if (!animated) return;

    const angleStart = 110;
    const angleEnd = 465;

    mouseAngle.set(angleStart);
    edgeProximity.set(0);

    const proxAnim = animate(edgeProximity, 1, {
      duration: 0.6,
      ease: 'easeOut',
    });

    const angleAnim = animate(mouseAngle, angleEnd, {
      duration: 3.5,
      ease: [0.25, 0.1, 0.25, 1],
      delay: 0.1,
    });

    const endAnim = animate(edgeProximity, 0, {
      duration: 1.2,
      ease: 'easeIn',
      delay: 2.8,
    });

    return () => {
      proxAnim.stop();
      angleAnim.stop();
      endAnim.stop();
    };
  }, [animated, mouseAngle, edgeProximity]);

  const meshGradients = buildMeshGradients(colors);
  const borderBg = meshGradients.map((g) => `${g} border-box`);
  const fillBg = meshGradients.map((g) => `${g} padding-box`);

  // Dynamically map motion values to string CSS variables
  const angleDeg = useTransform(mouseAngle, (a) => `${a.toFixed(3)}deg`);

  return (
    <MouseEnterContext.Provider value={[isHovered, setIsHovered]}>
      <div
        className={cn(
          'py-12 flex items-center justify-center',
          containerClassName
        )}
        style={{ perspective: '1000px' }}
      >
        <div
          ref={cardRef}
          onPointerMove={handlePointerMove}
          onPointerEnter={handlePointerEnter}
          onPointerLeave={handlePointerLeave}
          className={cn(
            'relative grid isolate border border-white/10 overflow-hidden',
            className
          )}
          style={
            {
              transformStyle: 'preserve-3d',
              background: backgroundColor,
              borderRadius: `${borderRadius}px`,
              transform: 'translate3d(0, 0, 0.01px)',
              boxShadow:
                'rgba(0,0,0,0.15) 0 1px 2px, rgba(0,0,0,0.15) 0 2px 4px, rgba(0,0,0,0.15) 0 4px 8px, rgba(0,0,0,0.15) 0 8px 16px, rgba(0,0,0,0.15) 0 16px 32px',
              '--spotlight-size': `${spotlightSize}px`,
              ...(spotlightColor
                ? { '--spotlight-color': spotlightColor }
                : {}),
            } as React.CSSProperties
          }
          {...props}
        >
          {/* --- LAYER: Spotlight --- */}
          <div className="absolute inset-0 z-0 overflow-hidden rounded-[inherit] pointer-events-none">
            <div
              className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100 [--kanso-spotlight:rgba(255,255,255,0.06)]"
              style={{
                background: `radial-gradient(var(--spotlight-size) circle at var(--mouse-x, 50%) var(--mouse-y, 50%), var(--spotlight-color, var(--kanso-spotlight)), transparent 80%)`,
              }}
            />
          </div>

          {/* --- LAYER: Border Glow Mesh Gradient --- */}
          <motion.div
            className="absolute inset-0 rounded-[inherit] pointer-events-none -z-[1]"
            style={{
              border: '1px solid transparent',
              background: [
                `linear-gradient(${backgroundColor} 0 100%) padding-box`,
                'linear-gradient(rgb(255 255 255 / 0%) 0% 100%) border-box',
                ...borderBg,
              ].join(', '),
              opacity: borderOpacity,
              maskImage: useTransform(
                angleDeg,
                (angle) =>
                  `conic-gradient(from ${angle} at center, black ${coneSpread}%, transparent ${coneSpread + 15}%, transparent ${100 - coneSpread - 15}%, black ${100 - coneSpread}%)`
              ),
              WebkitMaskImage: useTransform(
                angleDeg,
                (angle) =>
                  `conic-gradient(from ${angle} at center, black ${coneSpread}%, transparent ${coneSpread + 15}%, transparent ${100 - coneSpread - 15}%, black ${100 - coneSpread}%)`
              ),
            }}
          />

          {/* --- LAYER: Border Glow Fill Near Edges --- */}
          <motion.div
            className="absolute inset-0 rounded-[inherit] pointer-events-none -z-[1]"
            style={
              {
                border: '1px solid transparent',
                background: fillBg.join(', '),
                maskImage: useTransform(angleDeg, (angle) =>
                  [
                    'linear-gradient(to bottom, black, black)',
                    'radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%)',
                    'radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%)',
                    'radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%)',
                    'radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%)',
                    'radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%)',
                    `conic-gradient(from ${angle} at center, transparent 5%, black 15%, black 85%, transparent 95%)`,
                  ].join(', ')
                ),
                WebkitMaskImage: useTransform(angleDeg, (angle) =>
                  [
                    'linear-gradient(to bottom, black, black)',
                    'radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%)',
                    'radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%)',
                    'radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%)',
                    'radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%)',
                    'radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%)',
                    `conic-gradient(from ${angle} at center, transparent 5%, black 15%, black 85%, transparent 95%)`,
                  ].join(', ')
                ),
                maskComposite: 'subtract, add, add, add, add, add',
                WebkitMaskComposite:
                  'source-out, source-over, source-over, source-over, source-over, source-over',
                opacity: useTransform(borderOpacity, (v) => v * fillOpacity),
                mixBlendMode: 'soft-light',
              } as unknown as React.CSSProperties
            }
          />

          {/* --- LAYER: Outer Glow --- */}
          <motion.span
            className="absolute pointer-events-none z-[1] rounded-[inherit]"
            style={
              {
                inset: `${-glowRadius}px`,
                maskImage: useTransform(
                  angleDeg,
                  (angle) =>
                    `conic-gradient(from ${angle} at center, black 2.5%, transparent 10%, transparent 90%, black 97.5%)`
                ),
                WebkitMaskImage: useTransform(
                  angleDeg,
                  (angle) =>
                    `conic-gradient(from ${angle} at center, black 2.5%, transparent 10%, transparent 90%, black 97.5%)`
                ),
                opacity: glowOpacity,
                mixBlendMode: 'plus-lighter',
              } as unknown as React.CSSProperties
            }
          >
            <span
              className="absolute rounded-[inherit]"
              style={{
                inset: `${glowRadius}px`,
                boxShadow: buildBoxShadow(glowColor, glowIntensity),
              }}
            />
          </motion.span>

          {/* --- CONTENT --- */}
          <div className="flex flex-col relative z-10 w-full h-full">
            {children}
          </div>
        </div>
      </div>
    </MouseEnterContext.Provider>
  );
}

interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {
  children: React.ReactNode;
}

function CardBody({ children, className, ...props }: CardBodyProps) {
  return (
    <div
      className={cn(
        'h-96 w-96 [transform-style:preserve-3d] [&>*]:[transform-style:preserve-3d]',
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
}

type CardItemProps<T extends React.ElementType = 'div'> = {
  as?: T;
  children?: React.ReactNode;
  translateX?: number | string;
  translateY?: number | string;
  translateZ?: number | string;
  rotateX?: number | string;
  rotateY?: number | string;
  rotateZ?: number | string;
} & Omit<
  React.ComponentPropsWithoutRef<T>,
  | 'as'
  | 'children'
  | 'translateX'
  | 'translateY'
  | 'translateZ'
  | 'rotateX'
  | 'rotateY'
  | 'rotateZ'
>;

function formatTranslate(val: number | string): string {
  if (typeof val === 'number') return `${val}px`;
  return val;
}

function formatRotate(val: number | string): string {
  if (typeof val === 'number') return `${val}deg`;
  return val;
}

function CardItem<T extends React.ElementType = 'div'>({
  as,
  children,
  className,
  translateX = 0,
  translateY = 0,
  translateZ = 0,
  rotateX = 0,
  rotateY = 0,
  rotateZ = 0,
  ...props
}: CardItemProps<T>) {
  const ref = React.useRef<HTMLElement>(null);
  const [isMouseEntered] = useMouseEnter();

  React.useEffect(() => {
    if (!ref.current) return;
    if (isMouseEntered) {
      ref.current.style.transform = `translateX(${formatTranslate(translateX)}) translateY(${formatTranslate(translateY)}) translateZ(${formatTranslate(translateZ)}) rotateX(${formatRotate(rotateX)}) rotateY(${formatRotate(rotateY)}) rotateZ(${formatRotate(rotateZ)})`;
    } else {
      ref.current.style.transform =
        'translateX(0px) translateY(0px) translateZ(0px) rotateX(0deg) rotateY(0deg) rotateZ(0deg)';
    }
  }, [
    isMouseEntered,
    translateX,
    translateY,
    translateZ,
    rotateX,
    rotateY,
    rotateZ,
  ]);

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const Tag = (as || 'div') as any;

  return (
    <Tag
      ref={ref as unknown as React.Ref<HTMLDivElement>}
      className={cn(
        'w-fit transition-transform duration-300 ease-out',
        className
      )}
      {...props}
    >
      {children}
    </Tag>
  );
}

function useMouseEnter() {
  const context = React.useContext(MouseEnterContext);
  if (context === undefined) {
    return [false, () => {}] as [
      boolean,
      React.Dispatch<React.SetStateAction<boolean>>,
    ];
  }
  return context;
}

export { InteractiveCard, CardBody, CardItem, useMouseEnter };
export type { InteractiveCardProps, CardBodyProps, CardItemProps };

Props

Command Palette

Search for a command to run...