A portfolio project card printed like a receipt, with a torn edge, paper grain, a 3D tilt and a neon hover reveal.
The idea
A portfolio piece should feel like proof of work done. So each project prints out as a receipt: itemized, ticked off, torn from the roll and handed over.
How it works
Point at a step to light up its lines in the code.
- 01
Itemized receipt layout
Every project reads like a printed receipt, and every card stays the same height.
ReceiptCard.tsx// Pad deliverables to a fixed row count so all cards reach the same height. const paddedDeliverables: (string | null)[] = [ ...data.deliverables, ...Array(Math.max(0, rows - data.deliverables.length)).fill(null), ] // … const divider = <div style={{ borderTop: '1px dashed rgba(26,26,26,0.15)', marginBottom: dividerGap }} /> // … fontFamily: 'monospace', // like a till printer // … {/* DELIVERABLES, empty rows render as faded dots */} <div style={{ marginBottom: sectionGap }}> {paddedDeliverables.map((d, i) => ( <div key={i} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4, opacity: d ? 1 : 0.28 }}> <span style={{ color: d ? 'inherit' : muted }}>{d ?? '· · ·'}</span> {d && <span aria-hidden="true" style={{ color: accent }}>✓</span>} </div> ))} </div> // … // barcode = repeating stripes, no image background: 'repeating-linear-gradient(90deg, #1a1a1a 0px, #1a1a1a 2px, transparent 2px, transparent 4px, #1a1a1a 4px, #1a1a1a 5px, transparent 5px, transparent 8px)', - 02
Torn edge + paper grain
The torn edge is a shape CSS cuts the card into, and the grain is noise made in code, so there are zero image files.
ReceiptCard.tsx// Torn-paper top + bottom edge (straight sides). Each pair is x% y%, nudge the y values to re-tear. export const tornEdge = 'polygon(0% 1.2%, 7% 0.2%, 14% 1.6%, 21% 0.4%, 29% 1.8%, 36% 0.6%, 43% 1.4%, 50% 0.3%, 57% 1.7%, 64% 0.5%, 71% 1.5%, 79% 0.4%, 86% 1.8%, 93% 0.6%, 100% 1.2%, 100% 98.8%, 93% 99.6%, 86% 98.4%, 79% 99.5%, 71% 98.5%, 64% 99.4%, 57% 98.6%, 50% 99.7%, 43% 98.3%, 36% 99.5%, 29% 98.4%, 21% 99.6%, 14% 98.5%, 7% 99.8%, 0% 98.8%)' // Subtle gray paper grain: desaturated SVG noise inlined as a data URI, no image file needed. export const paperTexture = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\")" // … {/* RECEIPT BODY: shadow wrapper (unclipped) + clipped inner, since clip-path would cut the shadow off */} <div style={{ filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.07)) drop-shadow(0 6px 20px rgba(0,0,0,0.10))' }}> <div style={{ backgroundColor: data.paperColor ?? '#fff6c3', backgroundImage: paperTexture, clipPath: tornEdge, - 03
Cursor light
A soft warm glow follows your cursor, like a torch over receipts on a desk.
CursorLight.tsxexport default function CursorLight({ contained = false, size = 600, color = '255,248,200', intensity = 0.05, }: CursorLightProps) { const ref = useRef<HTMLDivElement>(null) const [pos, setPos] = useState({ x: -1000, y: -1000 }) useEffect(() => { function move(e: MouseEvent) { // measure the overlay so the glow lines up whether it covers the window or one box const rect = ref.current?.getBoundingClientRect() if (!rect) return setPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }) } window.addEventListener('mousemove', move) return () => window.removeEventListener('mousemove', move) }, []) return ( <div ref={ref} aria-hidden="true" style={{ position: contained ? 'absolute' : 'fixed', inset: 0, pointerEvents: 'none', // clicks and hovers pass straight through zIndex: 1, background: `radial-gradient(${size}px circle at ${pos.x}px ${pos.y}px, rgba(${color},${intensity}), transparent 70%)`, }} /> ) } - 04
Paper movement
The receipt tilts toward your cursor in 3D, lifts a little, and settles back with a springy bounce.
ReceiptCard.tsxfunction handleMouseEnter() { setHovered(true) setRevealed(true) // latch on, stays after the mouse leaves // cache the rect once on enter instead of measuring on every move if (ref.current) rectRef.current = ref.current.getBoundingClientRect() } function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) { if (reduceMotion) return const rect = rectRef.current if (!rect) return // cursor position as -0.5 … +0.5, where 0 is the centre of the card const x = (e.clientX - rect.left) / rect.width - 0.5 const y = (e.clientY - rect.top) / rect.height - 0.5 setTilt({ x, y }) } function handleMouseLeave() { setTilt({ x: 0, y: 0 }) setHovered(false) // revealed is intentionally NOT reset here, only scroll clears it } const lifted = hoverable && hovered && !reduceMotion const transform = isMobile ? 'scale(0.78)' : lifted ? `perspective(800px) rotateX(${tilt.y * -8}deg) rotateY(${tilt.x * 8}deg) translateY(-6px) scale(1.02) rotate(${rotation}deg)` : `perspective(800px) rotateX(0deg) rotateY(0deg) translateY(0) scale(1) rotate(${rotation}deg)` // snappy while tracking the mouse, springy settle when it leaves const transition = isMobile ? 'none' : hovered ? 'transform 0.1s ease' : 'transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)' // … transform, transition, willChange: hovered && !isMobile ? 'transform' : 'auto', transformOrigin: 'top center', // swings from the top, like it's pinned - 05
Hover reveal
Hover and a neon headline slides in from the left, an arrow from the right, and both stay until you scroll.
ReceiptCard.tsx// Annotations disappear on any scroll. (React skips the re-render when already false.) useEffect(() => { const onScroll = () => setRevealed(false) window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) }, []) // … function handleMouseEnter() { setHovered(true) setRevealed(true) // latch on, stays after the mouse leaves // cache the rect once on enter instead of measuring on every move if (ref.current) rectRef.current = ref.current.getBoundingClientRect() } // … function handleMouseLeave() { setTilt({ x: 0, y: 0 }) setHovered(false) // revealed is intentionally NOT reset here, only scroll clears it } // … // slide in from 12px to the left while fading in opacity: revealed ? 1 : 0, transform: revealed ? 'translateX(0)' : `translateX(${reduceMotion ? '0px' : '-12px'})`, transition: 'opacity 0.3s ease, transform 0.3s ease', // … // mirror of the heading: slides in from 12px to the right opacity: revealed ? 1 : 0, transform: revealed ? 'translateX(0)' : `translateX(${reduceMotion ? '0px' : '12px'})`, transition: 'opacity 0.3s ease, transform 0.3s ease',
Use it
Copy ReceiptCard.tsx and CursorLight.tsx into your project, then drop it in like this:
import ReceiptCard from './ReceiptCard'
import CursorLight from './CursorLight'
export default function Work() {
return (
<section style={{ background: '#1c1c1c', minHeight: '100vh', display: 'grid', placeItems: 'center' }}>
<CursorLight />
<ReceiptCard
rotation={-1}
data={{
id: 'recipe',
name: 'Recipe Website',
role: 'Designer + Developer',
impact: 'Side project',
deliverables: ['React', 'Tailwind', 'Supabase'],
href: '/work/recipe',
hoverHeading: 'From Figma to a Live Product',
}}
/>
</section>
)
}Dependencies
- React 18+useState, useRef, useEffect. Nothing else to install.
- No animation libraryEvery movement is a CSS transform + transition.
- Space Grotesk + DokdoOptional fonts, read from --font-space-grotesk and --font-dokdo. Falls back to sans-serif and cursive.