A yellow card that unfolds into a Creative License when you click it, with doodles bursting out from behind.

reacttypescriptcss

click or tap the card, and again to fold it back

It works the same on phones: tap the card to unfold it, and tap again to fold it back. With the keyboard, Tab to the card and press Enter or Space. If your device is set to reduce motion, the card opens straight away, without the bounce.

The idea

On my portfolio, this card is the door to the playful side of my work. It rests like a card tossed on a desk, and one click unfolds it into a Creative License while doodles and desk objects burst out from behind it.

Words you'll see 14
component
A reusable piece of interface, like a component in Figma. PlayCard is one, and so are LicenseField and Stamp.
props
The settings you hand to a component, like color or bursts. Change them and the card changes.
state (useState)
Something a component remembers. Here it is one true/false: is the card open?
useEffect
Code that runs after the component appears, to connect it to something outside it. Here it checks whether your device asks for less motion.
CSS
The language that styles things: colour, size, position, movement.
transform
Moves, rotates or scales something without pushing anything else around.
transition
Makes a change happen smoothly over time instead of all at once.
cubic-bezier
The shape of that smoothness: how a movement speeds up and slows down. Like easing in Figma prototypes.
z-index
Which thing sits in front when two overlap. Higher numbers are in front, like layer order in Figma.
SVG
A picture written as code: shapes, lines and points. It stays sharp at any size.
mix-blend-mode
How a layer mixes with what is under it, like blend modes in Figma. Multiply darkens.
mask-image
A stencil: only the parts of a layer where the mask is filled in show through.
flexbox
A way to lay things out in a row or column, like auto layout in Figma.
pointer-events
Whether something can be clicked or hovered. "none" lets the mouse pass straight through.

How it works

A quick map first. A component is a reusable piece of interface, like a component in Figma. Props are the settings and content you pass into it, like a title or a colour. The files end in .tsx: interface code written in TypeScript, which is JavaScript that also checks what kind of value each setting expects.

