Components

Gliding Card

Orchestrates the shared state between list items and a floating card to create smooth gliding transitions.

Travel Log / 2025

A collection of moments captured on instant film. Hover to recall memory.

Installation

npx shadcn@latest add https://satisui.xyz/r/gliding-card.json

Manual

  1. Install the required dependencies:
npm install motion
  1. Copy and paste the following code into components/satisui/gliding-card.tsx:
'use client';

import React, {
  createContext,
  useContext,
  useRef,
  useState,
  useCallback,
} from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils';

interface CardConfig {
  offset?: { x: number; y: number };
  rotation?: number;
}

interface GlidingCardContextType {
  activeId: string | null;
  activeContent: React.ReactNode | null;
  activeRect: DOMRect | null;
  activeConfig: CardConfig;
  registerActivation: (
    id: string,
    rect: DOMRect,
    content: React.ReactNode,
    config: CardConfig
  ) => void;
  registerDeactivation: () => void;
}

const GlidingCardContext = createContext<GlidingCardContextType | undefined>(
  undefined
);

/**
 * Orchestrates the shared state between list items (triggers) and the floating card (content).
 * Handles the logic for "gliding" transitions and hover grace periods.
 */
export function GlidingCard({ children }: { children: React.ReactNode }) {
  const [activeId, setActiveId] = useState<string | null>(null);
  const [activeContent, setActiveContent] = useState<React.ReactNode | null>(
    null
  );
  const [activeRect, setActiveRect] = useState<DOMRect | null>(null);
  const [activeConfig, setActiveConfig] = useState<CardConfig>({
    rotation: 0,
    offset: { x: 0, y: 0 },
  });

  const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const registerActivation = useCallback(
    (
      id: string,
      rect: DOMRect,
      content: React.ReactNode,
      config: CardConfig
    ) => {
      // Cancel pending deactivation to allow "bridging" the gap between adjacent items.
      if (leaveTimer.current) clearTimeout(leaveTimer.current);

      setActiveId(id);
      setActiveContent(content);
      setActiveRect(rect);
      setActiveConfig(config);
    },
    []
  );

  const registerDeactivation = useCallback(() => {
    // Grace period (50ms) prevents flickering if the cursor briefly leaves the hit area
    // while transitioning between items.
    leaveTimer.current = setTimeout(() => {
      setActiveId(null);
    }, 50);
  }, []);

  return (
    <GlidingCardContext.Provider
      value={{
        activeId,
        activeContent,
        activeRect,
        activeConfig,
        registerActivation,
        registerDeactivation,
      }}
    >
      {children}
    </GlidingCardContext.Provider>
  );
}

interface GlidingCardItemProps extends React.HTMLAttributes<HTMLElement> {
  /** The content to be rendered inside the floating card when this item is active. */
  target: React.ReactNode;
  /**
   * Positional offset for the card relative to this specific item.
   * Useful for adjustments per item (e.g., pushing the card further right).
   */
  offset?: { x?: number; y?: number };
  /** Rotation in degrees (Z-axis) applied to the card when this item is active. */
  rotation?: number;
  /**
   * Polymorphic prop to render the item as a specific HTML tag or Component.
   * @default 'div'
   */
  as?: React.ElementType;
}

/**
 * The interactive trigger element. Captures viewport coordinates on interaction
 * and updates the context to position the floating card.
 */
export function GlidingCardItem({
  children,
  className,
  target,
  offset = { x: 0, y: 0 },
  rotation = 0,
  as,
  ...props
}: GlidingCardItemProps) {
  const context = useContext(GlidingCardContext);
  if (!context)
    throw new Error('GlidingCardItem must be used within GlidingCard');

  const id = React.useId();
  const cardId = `gliding-card-${id}`;

  // Explicitly cast to ElementType to satisfy TypeScript checks for non-void elements,
  // ensuring the component can accept children.
  const Tag = (as || 'div') as React.ElementType<
    React.HTMLAttributes<HTMLElement>
  >;

  const handleActivate = (e: React.SyntheticEvent<HTMLElement>) => {
    // Capture live Viewport coordinates to support items in scrolling containers
    const rect = e.currentTarget.getBoundingClientRect();
    context.registerActivation(id, rect, target, {
      rotation,
      offset: { x: offset.x ?? 0, y: offset.y ?? 0 },
    });
  };

  const handleDeactivate = () => {
    context.registerDeactivation();
  };

  return (
    <Tag
      id={id}
      role='button'
      tabIndex={0}
      aria-describedby={context.activeId === id ? cardId : undefined}
      aria-expanded={context.activeId === id}
      // Spread props first so internal handlers take precedence while still calling user-provided handlers
      {...props}
      onMouseEnter={(e: React.MouseEvent<HTMLElement>) => {
        handleActivate(e);
        props.onMouseEnter?.(e);
      }}
      onMouseLeave={(e: React.MouseEvent<HTMLElement>) => {
        handleDeactivate();
        props.onMouseLeave?.(e);
      }}
      onFocus={(e: React.FocusEvent<HTMLElement>) => {
        handleActivate(e);
        props.onFocus?.(e);
      }}
      onBlur={(e: React.FocusEvent<HTMLElement>) => {
        handleDeactivate();
        props.onBlur?.(e);
      }}
      className={cn(
        'cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ',
        className
      )}
    >
      {children}
    </Tag>
  );
}

