A portfolio project card printed like a receipt, with a torn edge, paper grain, a 3D tilt and a neon hover reveal.

reacttypescriptcss

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.

  1. 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)',
  2. 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,
  3. 03

    Cursor light

    A soft warm glow follows your cursor, like a torch over receipts on a desk.

    CursorLight.tsx
    export 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%)`,
          }}
        />
      )
    }
  4. 04

    Paper movement

    The receipt tilts toward your cursor in 3D, lifts a little, and settles back with a springy bounce.

    ReceiptCard.tsx
    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 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
  5. 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.