Point at a step, tap it or Tab to it, to light up its lines in the code.

  1. 01

    The unfold

    Click and the tilted card straightens, lifts and turns into a licence, in one springy motion.

    1. One switch, open, decides everything: true or false (useState). A click turns it on, and a second click turns it off and folds the card back.
    2. Closed, the card sits turned -6° and 8% smaller (scale(0.92)), like a card tossed on a desk (transform).
    3. Open, it straightens to 0°, grows to full size and lifts 6px, and its shadow grows deeper to match (box-shadow).
    4. The change between the two looks plays over 0.6 seconds (transition). The bouncy curve (cubic-bezier) has a 1.56 in it, which overshoots, so the card lands with a little bounce.
    5. The big "PLAY ↗" word is swapped for the licence the moment it opens. Whatever you pass in as license is what shows.
    PlayCard.tsx
    const [open, setOpen] = useState(defaultOpen)
    // springy: the 1.56 overshoots the end, then settles back
    const spring = reduceMotion ? '0.01s linear' : '0.6s cubic-bezier(0.34, 1.56, 0.64, 1)'
    
    function handleClick() {
      if (open) {
        setOpen(false) // a second click folds it back
        return
      }
      setOpen(true)
      // let the unfold play before anything else happens (the portfolio changes page here)
      if (onOpen) setTimeout(onOpen, openDelay)
    }
    
    // …
    
    // one flag, two looks: resting tilted and a bit smaller, or straight, full size and lifted
    transform: open
      ? 'translateY(-6px) rotate(0deg) scale(1)'
      : 'translateY(0) rotate(-6deg) scale(0.92)',
    boxShadow: open
      ? '0 28px 56px rgba(0,0,0,0.3)'
      : '0 14px 30px rgba(0,0,0,0.22)',
    transition: `transform ${spring}, box-shadow ${spring}`,
    
    // …
    
    {/* the label swaps for the licence the moment it opens */}
    {open ? license : (

    This is part of the file. Copy the complete file from the Code tab.

  2. 02

    The burst

    Doodles and desk objects spill out from behind the card, one after another. The same open switch from 01 sends them out.

    1. While the card is closed, every piece waits at its centre, hidden behind it, shrunk to nothing (scale(0)) and see-through (opacity).
    2. When it opens, each piece flies to its own spot: x and y are how far it lands from the centre, in pixels, and rotate is its spin (translate).
    3. Each piece leaves 0.05 seconds after the one before (a transition delay), so they spill out instead of popping all at once.
    4. z decides what sits in front (z-index). The card is 20: a piece below 20 peeks out from behind it, and above 20 lands on top, like the pencil.
    5. A piece is either a small doodle drawn in code (SVG), like a star or heart, or a picture, if you give it src.
    PlayCard.tsx
    // closed: every piece waits at the centre of the card, shrunk to nothing
    transform: open
      ? `translate(${b.x}px, ${b.y}px) rotate(${b.rotate}deg) scale(1)`
      : 'translate(-50%, -50%) scale(0)',
    opacity: open ? 1 : 0,
    // open: each one leaves 0.05s after the one before
    transition: `all ${spring} ${i * 0.05}s`,
    zIndex: b.z ?? 10,
    
    // …
    
    {b.src
      ? <img src={b.src} alt="" style={{ width: b.size, height: b.size, objectFit: 'contain', display: 'block' }} />
      : <Shape type={b.shape ?? 'star'} size={b.size} color={b.color ?? ink} />}

    This is part of the file. Copy the complete file from the Code tab.

  3. 03

    Brown paper grain

    The grain is brown, not grey, so the yellow looks printed rather than dirty. It is made in code, so there is no image file.

    1. A layer of solid brown is laid over the whole card (inset: 0 stretches it to every edge).
    2. The brown is blended into the yellow, darkening it like ink soaking into paper (mix-blend-mode: multiply).
    3. Random noise (SVG noise filter, feTurbulence) is used as a stencil (mask-image), so the brown only shows through in specks.
    4. Clicks pass straight through the grain to the card (pointer-events: none), so it never gets in the way.

    You don't need to read the long line starting url("data:image/svg+xml. It is a tiny noise image written as text. To make the grain finer or coarser, change baseFrequency='0.9' inside it in the Edit tab.

    PlayCard.tsx
    // Paper grain: SVG noise used as a mask, so a colour only shows through in specks.
    const noiseMask =
      "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E … %3C/svg%3E\")"
    
    // …
    
    {/* GRAIN: brown, multiplied onto the yellow, visible only through the noise mask */}
    <div style={{
      position: 'absolute',
      inset: 0,
      zIndex: 21,
      pointerEvents: 'none', // clicks still reach the card
      opacity: 0.2,
      backgroundColor: ink,
      mixBlendMode: 'multiply',
      WebkitMaskImage: noiseMask,
      maskImage: noiseMask,
      WebkitMaskSize: '180px',
      maskSize: '180px',
    }} />

    This is part of the file. Copy the complete file from the Code tab.

  4. 04

    The licence details

    The licence reads like a real form: labels, dotted lines, handwriting and a rubber stamp. Each is a small component you can reuse.

    1. Each row is a small serif label, then a dotted line that stretches to fill the rest of the row (flex: 1, part of flexbox).
    2. The value sits on the line in handwriting, turned -2°, as if it was filled in by hand.
    3. The stamp is a circle with an outline (border-radius: 50%), turned -12° and pushed 10px past the corner, so it runs off the edge of the card.
    PlayCard.tsx
    // One form row: a serif label, a dotted line that fills the rest, and a handwritten value on it.
    export function LicenseField({ label, value, ink = '#6B3F2A', pen = '#E8186D' }: { label: string; value: string; ink?: string; pen?: string }) {
      return (
        <div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, marginBottom: 8 }}>
          <span style={{
            fontFamily: 'Georgia, "Times New Roman", serif',
            fontSize: 12,
            color: ink,
            whiteSpace: 'nowrap',
          }}>
            {label}
          </span>
          <span style={{
            flex: 1, // the dotted line stretches to fill the row
            borderBottom: `2px dotted ${ink}66`,
          }}>
            <span style={{
              fontFamily: 'var(--font-dokdo), cursive',
              fontSize: 16,
              color: pen,
              display: 'inline-block',
              transform: 'rotate(-2deg)', // filled in by hand, never quite straight
              lineHeight: 1,
            }}>
              {value}
            </span>
          </span>
        </div>
      )
    }
    
    // A rubber stamp: a rotated ring of small caps that bleeds off the card's corner.
    export function Stamp({ children, pen = '#E8186D' }: { children: ReactNode; pen?: string }) {
      return (
        <div style={{
          position: 'absolute',
          bottom: -10,
          right: -10,
          width: 76,
          height: 76,
          borderRadius: '50%',
          border: `2px solid ${pen}`,
          transform: 'rotate(-12deg)',
          opacity: 0.85,
          color: pen,
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          textAlign: 'center',
          padding: 8,
          boxSizing: 'border-box',
          fontFamily: 'var(--font-space-grotesk), sans-serif',
          fontSize: 8,
          fontWeight: 700,
          lineHeight: 1.25,
          letterSpacing: '0.05em',
          textTransform: 'uppercase',
        }}>
          {children}
        </div>
      )
    }

    This is part of the file. Copy the complete file from the Code tab.

