# Post-it note — agent installation guide

This document contains the complete distributable source, usage example,
installation requirements, and MIT license from Daniel Paul's UI Kit.
The HTML demo and ZIP download are generated from the same files.

## Install in an existing project

1. Target platform: React. Read the project's instructions and inspect its
   framework, supported OS/runtime, package manager, and component conventions.
   Reuse the existing app and compatible dependencies. Do not replace its framework.
2. Requirements: React 18.2+ or React 19 with matching react-dom, TypeScript, and a bundler with CSS Modules support (Vite, Next.js, or Astro).
   Dependency command: `npm install motion@14.9.0 @fontsource/caveat@5.3.0 @paper-design/shaders@0.0.82`. Adapt it to the project's package manager and reuse compatible existing dependencies.
3. Copy every component file and the MIT license below together. A suggested
   location is `src/components/post-it-note/`; adapt to the project's conventions
   and preserve relative imports. The platform-specific README and usage example explain
   integration, resources, and accessibility.
4. Integrate the component where the user asks. If no location is specified,
   add a small example in an appropriate existing preview or screen and report
   where to find it. Connect real app state and persistence only where needed;
   do not claim that local preview edits have been saved remotely.
5. Preserve keyboard access, visible focus, reduced-motion behavior, and the
   license. Run the relevant type checks and build, and report any checks that
   could not run. Do not deploy or publish without the user's authorization.

## Complete files

## PostItNote.tsx

