Card stamp arc
Interactive polaroid stamp arc with dynamic fan-out physics on hover, staggered rotation offsets, smooth spring transitions, and sound feedback.
Architecture
Mountains
Ocean view
Creative studio
Portrait
Astro & react CSS transforms Micro-interactions
---
export interface PhotoStamp {
id: number;
src?: string;
alt?: string;
caption?: string;
}
export interface Props {
arc?: number;
spread?: number;
yOffset?: number;
duration?: number;
className?: string;
photos?: PhotoStamp[];
}
const {
arc = 16,
spread = 180,
yOffset = 20,
duration = 0.4,
className = '',
photos
} = Astro.props;
const defaultStamps: PhotoStamp[] = [
{ id: 0, caption: 'Architecture', src: 'https://images.unsplash.com/photo-1513694203232-719a280e022f?w=600&auto=format&fit=crop' },
{ id: 1, caption: 'Mountains', src: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=600&auto=format&fit=crop' },
{ id: 2, caption: 'Ocean view', src: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=600&auto=format&fit=crop' },
{ id: 3, caption: 'Creative studio', src: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=600&auto=format&fit=crop' },
{ id: 4, caption: 'Portrait', src: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&auto=format&fit=crop' }
];
const stampsList = photos && photos.length > 0 ? photos : defaultStamps;
const count = stampsList.length;
const centerIdx = (count - 1) / 2;
const stampConfigs = stampsList.map((stamp, i) => {
const norm = centerIdx > 0 ? (i - centerIdx) / centerIdx : 0;
const idleRotate = norm * 6;
const idleX = norm * 36;
const idleY = Math.abs(norm) * 4;
const targetRotate = norm * arc;
const targetX = norm * spread;
const targetY = Math.abs(norm) * yOffset;
const mobileSpread = 90;
const mobileRotate = norm * 10;
const mobileX = norm * mobileSpread;
const mobileY = Math.abs(norm) * 4;
const dist = Math.abs(i - centerIdx);
return {
...stamp,
norm,
idleRotate,
idleX,
idleY,
targetRotate,
targetX,
targetY,
mobileRotate,
mobileX,
mobileY,
defaultZIndex: Math.round(10 - dist)
};
});
---
<div
class={`card-stamp-arc-root relative w-[6.4rem] h-[8.6rem] sm:w-[9.2rem] sm:h-[11.8rem] cursor-pointer flex items-center justify-center select-none touch-manipulation max-w-full overflow-visible ${className}`}
data-count={count}
>
{stampConfigs.map((stamp, i) => (
<div
class="stamp-card-item absolute inset-0 rounded-lg sm:rounded-xl p-1 pb-1.5 sm:p-1.5 sm:pb-2.5 flex flex-col bg-white text-zinc-800 border border-zinc-200/60 shadow-[0_4px_18px_rgba(0,0,0,0.06)] dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-800/60"
style={{
zIndex: stamp.defaultZIndex,
transformOrigin: '50% 100%',
transitionDuration: `${duration}s`,
'--idle-tr': `${stamp.idleRotate}deg`,
'--idle-tx': `${stamp.idleX}px`,
'--idle-ty': `${stamp.idleY}px`,
'--desktop-tr': `${stamp.targetRotate}deg`,
'--desktop-tx': `${stamp.targetX}px`,
'--desktop-ty': `${stamp.targetY}px`,
'--mobile-tr': `${stamp.mobileRotate}deg`,
'--mobile-tx': `${stamp.mobileX}px`,
'--mobile-ty': `${stamp.mobileY}px`
}}
>
{stamp.caption && (
<div class="stamp-tooltip absolute left-1/2 -top-7 sm:-top-9 pointer-events-none z-50 flex justify-center items-center w-max">
<span class="block whitespace-nowrap rounded-lg border border-zinc-200/80 bg-white/95 px-2.5 py-1 text-[10px] sm:text-[11px] font-mono tracking-tight text-zinc-900 shadow-md dark:border-zinc-800 dark:bg-zinc-900/95 dark:text-zinc-100 backdrop-blur-md">
{stamp.caption}
</span>
</div>
)}
<div class="relative w-full h-full rounded bg-zinc-100/90 dark:bg-zinc-800/60 overflow-hidden border border-zinc-200/30 dark:border-zinc-700/30 flex flex-col items-center justify-center pointer-events-none">
{stamp.src ? (
<img
src={stamp.src}
alt={stamp.caption || 'Photo'}
class="w-full h-full object-cover transition-opacity duration-300"
loading="lazy"
/>
) : (
<div class="w-full h-full flex flex-col items-center justify-center p-2 text-center">
<span class="text-[9px] font-mono font-medium text-zinc-400">0{stamp.id + 1}</span>
</div>
)}
</div>
</div>
))}
</div>
<style>
.card-stamp-arc-root { perspective: 1000px; }
.stamp-card-item {
backface-visibility: hidden;
transform-style: preserve-3d;
will-change: transform;
transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.3s ease;
}
@media (min-width: 640px) {
.stamp-card-item { transform: translate3d(var(--idle-tx), var(--idle-ty), 0) rotate(var(--idle-tr)); }
.card-stamp-arc-root:hover .stamp-card-item, .card-stamp-arc-root.has-active-focus .stamp-card-item {
transform: translate3d(var(--desktop-tx), var(--desktop-ty), 0) rotate(var(--desktop-tr));
}
.card-stamp-arc-root:hover .stamp-card-item:hover, .stamp-card-item.is-focused-card {
transform: translate3d(var(--desktop-tx), calc(var(--desktop-ty) - 14px), 30px) rotate(0deg) scale(1.05) !important;
}
}
@media (max-width: 639px) {
.stamp-card-item { transform: translate3d(var(--mobile-tx), var(--mobile-ty), 0) rotate(var(--mobile-tr)); }
.stamp-card-item.is-focused-card {
transform: translate3d(var(--mobile-tx), calc(var(--mobile-ty) - 12px), 30px) rotate(0deg) scale(1.06) !important;
}
}
.stamp-tooltip {
opacity: 0;
filter: blur(4px);
transform: translate(-50%, 6px) scale(0.92);
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
.card-stamp-arc-root:hover .stamp-card-item:hover .stamp-tooltip,
.stamp-card-item.is-focused-card .stamp-tooltip {
opacity: 1; filter: blur(0px); transform: translate(-50%, 0px) scale(1);
}
</style>
<script>
const setupStampArc = () => {
document.querySelectorAll('.card-stamp-arc-root').forEach((root) => {
const items = root.querySelectorAll('.stamp-card-item');
let lockedIdx: number | null = null;
const setFocus = (targetIdx: number | null) => {
items.forEach((item, i) => {
const card = item as HTMLElement;
if (i === targetIdx) card.classList.add('is-focused-card');
else card.classList.remove('is-focused-card');
});
};
items.forEach((item, i) => {
item.addEventListener('click', (e) => {
e.stopPropagation();
lockedIdx = lockedIdx === i ? null : i;
setFocus(lockedIdx);
});
item.addEventListener('mouseenter', () => setFocus(i));
});
root.addEventListener('mouseleave', () => setFocus(lockedIdx));
});
};
setupStampArc();
document.addEventListener('astro:after-swap', setupStampArc);
</script>