Use it

The buttons above copy the complete files. The code in How it works is only excerpts, so copy from here.

  1. You need Node.js installed. In a terminal, run npx create-next-app@latest my-play-card --yes to make a new Next.js project, then cd my-play-card.
  2. Inside the app folder, make a folder called components. Copy the complete file with the button above and save it as app/components/PlayCard.tsx.
  3. Open app/page.tsx, delete everything in it, and paste the example below.
  4. Run npm run dev and open localhost:3000. Click the card.
  5. Change these first: the words on the licence (each LicenseField), the card colour (color) and the doodles in bursts. To use your own pictures instead of doodles, put them in the public folder and give a burst src: '/your-picture.png'.
app/page.tsx
import PlayCard, { LicenseField, Stamp } from './components/PlayCard'

export default function Page() {
  return (
    <main style={{ background: '#3336A3', minHeight: '100vh', display: 'grid', placeItems: 'center' }}>
      <PlayCard
        color="#FFE555" // the card colour
        license={
          <div style={{ position: 'relative', height: '100%' }}>
            <LicenseField label="Issued to" value="Your name" />
            <LicenseField label="Specialty" value="Making things" />
            <LicenseField label="Valid" value="forever" />
            <Stamp>Certified<br />Creative ✦</Stamp>
          </div>
        }
        bursts={[
          { shape: 'star', x: -170, y: -190, size: 28, rotate: 15, color: '#FFE555' },
          { shape: 'heart', x: 220, y: -60, size: 36, rotate: -10, color: '#E8186D' },
          { shape: 'squiggle', x: -250, y: -50, size: 30, rotate: 5, color: '#e4ff5d' },
          { shape: 'circle', x: 40, y: -200, size: 16, rotate: 0, color: '#E8186D' },
        ]}
      />
    </main>
  )
}
What you can change 22
<PlayCard /> 8
licenserequired
What the card shows once it opens. Build it from LicenseField rows and a Stamp, or put anything you like in it.
burstsoptional
The pieces that fly out when it opens. Each one's parts are listed below. Leave it out for no burst.
labeloptional
The big word on the closed card. Default PLAY ↗.
coloroptional
The card colour. Default #FFE555.
inkoptional
The colour of the label, the grain, and doodles without their own colour. Default #6B3F2A.
defaultOpenoptional
Set to true to start already unfolded, like the card on the home page.
onOpenoptional
Something to do after it opens, like going to another page (the portfolio does this). It's a function, so it only works inside a file that starts with 'use client'.
openDelayoptional
How long to wait before onOpen runs, in milliseconds, so the unfold can play first. Default 620.
Inside each burst 8
xrequired
How far it lands left (minus) or right (plus) of the card's centre, in pixels.
yrequired
How far it lands above (minus) or below (plus) the centre, in pixels.
rotaterequired
Its spin when it lands, in degrees.
sizerequired
Its width, in pixels.
shapeoptional
star, heart, circle or squiggle. Default star.
coloroptional
The doodle's colour. Default is the card's ink.
srcoptional
A picture to use instead of a doodle, like '/jam.png' from the public folder.
zoptional
Front or back: below 20 it peeks out from behind the card, above 20 it lands on top. Default 10.
<LicenseField /> 4
labelrequired
The small serif label, like Issued to.
valuerequired
The handwritten answer on the dotted line.
inkoptional
The label and dotted line colour. Default #6B3F2A.
penoptional
The handwriting colour. Default #E8186D.
<Stamp /> 2
childrenoptional
The words inside the stamp, written between <Stamp> and </Stamp>. Use <br /> for a new line.
penoptional
The stamp colour. Default #E8186D.
Dependencies 4
  • React 18+useState and useEffect. Nothing else to install.
  • No animation libraryThe unfold and the burst are CSS transform + transition, switched by a single true/false.
  • Dokdo + Space GroteskOptional fonts (Dokdo for the handwriting, Space Grotesk for the stamp), read from --font-dokdo and --font-space-grotesk. Falls back to cursive and sans-serif.
  • ImagesOptional. The doodles are drawn in code (SVG); the jam, pencil and sharpener here are PNG pictures.
Things to learn next 7