```tsx
'use client'

// SPDX-License-Identifier: MIT — Copyright (c) 2026 Daniel Paul
import { useEffect, useRef, useState, type ReactNode, type ReactElement, type CSSProperties } from 'react'
import { motion, useReducedMotion } from 'motion/react'
import '@fontsource/caveat/latin-500.css'
import { noteSpring } from './motion'
import { createPaperCurl } from './paper-curl'
import { paperGeometry } from './paper-geometry'
import styles from './PostItNote.module.css'

const fallback = paperGeometry(300, 300, 0.3)
const fallbackOutline = `M4 .5H296Q299 .5 299 4L${fallback.right.map((p) => `${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join('L')}L${fallback.bottom.slice().reverse().map((p) => `${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join('L')}H4Q.5 299 .5 296V4Q.5 .5 4 .5Z`

export type NoteColor = 'yellow' | 'rose' | 'blue' | 'lilac'
export interface PostItNoteProps {
  title?: string
  children?: ReactNode
  color?: NoteColor
  rotation?: number
  editable?: boolean
  breeze?: boolean
  texture?: boolean
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  placeholder?: string
  maxLength?: number
  footerLabel?: string
  date?: string
  dateLabel?: string
  permalink?: string
  className?: string
  style?: CSSProperties
}

/** A paper note. Editing is local state unless value/onValueChange are supplied. */
export function PostItNote({
  title, children, color = 'yellow', rotation = 0, editable = false, breeze = true, texture = true, value, defaultValue = '',
  onValueChange, placeholder = 'Something worth keeping…', maxLength = 500,
  footerLabel, date, dateLabel, permalink, className = '', style,
}: PostItNoteProps): ReactElement {
  const [localValue, setLocalValue] = useState(defaultValue)
  const text = value ?? localValue
  const textarea = useRef<HTMLTextAreaElement>(null)
  const body = useRef<HTMLDivElement>(null)
  const [scrollEdges, setScrollEdges] = useState({ up: false, down: false })
  const note = useRef<HTMLElement>(null)
  const canvas = useRef<HTMLCanvasElement>(null)
  const reduceMotion = useReducedMotion()

  useEffect(() => {
    const scroller = editable ? textarea.current : body.current
    if (!scroller) return
    let active = true
    function measure() {
      if (!active || !scroller) return
      const up = scroller.scrollTop > 2
      const down = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 2
      setScrollEdges((current) => current.up === up && current.down === down ? current : { up, down })
    }
    scroller.addEventListener('scroll', measure, { passive: true })
    const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null
    observer?.observe(scroller)
    if (scroller.firstElementChild) observer?.observe(scroller.firstElementChild)
    measure()
    void document.fonts.ready.then(measure)
    return () => {
      active = false
      scroller.removeEventListener('scroll', measure)
      observer?.disconnect()
    }
  }, [editable, text, children])

  useEffect(() => {
    const renderer = createPaperCurl(canvas.current!, note.current!, breeze, texture)
    return () => renderer?.dispose()
  }, [breeze, color, style, texture])

  function update(next: string) {
    if (value === undefined) setLocalValue(next)
    onValueChange?.(next)
  }

  const dateContent = date ? <time dateTime={date}>{dateLabel ?? date}</time> : dateLabel
  const noteStyle = { ...style, '--note-rotation': `${rotation}deg` } as CSSProperties
  return (
    <motion.article
      ref={note}
      className={`${styles.note} ${className}`}
      data-note-color={color}
      data-editable={editable || undefined}
      onClick={(event) => {
        if (editable && event.target instanceof Element && !event.target.closest('a, button, input, textarea, select, [contenteditable]')) textarea.current?.focus()
      }}
      style={{ ...noteStyle, rotate: rotation }}
      initial={false}
      whileHover={{ y: editable && !reduceMotion ? -2 : 0 }}
      transition={reduceMotion ? { duration: 0 } : noteSpring}
    >
      <svg className={`${styles.artwork} ${styles.fallback}`} viewBox="0 0 300 300" preserveAspectRatio="none" aria-hidden="true">
        <path className={styles.fallbackFace} d={fallbackOutline} />
      </svg>
      <canvas ref={canvas} className={`${styles.artwork} ${styles.canvas}`} aria-hidden="true" />
      <div className={styles.paper}>
        <div className={styles.content}>
          {title && <h2 className={styles.title}>{title}</h2>}
          <div className={styles.writing} data-scroll-up={scrollEdges.up || undefined} data-scroll-down={scrollEdges.down || undefined}>
          {editable ? <textarea
            ref={textarea}
            className={styles.editor}
            aria-label={title ? `${title} — note text` : 'Note text'}
            value={text}
            onChange={(event) => update(event.target.value)}
            placeholder={placeholder}
            maxLength={maxLength}
            rows={3}
          /> : <div ref={body} className={styles.body} tabIndex={0} role="region" aria-label={title ? `${title} — note content` : 'Note content'}><div className={styles.bodyContent}>{children ?? text}</div></div>}
          </div>
          {(footerLabel || dateContent) && <footer className={styles.footer}>
            {footerLabel && <span>{footerLabel}</span>}
            {dateContent && (permalink ? <a href={permalink} aria-label={`Permanent link to ${title ?? 'note'}`}>{dateContent}</a> : <span>{dateContent}</span>)}
          </footer>}
        </div>
      </div>
    </motion.article>
  )
}
```

## PostItNote.module.css

```css
/* SPDX-License-Identifier: MIT — Copyright (c) 2026 Daniel Paul */
.note {
  --note-paper: #fae7a0;
  --note-edge: #d8ba5e;
  --note-ink: #453b1d;
  --note-writing: #244e92;
  --note-muted: #756335;
  --note-shadow: #72582230;
  position: relative;
  isolation: isolate;
  box-sizing: border-box;
  width: 100%;
  aspect-ratio: 1;
  min-width: 0;
  color: var(--note-ink);
  font-family: inherit;
  font-variation-settings: normal;
  font-size: 14px;
  line-height: 1.55;
  text-align: left;
  overflow-wrap: anywhere;
  filter: drop-shadow(0 2px 2px var(--note-shadow)) drop-shadow(0 14px 12px #2519001a);
}
.artwork { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
.fallback { z-index: 0; }
.fallbackFace { fill: var(--note-paper); stroke: var(--note-edge); stroke-width: 0.6; }
.canvas { z-index: 0; opacity: 0; }
.note[data-curl-ready] .canvas { opacity: 1; }
.note[data-curl-ready] .fallback { opacity: 0; }
.paper { position: absolute; inset: 0; z-index: 1; }
.note[data-editable] { cursor: text; }
.note[data-note-color='rose'] { --note-paper: #f6dbe2; --note-edge: #d6a5b5; --note-ink: #4d303b; --note-muted: #795563; --note-shadow: #854d622b; }
.note[data-note-color='blue'] { --note-paper: #dfeaf8; --note-edge: #adc4df; --note-ink: #283d56; --note-muted: #506988; --note-shadow: #3b5e802b; }
.note[data-note-color='lilac'] { --note-paper: #e9dff5; --note-edge: #c1a9dc; --note-ink: #423254; --note-muted: #705b87; --note-shadow: #6449822b; }
.content { box-sizing: border-box; position: relative; display: flex; flex-direction: column; height: 100%; min-height: 0; gap: 12px; padding: 8% 9% 10%; }
.title { flex: none; font: inherit; font-size: 12px; line-height: 1.4; font-weight: 600; margin: 0; color: inherit; text-align: center; }
.writing { position: relative; display: flex; flex: 1 1 0; min-height: 0; }
.writing[data-scroll-up] { --note-fade-top: transparent; }
.writing[data-scroll-down] { --note-fade-bottom: transparent; }
.body, .editor { color: var(--note-writing); font-family: 'Caveat', cursive; font-size: 24px; font-weight: 500; line-height: 1.2; text-align: center; }
.body, .editor { flex: 1 1 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--note-ink) 22%, transparent) transparent; border-radius: 5px; }
.writing > :is(.body, .editor) { mask-image: linear-gradient(to bottom, var(--note-fade-top, #000), #000 44px, #000 calc(100% - 44px), var(--note-fade-bottom, #000)); }
.body { white-space: pre-line; margin: 0; }
.bodyContent { box-sizing: border-box; min-height: 100%; display: flex; flex-direction: column; justify-content: center; padding-block: 8px; }
.bodyContent > :first-child { margin-top: 0; }
.bodyContent > :last-child { margin-bottom: 0; }
.bodyContent > * { flex-shrink: 0; }
.editor { display: block; box-sizing: border-box; width: 100%; padding: 12px 6px; margin: 0; border: 0; outline: none; resize: none; background: transparent; }
.editor::placeholder { color: var(--note-writing); opacity: 1; }
.note[data-editable]:hover .editor { background: color-mix(in srgb, var(--note-writing) 3%, transparent); }
.note[data-editable]:focus-within .editor { background: color-mix(in srgb, var(--note-writing) 5%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--note-writing) 10%, transparent); }
.body:focus-visible { outline: 1px solid color-mix(in srgb, var(--note-writing) 55%, transparent); outline-offset: 2px; }
.footer { display: flex; flex: none; flex-direction: column; justify-content: center; align-items: center; gap: 4px; margin: 0; padding: 0; color: var(--note-muted); font-size: 11px; line-height: 1.4; text-align: center; }
.footer a { color: inherit; text-decoration: none; border-radius: 2px; }
.footer a:hover { color: var(--note-ink); }
.footer a:focus-visible { outline: 2px solid var(--note-ink); outline-offset: 4px; }
/* Canvas owns the corner geometry; the writing always stays in the DOM. */
@media (prefers-reduced-motion: reduce) {
  .note { transform: rotate(var(--note-rotation, 0deg)) !important; }
}
```

## paper-curl.ts

```ts
// SPDX-License-Identifier: MIT — Copyright (c) 2026 Daniel Paul
import { noteBreeze } from './motion'
import { getPaperTexture } from './paper-texture'
import { paperGeometry, paperStripColor, type PaperPoint } from './paper-geometry'

/** Canvas paints only paper. The accessible, editable content remains HTML. */
export function createPaperCurl(canvas: HTMLCanvasElement, root: HTMLElement, breeze: boolean, textureEnabled = true) {
  const ctx = canvas.getContext('2d')
  if (!ctx || typeof ResizeObserver === 'undefined' || typeof Path2D === 'undefined') return null
  const events = new AbortController()
  const reduced = matchMedia('(prefers-reduced-motion: reduce)')
  const palette = getComputedStyle(root)
  const paper = palette.getPropertyValue('--note-paper').trim() || '#fae7a0'
  const edge = palette.getPropertyValue('--note-edge').trim() || '#d8ba5e'
  let width = 0
  let height = 0
  let dpr = 1
  let frame = 0
  let elapsed = noteBreeze.duration * 1000 * 0.2
  let previous = 0
  let lift = 0.3
  let flutter = 0
  let visible = !('IntersectionObserver' in window)
  let disposed = false
  let material: HTMLCanvasElement | null = null
  let loadingTexture = false
  root.dataset.paperTexture = textureEnabled ? 'pending' : 'off'

  function loadTexture() {
    if (!textureEnabled || loadingTexture || disposed) return
    loadingTexture = true
    root.dataset.paperTexture = 'loading'
    void getPaperTexture().then((result) => {
      if (disposed) return
      material = result
      root.dataset.paperTexture = result ? 'ready' : 'fallback'
      draw(lift)
    })
  }

  function textureInside(path: Path2D, opacity: number) {
    if (!material) return
    ctx!.save()
    ctx!.clip(path)
    ctx!.globalCompositeOperation = 'multiply'
    ctx!.globalAlpha = opacity
    ctx!.drawImage(material, 0, 0, width, height)
    ctx!.restore()
  }

  function draw(amount: number) {
    if (!width || !height || disposed) return
    ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
    ctx!.clearRect(0, 0, width, height)
    const { w, h, right, bottom, strips } = paperGeometry(width, height, amount, flutter)
    function polygon(points: PaperPoint[]) {
      const path = new Path2D()
      path.moveTo(points[0].x, points[0].y)
      for (const point of points.slice(1)) path.lineTo(point.x, point.y)
      path.closePath()
      return path
    }
    const face = new Path2D()
    face.moveTo(4, 0.5)
    face.lineTo(w - 3, 0.5)
    face.quadraticCurveTo(w, 0.5, w, 4)
    face.lineTo(right[0].x, right[0].y)
    face.lineTo(bottom[0].x, bottom[0].y)
    face.lineTo(4, h)
    face.quadraticCurveTo(0.5, h, 0.5, h - 3)
    face.lineTo(0.5, 4)
    face.quadraticCurveTo(0.5, 0.5, 4, 0.5)
    face.closePath()
    ctx!.fillStyle = paper
    ctx!.fill(face)
    textureInside(face, 0.36)
    ctx!.save()
    ctx!.clip(face)
    const wash = ctx!.createLinearGradient(0, 0, width * 0.25, height)
    wash.addColorStop(0, 'rgba(255,255,255,0.22)')
    wash.addColorStop(0.65, 'rgba(255,255,255,0)')
    wash.addColorStop(1, 'rgba(255,255,255,0.05)')
    ctx!.fillStyle = wash
    ctx!.fillRect(0, 0, width, height)
    // The fixed corner is removed from the flat face; the curved mesh fills it.
    ctx!.restore()

    // Paint from the attached edge toward the lifted tip. The cylindrical
    // surface has constant material length; its normals determine the shading.
    for (const strip of strips) {
      const path = polygon(strip.points)
      const tone = paperStripColor(paper, strip.angle, strip.back)
      ctx!.fillStyle = tone
      ctx!.fill(path)
      // A half-pixel overlap prevents hairline cracks between adjacent strips.
      ctx!.strokeStyle = tone
      ctx!.lineWidth = 0.5
      ctx!.stroke(path)
      textureInside(path, 0.24)
    }
    // Outline only the actual perimeter, never the internal mesh or hinge.
    ctx!.beginPath()
    ctx!.moveTo(4, 0.5)
    ctx!.lineTo(w - 3, 0.5)
    ctx!.quadraticCurveTo(w, 0.5, w, 4)
    ctx!.lineTo(right[0].x, right[0].y)
    for (const point of right.slice(1)) ctx!.lineTo(point.x, point.y)
    for (const point of bottom.slice().reverse()) ctx!.lineTo(point.x, point.y)
    ctx!.lineTo(4, h)
    ctx!.quadraticCurveTo(0.5, h, 0.5, h - 3)
    ctx!.lineTo(0.5, 4)
    ctx!.quadraticCurveTo(0.5, 0.5, 4, 0.5)
    ctx!.closePath()
    ctx!.strokeStyle = edge
    ctx!.globalAlpha = 0.4
    ctx!.lineWidth = 0.7
    ctx!.lineJoin = 'round'
    ctx!.stroke()
    ctx!.globalAlpha = 1
  }

  function tick(now: number) {
    if (disposed) return
    if (previous) elapsed += Math.min(now - previous, 50)
    previous = now
    const phase = elapsed / (noteBreeze.duration * 1000)
    const swell = (1 - Math.cos(phase * Math.PI * 2)) / 2
    // The larger swell lifts the corner; a shorter ripple flicks its free edge.
    flutter = Math.sin(phase * Math.PI * 6) * Math.pow(swell, 1.5)
    lift = 0.12 + 0.8 * swell
    draw(lift)
    frame = requestAnimationFrame(tick)
  }

  function sync() {
    if (disposed) return
    if (visible) loadTexture()
    const running = breeze && !reduced.matches && !document.hidden && visible && !root.matches(':focus-within')
    root.dataset.curlState = running ? 'animated' : 'still'
    if (running && !frame) {
      previous = 0
      frame = requestAnimationFrame(tick)
    } else if (!running) {
      cancelAnimationFrame(frame)
      frame = 0
      previous = 0
      if (reduced.matches || !breeze) {
        lift = 0.3
        flutter = 0
      }
      draw(lift)
    }
  }

  function resize() {
    if (disposed) return
    // Use layout dimensions so a rotated parent does not distort the bitmap.
    width = root.clientWidth
    height = root.clientHeight
    dpr = Math.min(window.devicePixelRatio || 1, 2)
    canvas.width = Math.max(1, Math.round(width * dpr))
    canvas.height = Math.max(1, Math.round(height * dpr))
    draw(lift)
    root.dataset.curlReady = 'true'
    sync()
  }
  const resizeObserver = new ResizeObserver(resize)
  resizeObserver.observe(root)
  const intersection = 'IntersectionObserver' in window ? new IntersectionObserver(([entry]) => {
    visible = entry.isIntersecting
    sync()
  }) : null
  intersection?.observe(root)
  document.addEventListener('visibilitychange', sync, { signal: events.signal })
  window.addEventListener('resize', resize, { signal: events.signal })
  reduced.addEventListener('change', sync, { signal: events.signal })
  root.addEventListener('focusin', sync, { signal: events.signal })
  root.addEventListener('focusout', () => queueMicrotask(sync), { signal: events.signal })
  canvas.addEventListener('contextlost', () => {
    root.removeAttribute('data-curl-ready')
    dispose()
  }, { signal: events.signal })
  resize()

  function dispose() {
    disposed = true
    cancelAnimationFrame(frame)
    events.abort()
    resizeObserver.disconnect()
    intersection?.disconnect()
    root.removeAttribute('data-curl-ready')
    root.removeAttribute('data-curl-state')
    root.removeAttribute('data-paper-texture')
  }
  return { dispose }
}
```

## paper-geometry.ts

```ts
// SPDX-License-Identifier: MIT — Copyright (c) 2026 Daniel Paul
export interface PaperPoint { x: number; y: number }
export interface PaperStrip { points: PaperPoint[]; angle: number; back: boolean }

/** Bend a fixed triangular corner around a cylinder without stretching it. */
export function paperGeometry(width: number, height: number, amount: number, flutter = 0) {
  const w = width - 1, h = height - 1
  const length = Math.min(width, height) * 0.25
  const reach = length / Math.SQRT2
  const turn = 0.65 + amount * 1.85 + flutter * 0.07
  const radius = reach / turn
  const cameraX = 0.1, cameraY = 0.6
  function project(t: number, side: 'right' | 'bottom'): PaperPoint {
    const distance = reach * t
    const angle = distance / radius
    const travel = radius * Math.sin(angle)
    const z = radius * (1 - Math.cos(angle))
    const inset = (travel - distance) / Math.SQRT2
    return {
      x: (side === 'right' ? w : w - length + length * t) + inset - z * cameraX,
      y: (side === 'bottom' ? h : h - length + length * t) + inset - z * cameraY,
    }
  }
  const right: PaperPoint[] = [], bottom: PaperPoint[] = [], strips: PaperStrip[] = []
  const steps = 24
  for (let i = 0; i <= steps; i++) {
    right.push(project(i / steps, 'right'))
    bottom.push(project(i / steps, 'bottom'))
    if (i) {
      const angle = turn * (i - 0.5) / steps
      strips.push({
        points: [right[i - 1], right[i], bottom[i], bottom[i - 1]], angle,
        back: Math.cos(angle) - (cameraX + cameraY) / Math.SQRT2 * Math.sin(angle) < 0,
      })
    }
  }
  return { w, h, length, right, bottom, strips }
}

/** Diffuse light on the same paper pigment, including the paler reverse face. */
export function paperStripColor(paper: string, angle: number, back: boolean) {
  if (!/^#[0-9a-f]{6}$/i.test(paper)) return paper
  const white = back ? 0.32 : Math.max(0, 0.05 * Math.cos(angle - 0.35))
  const shade = (1 - Math.abs(Math.cos(angle - 0.4))) * 0.12
  const shadow = [66, 48, 25]
  const channels = [1, 3, 5].map((offset, i) => {
    const base = parseInt(paper.slice(offset, offset + 2), 16)
    const lit = base + (255 - base) * white
    return Math.round(lit + (shadow[i] - lit) * shade)
  })
  return `rgb(${channels.join(',')})`
}
```

## paper-texture.ts

```ts
// SPDX-License-Identifier: MIT — Copyright (c) 2026 Daniel Paul
// Uses Paper Shaders (Apache-2.0) through its public npm API.
let texture: Promise<HTMLCanvasElement | null> | undefined

/** Render Paper's static material once, then release its WebGL context. */
export function getPaperTexture() {
  return texture ??= renderTexture()
}

async function renderTexture(): Promise<HTMLCanvasElement | null> {
  let mount: import('@paper-design/shaders').ShaderMount | undefined
  let host: HTMLDivElement | undefined
  try {
    const paper = await import('@paper-design/shaders')
    const noise = paper.getShaderNoiseTexture()
    if (!noise) return null
    const image = new Image()
    image.src = paper.emptyPixel
    await Promise.all([noise.decode(), image.decode()])
    host = document.createElement('div')
    host.setAttribute('aria-hidden', 'true')
    host.style.cssText = 'position:fixed;left:-10000px;top:0;width:512px;height:512px;opacity:0;pointer-events:none;contain:strict;overflow:hidden'
    document.body.append(host)
    mount = new paper.ShaderMount(host, paper.paperTextureFragmentShader, {
      u_image: image, u_isImage: false, u_noiseTexture: noise,
      u_colorBack: [1, 1, 1, 1], u_colorPaper: [1, 1, 1, 1],
      u_colorShadow: [0.36, 0.34, 0.3, 1],
      u_blending: 1, u_distortion: 0, u_clip: false, u_angle: 300, u_seed: 4,
      u_roughness: 0.22, u_roughnessSize: 0.32, u_roughnessRows: 0,
      u_fiber: 0.07, u_fiberSize: 0.3,
      u_folds: 0, u_foldSizeX: 1, u_foldSizeY: 1, u_foldOffsetX: 0, u_foldOffsetY: 0,
      u_wrinkles: 0, u_wrinkleSize: 0.65, u_crumples: 0, u_crumpleCount: 6, u_drops: 0,
      u_fit: paper.ShaderFitOptions.none, u_scale: 0.9, u_rotation: 0,
      u_originX: 0.5, u_originY: 0.5, u_offsetX: 0, u_offsetY: 0,
      u_worldWidth: 0, u_worldHeight: 0,
    }, { preserveDrawingBuffer: true }, 0, 0, 1, 512 * 512)
    // Allow the mount's ResizeObserver to establish a correctly sized frame.
    await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
    mount.setFrame(0)
    const result = document.createElement('canvas')
    result.width = 512
    result.height = 512
    const context = result.getContext('2d')
    if (!context) return null
    context.drawImage(mount.canvasElement, 0, 0, 512, 512)
    if (!context.getImageData(256, 256, 1, 1).data[3]) return null
    return result
  } catch {
    // The Canvas curl remains usable when WebGL or the optional import fails.
    return null
  } finally {
    mount?.dispose()
    host?.remove()
  }
}
```

## motion.ts

```ts
// SPDX-License-Identifier: MIT — Copyright (c) 2026 Daniel Paul
// Shared spring tier: short travel, no overshoot.
export const noteSpring = { type: 'spring', duration: 0.16, bounce: 0 } as const

// Ambient corner movement is deliberately slower than interaction feedback.
export const noteBreeze = { duration: 6.5 } as const
```

## Usage.tsx

```tsx
'use client'

import { useState } from 'react'
import { PostItNote } from './PostItNote'

export default function Example() {
  const [note, setNote] = useState('Make room for the small ideas.\n\nThey might turn into something.')
  return (
    <div style={{ maxWidth: 300, padding: 20 }}>
      <PostItNote title="Note to self" color="yellow" rotation={-2} editable value={note} onValueChange={setNote} date="2026-11-01" dateLabel="1 Nov 2026" />
    </div>
  )
}
```

## README.md

````text
# Post-it note

An original React paper note by Daniel Paul. Licensed for personal and commercial
use under the included MIT license. The license covers these component files,
not the rest of danielpaul.me.

## Requirements and dependencies

- An existing React 18.2+ or React 19 app with matching react-dom.
- TypeScript and the matching React type definitions.
- CSS Modules support, available in Vite, Next.js, and Astro.
- Motion 14.9.0, the self-hosted Caveat font (5.3.0), and Paper Shaders (0.0.82):

```sh
npm install motion@14.9.0 @fontsource/caveat@5.3.0 @paper-design/shaders@0.0.82
```

Use `pnpm add motion@14.9.0 @fontsource/caveat@5.3.0 @paper-design/shaders@0.0.82` or `yarn add motion@14.9.0 @fontsource/caveat@5.3.0 @paper-design/shaders@0.0.82` for those package managers.
If your app needs TypeScript types, use its package manager to add the dev
dependencies `typescript`, `@types/react`, and `@types/react-dom`, matching
its installed React version. Do not upgrade an existing app's React version
just to install the note.

No Tailwind, shadcn, providers, path aliases, images, icons, sound files, or
site-specific helpers are required. Title and date inherit your font; the body
uses blue Caveat handwriting. PostItNote.tsx imports the font stylesheet, so
installing the declared font package is all that is required. Fonts are bundled
and served by your own app; no Google Fonts request or account is needed.
Paper colors come from the supplied CSS module. The included paper-curl.ts
uses the browser’s Canvas 2D API to draw a single sheet with a rolling corner and a shaded underside,
light, and shadow. paper-texture.ts renders the official Paper Texture shader
from @paper-design/shaders once into a cached bitmap, then releases WebGL. The
curl composites that material inside the single paper surface. No external
image is loaded; the shader’s noise texture is bundled in its npm package.

## Add the files

Copy these files together into `src/components/post-it-note/`, or your
project's equivalent component folder:

- PostItNote.tsx
- PostItNote.module.css
- paper-curl.ts
- paper-geometry.ts
- paper-texture.ts
- motion.ts
- LICENSE.txt

Keep the relative imports intact. Put the included Usage.tsx beside them for
a complete working example:

```tsx
import Example from './components/post-it-note/Usage'

export default function App() {
  return <Example />
}
```

For a read-only note:

```tsx
import { PostItNote } from './components/post-it-note/PostItNote'

<PostItNote title="On my desk" color="yellow" rotation={-2} footerLabel="A small reminder">
  Leave a little room for the unexpected.
</PostItNote>
```

For editing, supply `editable` and either `defaultValue` (internal state) or
`value` plus `onValueChange` (controlled state). The Usage.tsx example shows
the controlled form. A labelled native textarea supports keyboard input,
selection, multiline text, and a character limit. The paper keeps a square
aspect ratio at every width. Longer writing scrolls inside the body while the
title and date stay in place. Soft edge fades appear only when more writing is hidden above or below.
The cues update after typing, scrolling, resizing, or font loading.
Read-only content also scrolls and supports
keyboard focus. Click the paper to focus an editable note; a soft blue wash
highlights the writing area with a quiet focus marker.
Pass `children` for rich read-only content; editable notes use plain text.

The component does not persist edits, call an API, or store anything in the
browser. Connect onValueChange to your application's state, and implement
saving in the parent if required. Announce a saved state only after that
save has actually succeeded.

## Framework setup

**Next.js App Router:** PostItNote.tsx and Usage.tsx include 'use client'.
A server page can render a read-only note with serializable props. Define
onValueChange in a client component for an editable note.

**Vite React TypeScript:** keep the generated vite-env.d.ts for CSS Module
types. No extra CSS configuration is required.

**Astro:** configure @astrojs/react. The HTML and static SVG render on the server. Hydrate with
`<PostItNote client:visible />` or `<Example client:load />` to animate the curl
and enable editing. Without hydration the note remains readable and keeps a
static curled corner.
The redistributed files do not import Astro APIs.

## Props and behavior

- title: optional centered heading at the top, rendered as h2. Omit when your surrounding content
  already supplies a heading.
- children: React content for a read-only note, otherwise value/defaultValue.
- color: yellow (default), rose, blue, or lilac. Paper keeps its pigment in
  light and dark themes; supply the surrounding surface from your app.
- rotation: angle in degrees, default 0. Negative values tilt counterclockwise;
  positive values tilt clockwise. For example, rotation={-2}. Applies to the
  whole sheet and composes with hover lift. Static rotation remains with
  reduced motion; the corner animation and hover lift stop.
- editable: false by default; true enables a scrollable native textarea, a soft editing highlight,
  click-to-focus on the paper, and hover lift.
- breeze: true by default; the Canvas corner gently lifts and settles while the
  text stays still. Set false for a static curl.
- texture: true by default; subtle static grain and fibers from Paper Design.
  Set false for plain paper. If WebGL is unavailable, the curl still works
  without the texture.
- value / onValueChange: controlled text and its update callback.
- defaultValue: initial uncontrolled text, defaults to an empty string.
- placeholder: defaults to 'Something worth keeping…'.
- maxLength: defaults to 500; applies to the editable textarea.
- footerLabel: optional small secondary caption above the centered date.
- date: optional HTML datetime value, paired with a preformatted dateLabel.
- dateLabel: visible date centered at the bottom of the note. Without date, renders text.
- permalink: optional URL on the date label. Use trusted local or http(s)
  URLs; never pass untrusted javascript: URLs.
- className / style: compose layout and custom CSS variables. The note fills
  its container; the supplied example limits that container to 300px.

The component is one sheet, with no stack, adhesive strip, or reserved top
band. The bottom-right quarter of its width is a fixed triangular corner.
paper-geometry.ts bends that section around a cylinder: the attached points
and material length stay constant while the curl angle changes. A shaded mesh
reveals the reverse face as it turns; the left edge stays still. The original
flat corner is removed before the curved surface is drawn, so no second sheet
remains underneath. The Canvas uses device-pixel-ratio scaling capped at 2,
requestAnimationFrame, and the named ambient duration in motion.ts. The
texture shader is static and shared across notes, with no extra WebGL animation
loop. It loads only when a textured note enters view. React
does not re-render each frame. ResizeObserver keeps the artwork aligned as
the container width changes; all writing and metadata remain accessible HTML above it.

Editing pauses the curl. Hidden tabs and off-screen notes pause it too.
Reduced motion or breeze=false renders a static curl. Without JavaScript,
Canvas, or ResizeObserver, an inline SVG keeps the paper readable with a
static curl. Editable notes lift slightly on hover using the included spring
token; useReducedMotion removes that lift without needing a provider.
Focus rings stay visible, and touch text is large enough to avoid iOS zoom.

## License

Keep LICENSE.txt and the source copyright notices when copying or adapting.
Motion retains its own license in the installed npm package. Caveat is
licensed under the SIL Open Font License 1.1; @fontsource/caveat includes its
font license. Paper Shaders retains its Apache-2.0 license and NOTICE in the
installed npm package. Preserve those dependency licenses when redistributing
assets. The original note code is MIT; it does not relicense these dependencies.
````

## LICENSE.txt

```text
MIT License

Copyright (c) 2026 Daniel Paul

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
