Components
Gliding Card
Orchestrates the shared state between list items and a floating card to create smooth gliding transitions.
Installation
CLI (Recommended)
npx shadcn@latest add https://satisui.xyz/r/gliding-card.jsonManual
- Install the required dependencies:
npm install motion- 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.
| Prop | Type | Default | Description |
|---|---|---|---|
target | React.ReactNode | Required | The 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. |
rotation | number | 0 | Rotation in degrees (Z-axis) applied to the card when this item is active. |
as | React.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.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes for the card container. |