Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 44 additions & 13 deletions src/components/card.module.css
Original file line number Diff line number Diff line change
@@ -1,21 +1,53 @@
.card {
display: inline-flex;
position: relative;
border: 1px solid var(--color-stone-400);
border-radius: var(--rounding-100);
overflow: hidden;
height: var(--card-height);
width: var(--card-width);
perspective: 1000px;
transform-style: preserve-3d;
transform-origin: center center;
background: linear-gradient(
to bottom,
var(--color-stone-800),
var(--color-stone-500),
var(--color-stone-900)
);
color: var(--color-stone-100);
transition-duration: var(--anim-dur-300);
transition-timing-function: var(--anim-easing-100);
transition-property: transform, box-shadow;
transition:
transform 0.1s ease-out,
box-shadow 0.2s ease-out;
--sheen-x: 50%;
--sheen-y: 50%;
}

.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(
circle at var(--sheen-x) var(--sheen-y),
rgba(255, 255, 255, 0.25) 0%,
rgba(255, 255, 255, 0.1) 30%,
transparent 60%
);
opacity: 0;
pointer-events: none;
z-index: 10;
transition: opacity 0.3s ease-out;
border-radius: var(--rounding-100);
}

.card.face-up.idle:hover {
will-change: transform;
}

.card.face-up.idle:hover::before {
opacity: 1;
}

.card-front {
Expand All @@ -24,18 +56,17 @@
outline-offset: -2px;
}

.card.face-up.idle:hover {
transform: translateY(calc(-1 * var(--spacing-100)));
}

.card.face-up {
box-shadow: 0 var(--spacing-100) var(--spacing-300) calc(var(--spacing-200) * -1)
var(--color-stone-700);
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.2),
0 1px 2px rgba(0, 0, 0, 0.1);
}

.card.face-up:hover {
box-shadow: 0 var(--spacing-200) var(--spacing-400) calc(var(--spacing-100) * -1)
var(--color-stone-700);
.card.face-up.idle:hover {
box-shadow:
0 12px 24px -4px rgba(0, 0, 0, 0.3),
0 6px 12px -2px rgba(0, 0, 0, 0.2),
0 3px 6px -1px rgba(0, 0, 0, 0.15);
}

.card.in-play {
Expand Down
51 changes: 50 additions & 1 deletion src/components/card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { clsx } from 'clsx'
import { useState, type MouseEvent } from 'react'
import type { Card } from '../types/cards'
import cardBackImg from '../images/card-back.png'
import swordIcon from '../images/sword.png'
Expand All @@ -18,6 +19,9 @@ export function Card({
artwork,
id,
}: { isStacked?: boolean; onClick?: () => void; className?: string } & Card) {
const [tilt, setTilt] = useState({ rotateX: 0, rotateY: 0 })
const [isHovering, setIsHovering] = useState(false)

const withClsx = (rootClass: string, additionalClassName?: string) => {
return clsx(
{
Expand All @@ -37,8 +41,53 @@ export function Card({
)
}

const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
if (orientation !== 'face-up' || status !== 'idle') return

if (!isHovering) setIsHovering(true)

const card = e.currentTarget
const rect = card.getBoundingClientRect()

// Calculate cursor position relative to card center (-0.5 to 0.5)
const x = (e.clientX - rect.left) / rect.width - 0.5
const y = (e.clientY - rect.top) / rect.height - 0.5

// Convert to rotation degrees (max ±8 degrees for subtle effect)
const maxTilt = 8
const rotateY = x * maxTilt * 2 // Multiply by 2 since x ranges from -0.5 to 0.5
const rotateX = -y * maxTilt * 2 // Negative for natural tilt direction

setTilt({ rotateX, rotateY })
}

const handleMouseLeave = () => {
setTilt({ rotateX: 0, rotateY: 0 })
setIsHovering(false)
}

const isHoverable = orientation === 'face-up' && status === 'idle'
const translateY = isHoverable && isHovering ? 'calc(-1 * var(--spacing-100))' : '0px'

// Calculate sheen position (0 to 100%)
const sheenX = isHoverable && isHovering ? ((tilt.rotateY / 16 + 0.5) * 100).toFixed(1) : '50'
const sheenY = isHoverable && isHovering ? ((-tilt.rotateX / 16 + 0.5) * 100).toFixed(1) : '50'

return (
<div className={withClsx(css['card'], className)} onClick={onClick} id={id}>
<div
className={withClsx(css['card'], className)}
onClick={onClick}
id={id}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={
{
transform: `perspective(1000px) rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg) translateY(${translateY})`,
'--sheen-x': `${sheenX}%`,
'--sheen-y': `${sheenY}%`,
} as React.CSSProperties
}
>
<div className={withClsx(css['card-front'])}>
<div className={withClsx(css['card-header'])}>
<div className={withClsx(css['card-name'])}>{name}</div>
Expand Down