interface GlidingCardContentProps {
  className?: string;
}

/**
 * The visual container for the floating card.
 * It calculates positioning relative to the viewport, decoupling it from the DOM hierarchy.
 */
export function GlidingCardContent({ className }: GlidingCardContentProps) {
  const context = useContext(GlidingCardContext);
  if (!context)
    throw new Error('GlidingCardContent must be used within GlidingCard');

  const containerRef = useRef<HTMLDivElement>(null);
  const { activeId, activeContent, activeRect, activeConfig } = context;

  const getRelativePosition = () => {
    if (!activeRect || !containerRef.current) return { top: 0 };

    // DECISION: Calculate delta between the Container's rect and the Item's rect.
    // This supports complex layouts (e.g., Grid) where Item and Content are in different DOM sub-trees.
    const containerRect = containerRef.current.getBoundingClientRect();
    const topOfItem = activeRect.top - containerRect.top;
    const centerOfItem = topOfItem + activeRect.height / 2;

    return { top: centerOfItem + (activeConfig.offset?.y || 0) };
  };

  const pos = getRelativePosition();
  const currentCardId = activeId ? `gliding-card-${activeId}` : undefined;

  return (
    <div
      ref={containerRef}
      // pointer-events-none allows clicking through the empty space around the card,
      // while the inner motion.div re-enables pointer events for the card content itself.
      className='relative w-full h-full pointer-events-none'
    >
      <AnimatePresence>
        {activeId && activeRect && (
          <motion.div
            id={currentCardId}
            role='tooltip'
            className={cn(
              'absolute left-0 z-50 pointer-events-auto',
              className
            )}
            style={{ transformOrigin: 'center left' }}
            initial={{
              opacity: 0,
              scale: 0.9,
              x: (activeConfig.offset?.x || 0) - 20,
              top: pos.top,
              y: '-50%', // Anchor center of card to center of item
            }}
            animate={{
              opacity: 1,
              scale: 1,
              x: activeConfig.offset?.x || 0,
              top: pos.top,
              y: '-50%',
              rotateZ: activeConfig.rotation || 0,
            }}
            exit={{
              opacity: 0,
              scale: 0.9,
              x: (activeConfig.offset?.x || 0) - 20,
              rotateZ: activeConfig.rotation || 0,
              y: '-50%',
            }}
            transition={{ type: 'spring', stiffness: 350, damping: 25 }}
          >
            {activeContent}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Usage

Wrap your list items in GlidingCardItem and place the GlidingCardContent component where you want the floating card to appear. The components use a shared context, allowing for flexible layouts.

import {
  GlidingCard,
  GlidingCardItem,
  GlidingCardContent,
} from '@/components/ui/gliding-card';

export default function Demo() {
  return (
    <div className='flex min-h-[400px] items-center justify-center gap-12 p-8'>
      <GlidingCard>
        {/* Left Column: The Trigger List */}
        <div className='flex flex-col gap-2'>
          <GlidingCardItem
            className='p-4 rounded-lg hover:bg-muted cursor-pointer'
            target={
              <div className='w-48 h-32 bg-blue-500 rounded-xl p-4 text-white shadow-xl'>
                <p className='font-bold'>Project Alpha</p>
                <p className='text-sm opacity-80'>Status: Active</p>
              </div>
            }
          >
            <p className='font-medium'>Project Alpha</p>
          </GlidingCardItem>

          <GlidingCardItem
            className='p-4 rounded-lg hover:bg-muted cursor-pointer'
            // Example: Using offset and rotation
            offset={{ x: 20, y: 0 }}
            rotation={5}
            target={
              <div className='w-48 h-32 bg-purple-500 rounded-xl p-4 text-white shadow-xl'>
                <p className='font-bold'>Project Beta</p>
                <p className='text-sm opacity-80'>Status: Review</p>
              </div>
            }
          >
            <p className='font-medium'>Project Beta</p>
          </GlidingCardItem>
        </div>

        {/* Right Column: The Card Display Area */}
        <div className='relative w-48'>
          <GlidingCardContent />
        </div>
      </GlidingCard>
    </div>
  );
}

Props

GlidingCardItem

The trigger component that activates the floating card.

PropTypeDefaultDescription
targetReact.ReactNodeRequiredThe content to be rendered inside the floating card when this item is active.
offset{ x?: number; y?: number }{ x: 0, y: 0 }Positional offset for the card relative to this specific item.
rotationnumber0Rotation in degrees (Z-axis) applied to the card when this item is active.
asReact.ElementType'div'Polymorphic prop to render the item as a specific HTML tag or Component (e.g., 'li', 'button').

GlidingCardContent

The container for the floating card animation.

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the card container.