Member Card
The 3D animated membership card is a signature visual element in the customer-app. It displays the member's tier, points balance, and membership details with a premium, tactile feel.
Anatomy
+-------------------------------------------+
| [Chip] [Contactless] |
| |
| RETAILOS MEMBER |
| |
| RINA SARI |
| ID: 8847 2290 3341 |
| |
| GOLD MEMBER 12.450 poin |
+-------------------------------------------+Tier Gradients
| Tier | Gradient | Text Color |
|---|---|---|
| Silver | linear-gradient(135deg, #C0C0C0, #E8E8E8, #A8A8A8) | #4A4A4A |
| Gold | linear-gradient(135deg, #D4A843, #F2D477, #C49A2A) | #5C3D0E |
| Platinum | linear-gradient(135deg, #2D2D2D, #4A4A4A, #1A1A1A) | #E0E0E0 |
| Employee | linear-gradient(135deg, #2D6B4A, #4A9E72, #1E5038) | #FFFFFF |
Visual Elements
| Element | Style |
|---|---|
| Card size | 340x200px (mobile), aspect-ratio 1.7:1 |
| Border radius | 16px |
| Chip | 36x28px gold rectangle with etched lines, top-left |
| Contactless symbol | 3 curved arcs, 20px, top-right |
| Brand text | 10px, uppercase, letter-spacing 0.15em |
| Member name | 16px, font-semibold, uppercase |
| Member ID | font-mono, 13px, spaced groups of 4 digits |
| Tier label | 12px, uppercase, letter-spacing 0.1em |
| Points | font-mono, 16px, font-bold |
3D Tilt Effect
The card tilts in response to device motion (mobile) or mouse position (web):
- Maximum tilt: 15 degrees on each axis
- Perspective: 1000px
- Transition:
transform 100ms ease-out - Glare overlay: white gradient that shifts with tilt angle, opacity 0.15
- Shadow: dynamic, shifts opposite to tilt direction
Implementation
tsx
import { useRef, useState } from 'react'
interface TiltState {
rotateX: number
rotateY: number
glareX: number
glareY: number
}
export function MemberCard({ member }: { member: MemberData }) {
const cardRef = useRef<HTMLDivElement>(null)
const [tilt, setTilt] = useState<TiltState>({ rotateX: 0, rotateY: 0, glareX: 50, glareY: 50 })
const handleMouseMove = (e: React.MouseEvent) => {
const rect = cardRef.current?.getBoundingClientRect()
if (!rect) return
const x = (e.clientX - rect.left) / rect.width
const y = (e.clientY - rect.top) / rect.height
setTilt({
rotateX: (0.5 - y) * 30, // max 15deg
rotateY: (x - 0.5) * 30, // max 15deg
glareX: x * 100,
glareY: y * 100,
})
}
const handleMouseLeave = () => {
setTilt({ rotateX: 0, rotateY: 0, glareX: 50, glareY: 50 })
}
const tierGradients = {
silver: 'linear-gradient(135deg, #C0C0C0, #E8E8E8, #A8A8A8)',
gold: 'linear-gradient(135deg, #D4A843, #F2D477, #C49A2A)',
platinum: 'linear-gradient(135deg, #2D2D2D, #4A4A4A, #1A1A1A)',
employee: 'linear-gradient(135deg, #2D6B4A, #4A9E72, #1E5038)',
}
const tierTextColors = {
silver: '#4A4A4A', gold: '#5C3D0E', platinum: '#E0E0E0', employee: '#FFFFFF',
}
return (
<div style={{ perspective: '1000px' }} className="flex justify-center">
<div
ref={cardRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="relative w-[340px] aspect-[1.7/1] rounded-2xl overflow-hidden cursor-pointer select-none"
style={{
background: tierGradients[member.tier],
transform: `rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg)`,
transition: 'transform 100ms ease-out',
boxShadow: `${tilt.rotateY * 0.5}px ${-tilt.rotateX * 0.5}px 20px rgba(0,0,0,0.25)`,
}}
>
{/* Glare overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: `radial-gradient(circle at ${tilt.glareX}% ${tilt.glareY}%, rgba(255,255,255,0.25), transparent 60%)`,
}}
/>
{/* Content */}
<div className="relative z-10 p-5 h-full flex flex-col justify-between"
style={{ color: tierTextColors[member.tier] }}
>
<div className="flex justify-between items-start">
{/* Chip */}
<div className="w-9 h-7 rounded-[4px] bg-gradient-to-br from-yellow-400 to-yellow-600 border border-yellow-700/30" />
{/* Contactless */}
<ContactlessIcon className="w-5 h-5 opacity-60" />
</div>
<div>
<p className="text-[10px] uppercase tracking-[0.15em] opacity-70 mb-3">RetailOS Member</p>
<p className="text-base font-semibold uppercase tracking-wide">{member.name}</p>
<p className="font-mono text-[13px] opacity-80 mt-0.5">
{member.id.match(/.{4}/g)?.join(' ')}
</p>
</div>
<div className="flex justify-between items-end">
<p className="text-xs uppercase tracking-[0.1em] opacity-80">
{member.tier} Member
</p>
<p className="font-mono text-base font-bold">
{member.points.toLocaleString('id-ID')} poin
</p>
</div>
</div>
</div>
</div>
)
}Mobile (React Native) Considerations
On mobile, use react-native-reanimated for the tilt animation driven by device accelerometer via DeviceMotion sensor.
- Use
Animated.Viewwithtransformstyle - Throttle sensor updates to 60fps
- Respect
prefers-reduced-motion--- disable tilt, keep static gradient
Flip Animation
Tap the card to flip and show the back:
- Back side: QR code for in-store scanning, barcode, member since date
- Flip: 3D
rotateY(180deg)transition, 500ms, ease-in-out - Use
backface-visibility: hiddenon both sides
Performance
Use will-change: transform and transform: translateZ(0) to promote the card to its own compositing layer for smooth animation.
Don't
- Don't use tilt animation on low-end devices --- detect and disable via
navigator.hardwareConcurrency < 4 - Don't use real card numbers in screenshots or documentation
- Don't place interactive elements on top of the card surface --- tilt conflicts with tap targets