Post-it note
Components
For the ideas you want to keep close. Blue handwritten ink, a softly textured sheet, a gently lifting corner, and four colors. Click into the note and make it yours.
The live previews use React. Install includes the selected platform’s native source and setup.
A little reminder
Worth remembering
For another day
Your next idea
Slow down
Less, but better
Playground
Try it your way.
Note to self
A little space to think. Click the paper and start writing.
Installation
ReactThe npm package is being prepared for its first release. For now, download the files and install the dependencies below.
npm install motion@14.9.0 @fontsource/caveat@5.3.0 @paper-design/shaders@0.0.82Usage
'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>
)
}Installation
SwiftUINo packages to install. Add the Swift files to your iOS app target in Xcode.
Usage
import SwiftUI
@available(iOS 18.0, *)
struct NoteExample: View {
@State private var note = "Make room for the small ideas.\n\nThey might turn into something."
var body: some View {
PostItNote(text: $note, title: "Note to self", dateLabel: "1 Nov 2026", color: .yellow, width: 280, rotation: -2, editable: true)
.padding(24)
}
}Installation
React NativeThe npm package is being prepared for its first release. For now, download the files and install the dependencies below.
npx expo install react-native-svg expo-linear-gradient expo-font @expo-google-fonts/caveatUsage
import { useState } from 'react'
import { ActivityIndicator, View, useWindowDimensions } from 'react-native'
import { useFonts, Caveat_500Medium } from '@expo-google-fonts/caveat'
import { PostItNote } from './PostItNote'
export default function Example() {
const { width } = useWindowDimensions()
const [loaded, error] = useFonts({ Caveat_500Medium })
const [note, setNote] = useState('Make room for the small ideas.\n\nThey might turn into something.')
if (error) throw error
if (!loaded) return <ActivityIndicator accessibilityLabel="Loading handwriting font" />
return <View style={{ padding: 24, alignItems: 'center' }}>
<PostItNote title="Note to self" value={note} onValueChange={setNote} width={Math.min(292, width - 48)} rotation={-2} editable dateLabel="1 Nov 2026" />
</View>
}React 18.2+ or React 19 with matching react-dom, TypeScript, and a bundler with CSS Modules support (Vite, Next.js, or Astro).
Installation
ReactThe npm package is being prepared for its first release. For now, download the files and install the dependencies below.
npm install motion@14.9.0 @fontsource/caveat@5.3.0 @paper-design/shaders@0.0.82Usage
'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>
)
}Setup with AI agent
Give your agent this link. It includes every file, dependency, and setup step.
Go to https://danielpaul.me/ui/post-it-note/agent.md for the source, dependencies, and setup instructions, then add the Post-it note component to this project.Source files
Keep these files together and follow the README for your project’s setup.
'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>
)
}/* 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; }
}// 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 }
}// 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(',')})`
}// 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()
}
}// 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'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>
)
}# 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.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.API
| Prop | Default | What it does |
|---|---|---|
title | — | Small centered heading at the top of the note. |
children | — | Read-only content, including React markup. Falls back to value/defaultValue. |
color | yellow | yellow, rose, blue, or lilac. All four keep their paper pigment in either theme. |
rotation | 0 | Angle in degrees for the whole sheet. Negative tilts counterclockwise; positive tilts clockwise. Composes with hover lift and remains static with reduced motion. |
editable | false | Replace the body with a labelled, scrollable textarea. Click the paper to focus it; a soft highlight shows the editing area. |
breeze | true | A fixed-size right corner curls around a curved surface, revealing its underside without stretching. The left edge stays still. Set false for a static curl. |
texture | true | Paper Design’s static grain and fibers, rendered once and clipped to the curled sheet. Falls back to plain paper without WebGL. |
value / onValueChange | — | Controlled text. Connect the callback to your state and persistence. |
defaultValue | '' | Initial text for an uncontrolled note. Edits stay in memory. |
placeholder / maxLength | Something worth keeping… / 500 | Editable field placeholder and character limit. |
footerLabel | — | Optional secondary caption above the date. |
date / dateLabel | — | Centered at the bottom: optional machine-readable date and its display label. A label alone renders plain text. |
permalink | — | Turn the date label into a link, such as an archive anchor. |
className / style | — | Compose with your layout. The note fills its container width and keeps a square aspect ratio. |
The blog uses this exact component for its notes. Notes render readable HTML on the server, then hydrate for the Canvas curl. Text stays accessible and selectable.
The paper stays square; longer text scrolls inside while title and date remain in place. Edge fades show when more content is hidden above or below. Keyboard focus stays visible, and reduced motion stops the corner and hover lift. Editing pauses the corner, as do hidden tabs and off-screen notes. Nothing is stored or sent automatically.
An iOS 18+ SwiftUI app, built with Xcode 16 or newer. Uses SwiftUI, UIKit, and Foundation, with no third-party packages or font files.
Installation
SwiftUINo packages to install. Add the Swift files to your iOS app target in Xcode.
Usage
import SwiftUI
@available(iOS 18.0, *)
struct NoteExample: View {
@State private var note = "Make room for the small ideas.\n\nThey might turn into something."
var body: some View {
PostItNote(text: $note, title: "Note to self", dateLabel: "1 Nov 2026", color: .yellow, width: 280, rotation: -2, editable: true)
.padding(24)
}
}Setup with AI agent
Give your agent this link. It includes every file, dependency, and setup step.
Go to https://danielpaul.me/ui/post-it-note/swiftui/agent.md for the source, dependencies, and setup instructions, then add the Post-it note for SwiftUI component to this project.Source files
Keep these files together and follow the README for your project’s setup.
// SPDX-License-Identifier: MIT — Copyright (c) 2026 Daniel Paul
import SwiftUI
import UIKit
@available(iOS 18.0, *)
struct PostItNote: View {
enum Paper: CaseIterable {
case yellow, rose, blue, lilac
var channels: (Double, Double, Double) {
switch self {
case .yellow: (250, 231, 160)
case .rose: (246, 219, 226)
case .blue: (223, 234, 248)
case .lilac: (233, 223, 245)
}
}
var color: Color { Color(red: channels.0 / 255, green: channels.1 / 255, blue: channels.2 / 255) }
}
@Binding var text: String
var title = ""
var dateLabel = ""
var color: Paper = .yellow
var width: CGFloat = 292
var rotation: Double = 0
var editable = false
var breeze = true
var texture = true
var maxLength = 500
@Environment(\.accessibilityReduceMotion) private var reducedMotion
@Environment(\.scenePhase) private var scenePhase
@State private var editing = false
@State private var moreAbove = false
@State private var moreBelow = false
@State private var startedAt = Date()
@State private var pausedAt: Date?
private var running: Bool { breeze && !reducedMotion && !editing && scenePhase == .active }
private let ink = Color(red: 36/255, green: 78/255, blue: 146/255)
var body: some View {
ZStack {
TimelineView(.animation(minimumInterval: 1/30, paused: !running)) { timeline in
Canvas { context, size in
let phase = (pausedAt ?? timeline.date).timeIntervalSince(startedAt) / 6.5
let swell = (1 - cos(phase * .pi * 2)) / 2
let amount = reducedMotion || !breeze ? 0.3 : 0.12 + 0.8 * swell
let flutter = reducedMotion || !breeze ? 0 : sin(phase * .pi * 6) * pow(swell, 1.5)
let sheet = paperPaths(size: size, amount: amount, flutter: flutter)
context.fill(sheet.front, with: .color(color.color))
for strip in sheet.strips {
let tone = paperTone(angle: strip.angle, back: strip.back)
context.fill(strip.path, with: .color(tone))
context.stroke(strip.path, with: .color(tone), lineWidth: 0.5)
}
context.stroke(sheet.outline, with: .color(.brown.opacity(0.18)), lineWidth: 0.7)
context.clip(to: sheet.outline)
if texture {
for index in 0..<1500 {
let x = CGFloat((index * 193 + 17) % 997) / 997 * size.width
let y = CGFloat((index * 347 + 39) % 991) / 991 * size.height
let dot = CGRect(x: x, y: y, width: 0.65, height: 0.8)
context.fill(Path(ellipseIn: dot), with: .color(.brown.opacity(0.09)))
}
}
}
}
.allowsHitTesting(false)
VStack(spacing: 12) {
if !title.isEmpty {
Text(title).font(.system(size: 12, weight: .semibold)).accessibilityAddTraits(.isHeader)
}
NativeWriting(text: $text, editable: editable, maxLength: maxLength, title: title, editing: $editing, moreAbove: $moreAbove, moreBelow: $moreBelow)
.background(editing ? ink.opacity(0.05) : .clear, in: RoundedRectangle(cornerRadius: 5))
.mask {
GeometryReader { geometry in
let fade = min(0.35, 44 / max(1, geometry.size.height))
LinearGradient(stops: [
.init(color: moreAbove ? .clear : .black, location: 0),
.init(color: .black, location: fade),
.init(color: .black, location: 1 - fade),
.init(color: moreBelow ? .clear : .black, location: 1)
], startPoint: .top, endPoint: .bottom)
}
}
if !dateLabel.isEmpty { Text(dateLabel).font(.system(size: 11)).foregroundStyle(.brown) }
}
.foregroundStyle(Color(red: 69/255, green: 59/255, blue: 29/255))
.padding(.horizontal, width * 0.09)
.padding(.top, width * 0.08)
.padding(.bottom, width * 0.1)
}
.frame(width: width, height: width)
.rotationEffect(.degrees(rotation))
.shadow(color: .black.opacity(0.13), radius: 12, x: 0, y: 10)
.onChange(of: running) { _, isRunning in
if isRunning {
if let pause = pausedAt { startedAt = startedAt.addingTimeInterval(Date().timeIntervalSince(pause)) }
pausedAt = nil
} else { pausedAt = Date() }
}
.onAppear { if !running { pausedAt = Date() } }
}
private func paperTone(angle: Double, back: Bool) -> Color {
let white = back ? 0.32 : max(0, 0.05 * cos(angle - 0.35))
let shade = (1 - abs(cos(angle - 0.4))) * 0.12
func channel(_ base: Double, _ shadow: Double) -> Double {
let lit = base + (255 - base) * white
return (lit + (shadow - lit) * shade) / 255
}
return Color(red: channel(color.channels.0, 66), green: channel(color.channels.1, 48), blue: channel(color.channels.2, 25))
}
private func paperPaths(size: CGSize, amount: Double, flutter: Double) -> (front: Path, outline: Path, strips: [(path: Path, angle: Double, back: Bool)]) {
let w = size.width - 1, h = size.height - 1
let length = min(size.width, size.height) * 0.25
let reach = length / sqrt(2.0)
let turn = 0.65 + amount * 1.85 + flutter * 0.07
let radius = reach / turn
func project(_ t: Double, right: Bool) -> CGPoint {
let distance = reach * t, angle = distance / radius
let travel = radius * sin(angle), z = radius * (1 - cos(angle))
let inset = (travel - distance) / sqrt(2.0)
return CGPoint(x: (right ? w : w - length + length * t) + inset - z * 0.1,
y: (right ? h - length + length * t : h) + inset - z * 0.6)
}
let right = (0...24).map { project(Double($0) / 24, right: true) }
let bottom = (0...24).map { project(Double($0) / 24, right: false) }
func start(_ path: inout Path) {
path.move(to: CGPoint(x: 4, y: 0.5))
path.addLine(to: CGPoint(x: w - 3, y: 0.5))
path.addQuadCurve(to: CGPoint(x: w, y: 4), control: CGPoint(x: w, y: 0.5))
}
func finish(_ path: inout Path) {
path.addLine(to: CGPoint(x: 4, y: h))
path.addQuadCurve(to: CGPoint(x: 0.5, y: h - 3), control: CGPoint(x: 0.5, y: h))
path.addLine(to: CGPoint(x: 0.5, y: 4))
path.addQuadCurve(to: CGPoint(x: 4, y: 0.5), control: CGPoint(x: 0.5, y: 0.5))
path.closeSubpath()
}
var front = Path()
start(&front)
front.addLine(to: right[0])
front.addLine(to: bottom[0])
finish(&front)
var outline = Path()
start(&outline)
for point in right + bottom.reversed() { outline.addLine(to: point) }
finish(&outline)
let strips = (1...24).map { i -> (path: Path, angle: Double, back: Bool) in
var path = Path()
path.move(to: right[i - 1]); path.addLine(to: right[i])
path.addLine(to: bottom[i]); path.addLine(to: bottom[i - 1]); path.closeSubpath()
let angle = turn * (Double(i) - 0.5) / 24
let back = cos(angle) - 0.7 / sqrt(2.0) * sin(angle) < 0
return (path, angle, back)
}
return (front, outline, strips)
}
}
@available(iOS 18.0, *)
private struct NativeWriting: UIViewRepresentable {
@Binding var text: String
let editable: Bool
let maxLength: Int
let title: String
@Binding var editing: Bool
@Binding var moreAbove: Bool
@Binding var moreBelow: Bool
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.delegate = context.coordinator
view.backgroundColor = .clear
view.font = UIFont(name: "ChalkboardSE-Regular", size: 24) ?? .systemFont(ofSize: 24)
view.textColor = UIColor(red: 36/255, green: 78/255, blue: 146/255, alpha: 1)
view.tintColor = view.textColor
view.textAlignment = .center
view.textContainerInset = UIEdgeInsets(top: 12, left: 6, bottom: 12, right: 6)
view.showsVerticalScrollIndicator = false
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view
}
func updateUIView(_ view: UITextView, context: Context) {
context.coordinator.parent = self
if view.text != text { view.text = text }
view.isEditable = editable
view.accessibilityLabel = title.isEmpty ? "Note text" : "\(title) — note text"
DispatchQueue.main.async { [weak view, weak coordinator = context.coordinator] in
if let view { coordinator?.scrollViewDidScroll(view) }
}
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: NativeWriting
init(_ parent: NativeWriting) { self.parent = parent }
func textViewDidBeginEditing(_ textView: UITextView) { parent.editing = true }
func textViewDidEndEditing(_ textView: UITextView) { parent.editing = false }
func textViewDidChange(_ textView: UITextView) { parent.text = textView.text; scrollViewDidScroll(textView) }
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
guard let swiftRange = Range(range, in: textView.text) else { return false }
return textView.text.replacingCharacters(in: swiftRange, with: text).count <= parent.maxLength
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let up = scrollView.contentOffset.y > 2
let down = scrollView.contentSize.height - scrollView.bounds.height - scrollView.contentOffset.y > 2
if parent.moreAbove != up { parent.moreAbove = up }
if parent.moreBelow != down { parent.moreBelow = down }
}
}
}import SwiftUI
@available(iOS 18.0, *)
struct NoteExample: View {
@State private var note = "Make room for the small ideas.\n\nThey might turn into something."
var body: some View {
PostItNote(text: $note, title: "Note to self", dateLabel: "1 Nov 2026", color: .yellow, width: 280, rotation: -2, editable: true)
.padding(24)
}
}# Post-it note for SwiftUI
An original iOS native implementation by Daniel Paul. Free under MIT.
Requires an iOS 18+ SwiftUI app and Xcode 16 or newer. Uses SwiftUI, UIKit, and
Foundation only. No package installation, custom font assets, or API keys.
## Add to an existing project
1. Copy PostItNote.swift and Usage.swift into your Xcode project, enable the
app target's membership for both files, and retain LICENSE.txt.
2. Keep your app's deployment target at iOS 18 or higher. The example and
component are availability annotated so an older app can provide a fallback.
3. Render NoteExample() from an existing screen. Do not add another @main app.
4. For your own screen, pass a Binding<String> into PostItNote(text:). The
example's @State demonstrates local editing. Save from your parent model
if persistence is required; the component performs no network/storage work.
## Usage
```swift
PostItNote(text: $note, title: "Note to self", dateLabel: "1 Nov 2026",
color: .yellow, width: 280, rotation: -2, editable: true)
```
## API and behavior
- text: required Binding<String>.
- title / dateLabel: optional strings; remain fixed while writing scrolls.
- color: .yellow (default), .rose, .blue, or .lilac.
- width: square size in points; default 292. Choose a width that fits your layout.
- rotation: static angle in degrees, default 0; negative tilts counterclockwise.
For example rotation: -2. The tilt remains with Reduce Motion.
- editable: false by default. Uses a selectable UITextView; true enables editing.
- breeze / texture: true by default.
- maxLength: 500 by default; limits user input, not values set by your model.
The blue handwriting uses the built-in iOS Chalkboard SE font, with a system
fallback. Canvas renders the single paper sheet, right-corner curl, and static
grain. The corner has a fixed footprint and material length; only its
cylindrical bend changes. This is a native equivalent of the design, not the browser's WebGL
shader. The left edge stays still. The corner pauses while editing, in the
background, and with Reduce Motion. Longer content scrolls inside; edge fades
show more writing above/below, without arrows or an editing border.
The UIKit bridge handles text editing, scroll measurement, and selection;
SwiftUI owns the square layout and fade mask. No third-party libraries.
Validate VoiceOver, text selection, dynamic type/accessibility requirements,
and animation performance in your target app. The website preview uses React;
run NoteExample in an iOS simulator/device to preview this implementation.
Keep the MIT license and source copyright notice when redistributing.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.An existing Expo React Native app with TypeScript. Keep the React and React Native versions supplied by your Expo SDK. For bare React Native, install Expo modules first or adapt the font/gradient imports to your existing native libraries.
Installation
React NativeThe npm package is being prepared for its first release. For now, download the files and install the dependencies below.
npx expo install react-native-svg expo-linear-gradient expo-font @expo-google-fonts/caveatUsage
import { useState } from 'react'
import { ActivityIndicator, View, useWindowDimensions } from 'react-native'
import { useFonts, Caveat_500Medium } from '@expo-google-fonts/caveat'
import { PostItNote } from './PostItNote'
export default function Example() {
const { width } = useWindowDimensions()
const [loaded, error] = useFonts({ Caveat_500Medium })
const [note, setNote] = useState('Make room for the small ideas.\n\nThey might turn into something.')
if (error) throw error
if (!loaded) return <ActivityIndicator accessibilityLabel="Loading handwriting font" />
return <View style={{ padding: 24, alignItems: 'center' }}>
<PostItNote title="Note to self" value={note} onValueChange={setNote} width={Math.min(292, width - 48)} rotation={-2} editable dateLabel="1 Nov 2026" />
</View>
}Setup with AI agent
Give your agent this link. It includes every file, dependency, and setup step.
Go to https://danielpaul.me/ui/post-it-note/react-native/agent.md for the source, dependencies, and setup instructions, then add the Post-it note for React Native component to this project.Source files
Keep these files together and follow the README for your project’s setup.
// SPDX-License-Identifier: MIT — Copyright (c) 2026 Daniel Paul
import { useEffect, useId, useMemo, useRef, useState, type ComponentRef, type ReactElement } from 'react'
import { AccessibilityInfo, Animated, AppState, Easing, ScrollView, StyleSheet, Text, TextInput, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'
import Svg, { Defs, Path, Pattern, Circle } from 'react-native-svg'
import { LinearGradient } from 'expo-linear-gradient'
import { paperGeometry, paperStripColor } from './paper-geometry'
export type NoteColor = 'yellow' | 'rose' | 'blue' | 'lilac'
export interface PostItNoteProps {
title?: string
value: string
onValueChange?: (text: string) => void
color?: NoteColor
width?: number
rotation?: number
editable?: boolean
breeze?: boolean
texture?: boolean
dateLabel?: string
maxLength?: number
fontFamily?: string
}
const papers = { yellow: '#fae7a0', rose: '#f6dbe2', blue: '#dfeaf8', lilac: '#e9dff5' }
const AnimatedPath = Animated.createAnimatedComponent(Path)
function paperPaths(width: number, phase: number, paper: string) {
const swell = (1 - Math.cos(phase * Math.PI * 2)) / 2
const amount = 0.12 + 0.8 * swell
const flutter = Math.sin(phase * Math.PI * 6) * Math.pow(swell, 1.5)
const { w, h, right, bottom, strips } = paperGeometry(width, width, amount, flutter)
const points = (items: { x: number; y: number }[]) => items.map((p) => `${p.x} ${p.y}`).join('L')
const start = `M4 .5H${w - 3}Q${w} .5 ${w} 4L`
const end = `H4Q.5 ${h} .5 ${h - 3}V4Q.5 .5 4 .5Z`
return {
front: `${start}${points([right[0], bottom[0]])}${end}`,
outline: `${start}${points([...right, ...bottom.slice().reverse()])}${end}`,
strips: strips.map((strip) => ({ d: `M${points(strip.points)}Z`, color: paperStripColor(paper, strip.angle, strip.back) })),
}
}
/** Expo native version. Load Caveat once in your app; see Usage.tsx. */
export function PostItNote({ title, value, onValueChange, color = 'yellow', width = 292, rotation = 0, editable = false, breeze = true, texture = true, dateLabel, maxLength = 500, fontFamily = 'Caveat_500Medium' }: PostItNoteProps): ReactElement {
const progress = useRef(new Animated.Value(.2)).current
const field = useRef<ComponentRef<typeof TextInput>>(null)
const [focused, setFocused] = useState(false)
const [reduced, setReduced] = useState(true)
const [active, setActive] = useState(AppState.currentState === 'active')
const [viewportHeight, setViewportHeight] = useState(0)
const [contentHeight, setContentHeight] = useState(0)
const [offset, setOffset] = useState(0)
const id = useId().replace(/[^a-z0-9]/gi, '')
const paper = papers[color]
const padding = width * .09
const paths = useMemo(() => {
const inputRange = Array.from({ length: 65 }, (_, i) => i / 64)
const samples = inputRange.map((phase) => paperPaths(width, phase, paper))
return {
front: progress.interpolate({ inputRange, outputRange: samples.map((sample) => sample.front) }),
outline: progress.interpolate({ inputRange, outputRange: samples.map((sample) => sample.outline) }),
strips: samples[0].strips.map((_, i) => ({
d: progress.interpolate({ inputRange, outputRange: samples.map((sample) => sample.strips[i].d) }),
color: progress.interpolate({ inputRange, outputRange: samples.map((sample) => sample.strips[i].color) }),
})),
}
}, [paper, progress, width])
useEffect(() => {
let mounted = true
void AccessibilityInfo.isReduceMotionEnabled().then((value) => { if (mounted) setReduced(value) })
const motion = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduced)
const app = AppState.addEventListener('change', (value) => setActive(value === 'active'))
return () => { mounted = false; motion.remove(); app.remove() }
}, [])
useEffect(() => {
if (!breeze || reduced) { progress.setValue(.2); return }
if (!active || focused) return
const animation = Animated.loop(Animated.sequence([
Animated.timing(progress, { toValue: 1, duration: 3250, easing: Easing.linear, useNativeDriver: false, isInteraction: false }),
Animated.timing(progress, { toValue: 0, duration: 3250, easing: Easing.linear, useNativeDriver: false, isInteraction: false }),
]))
animation.start()
return () => animation.stop()
}, [active, breeze, focused, progress, reduced])
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => setOffset(event.nativeEvent.contentOffset.y)
const moreAbove = offset > 2
const moreBelow = contentHeight - viewportHeight - offset > 2
const writing = { fontFamily, color: '#244e92', fontSize: 24, lineHeight: 29, textAlign: 'center' as const }
return <View style={[styles.note, { width, height: width, transform: [{ rotate: `${rotation}deg` }] }]}>
<Svg width={width} height={width} style={StyleSheet.absoluteFill} pointerEvents="none">
<Defs><Pattern id={`grain${id}`} width="40" height="40" patternUnits="userSpaceOnUse">{Array.from({ length: 45 }, (_, i) => <Circle key={i} cx={(i * 17.31) % 40} cy={(i * 23.17) % 40} r="0.42" fill="#756335" opacity="0.13" />)}</Pattern></Defs>
<AnimatedPath d={paths.front} fill={paper} />
{paths.strips.map((strip, i) => <AnimatedPath key={i} d={strip.d} fill={strip.color} stroke={strip.color} strokeWidth="0.5" />)}
{texture && <AnimatedPath d={paths.outline} fill={`url(#grain${id})`} />}
<AnimatedPath d={paths.outline} fill="none" stroke="#756335" strokeOpacity="0.18" strokeWidth="0.7" />
</Svg>
<View style={[styles.content, { paddingHorizontal: padding, paddingTop: width * .08, paddingBottom: width * .1 }]}>
{title && <Text accessibilityRole="header" style={styles.title} onPress={() => editable && field.current?.focus()}>{title}</Text>}
<View style={styles.writing} onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}>
{editable ? <TextInput ref={field} accessibilityLabel={title ? `${title} — note text` : 'Note text'} value={value} onChangeText={onValueChange} multiline scrollEnabled maxLength={maxLength} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onContentSizeChange={(event) => setContentHeight(event.nativeEvent.contentSize.height)} onScroll={onScroll} textAlignVertical="top" underlineColorAndroid="transparent" style={[styles.input, writing, focused && styles.highlight]} /> : <ScrollView onScroll={onScroll} scrollEventThrottle={32} onContentSizeChange={(_, height) => setContentHeight(height)} showsVerticalScrollIndicator={false} nestedScrollEnabled contentContainerStyle={styles.readOnlyContent}><Text selectable style={writing}>{value}</Text></ScrollView>}
{moreAbove && <LinearGradient pointerEvents="none" colors={[paper, `${paper}00`]} style={styles.fadeTop} />}
{moreBelow && <LinearGradient pointerEvents="none" colors={[`${paper}00`, paper]} style={styles.fadeBottom} />}
</View>
{dateLabel && <Text style={styles.date}>{dateLabel}</Text>}
</View>
</View>
}
const styles = StyleSheet.create({
note: { shadowColor: '#453b1d', shadowOffset: { width: 0, height: 10 }, shadowOpacity: .16, shadowRadius: 12 },
content: { flex: 1, gap: 12 },
title: { fontSize: 12, lineHeight: 17, fontWeight: '600', color: '#453b1d', textAlign: 'center' },
writing: { flex: 1, minHeight: 0 },
fadeTop: { position: 'absolute', top: 0, left: 0, right: 0, height: 44 },
fadeBottom: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 44 },
input: { flex: 1, paddingVertical: 12, paddingHorizontal: 6, borderRadius: 5 },
highlight: { backgroundColor: 'rgba(36,78,146,0.05)' },
readOnlyContent: { flexGrow: 1, justifyContent: 'center', paddingVertical: 8 },
date: { color: '#756335', fontSize: 11, lineHeight: 15, textAlign: 'center' },
})// 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(',')})`
}import { useState } from 'react'
import { ActivityIndicator, View, useWindowDimensions } from 'react-native'
import { useFonts, Caveat_500Medium } from '@expo-google-fonts/caveat'
import { PostItNote } from './PostItNote'
export default function Example() {
const { width } = useWindowDimensions()
const [loaded, error] = useFonts({ Caveat_500Medium })
const [note, setNote] = useState('Make room for the small ideas.\n\nThey might turn into something.')
if (error) throw error
if (!loaded) return <ActivityIndicator accessibilityLabel="Loading handwriting font" />
return <View style={{ padding: 24, alignItems: 'center' }}>
<PostItNote title="Note to self" value={note} onValueChange={setNote} width={Math.min(292, width - 48)} rotation={-2} editable dateLabel="1 Nov 2026" />
</View>
}# Post-it note for React Native
An original native implementation by Daniel Paul. MIT. Built for an existing
Expo app with TypeScript, using your Expo SDK's matching React Native version.
## Install
```sh
npx expo install react-native-svg expo-linear-gradient expo-font @expo-google-fonts/caveat
```
Expo resolves versions compatible with your SDK. Do not replace the app's
React or React Native version. For bare React Native, configure Expo modules
first (https://docs.expo.dev/bare/installing-expo-modules/) and rebuild the
native app after installing the native dependencies. Follow your existing
package manager and CocoaPods workflow. No web CSS or Paper Design shader is
required; this version uses SVG grain and an animated native SVG outline.
Copy PostItNote.tsx, paper-geometry.ts, Usage.tsx, and LICENSE.txt together into your component
folder. Render Example from Usage.tsx on an existing screen. The example loads
Caveat_500Medium from the font package before displaying the note; move that
font loading to your app root when using multiple notes. The font is bundled
by Metro. No runtime request to Google Fonts is made.
## API
- value: required string; supply onValueChange when editable.
- title and dateLabel: optional fixed text above and below the writing.
- color: yellow, rose, blue, or lilac; default yellow.
- width: square size in logical pixels; default 292. Choose a size that fits
the parent (for example Math.min(292, availableWidth)).
- rotation: static angle in degrees, default 0; negative tilts counterclockwise.
For example rotation={-2}. The tilt remains with reduced motion.
- editable: false by default. Uses a scrollable native multiline TextInput.
- breeze and texture: true by default. Right edge only; the left stays still.
- maxLength: 500 by default.
- fontFamily: Caveat_500Medium by default; override if your app has a font.
Long notes scroll inside the paper, with fades when content is hidden above
or below. No arrow buttons. The note stays square and title/date stay fixed.
Editing, reduced motion, and backgrounded apps pause the corner. In a long
virtualized list, also set breeze=false for rows your app marks off screen.
The animation uses the JS driver for the SVG path. Keep large lists virtualized.
Changes are local until your parent saves value through onValueChange.
This source targets iOS/Android through Expo, not React Native Web. A fixed triangular corner bends around a cylinder without changing its material
length. paper-geometry.ts is shared with the React version. Native
SVG grain is an approximation of the website's WebGL paper shader. Validate
keyboard, font loading, scroll behavior, and performance on your target devices.
Keep LICENSE.txt. Dependencies retain their respective licenses. Caveat is
SIL Open Font License 1.1; preserve the font package's bundled license when
redistributing. Docs: https://docs.expo.dev/versions/latest/sdk/svg/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.Free to use. License is MIT.