Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/sticker-wall
2
For dark mode, add the dark class to your <html> element:
Optional
<html class="dark">
Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for one inside Claude Code, Codex, or Cursor and it installs the component you pick. Works with any AI Canvas account, free or premium.
Sticker Wall is a draggable note-and-emoji surface backed by Matter.js, so cards collide, pile up, and settle with real 2D physics rather than scripted ease curves. Drop a note, throw an emoji, give it a flick, and watch it tumble and stack against whatever is already there. Motion drives the grab handles; Matter.js owns the physics simulation. It is a playful canvas for brainstorms, mood boards, retros, and standout empty states where you want users to interact rather than just read.
Built with
MotionMatter.jsCanvasTailwind CSS
Frequently asked questions
Is Sticker Wall free to use?
Yes. Sticker Wall is part of the free AI Canvas library and is open source under the MIT license, so you can use it in personal and commercial projects.
How do I install Sticker Wall?
One command: npx shadcn@latest add https://aicanvas.me/r/sticker-wall.json, free with a free AI Canvas account. Or connect the AI Canvas MCP server and ask your AI editor to install Sticker Wall for you. You can also copy the source straight from the Code tab, no account needed. It works in any React project with Tailwind CSS.
What is Sticker Wall built with?
Sticker Wall is built with React and TypeScript, using Motion, Matter.js, Canvas, and Tailwind CSS. It ships with both light and dark styling.
Where would I use Sticker Wall?
Common uses include Brainstorm, Mood board, and Empty state. Like every AI Canvas component, it is self-contained and drops into any React project.
Can I remix Sticker Wall with AI?
Yes. Sticker Wall ships with one comprehensive AI prompt written against the real source code. Open "Remix with AI" on this page to read and copy it into Claude, Cursor, ChatGPT, or any AI tool. Prompts are for remixing your own variation; for the exact component, install it with the one-command CLI.
One comprehensive prompt, written against the real source code. Works in Claude, Cursor, ChatGPT, or any AI tool you use.
This prompt is for remixing. Use it to build your own variation of Sticker Wall. Results depend on the model you use, and no prompt in the world is 100% exact.
Want the exact component?
One command installs it, pixel-perfect. Copy, paste into your project, done.
npx shadcn@latest add @aicanvas/sticker-wall
AI prompt for Sticker Wall
Before building, verify your project has the following setup:
- React / Next.js with TypeScript (strict)
- Tailwind CSS v4 (configured via CSS `@theme inline`, NOT `tailwind.config.ts`)
If any are missing, set up via the shadcn CLI:
npx shadcn@latest init
---
Create a React client component named `StickerWall` — a full-viewport physical feedback wall where text cards and emoji stickers pile up, collide, and can be dragged/tossed with real physics. A centered input lets visitors submit a note that falls in from above and joins the pile.
Write this as a single self-contained 'use client' TypeScript file. Inline everything, no helper files. Default export `StickerWall`. No 'any' types.
## Dependencies
```
npm install matter-js framer-motion
npm install -D @types/matter-js
```
- Tailwind CSS v4 (CSS-based @theme inline, NOT v3 tailwind.config.ts)
- Must dynamic-import matter-js inside an effect so SSR doesn't break.
## Tuning constants (exact values)
```ts
const GRAVITY_SCALE = 0.0012
const RESTITUTION = 0.05
const FRICTION = 0.6
const FRICTION_AIR = 0.02
const DENSITY = 0.0015
const STICKER_CAP = 60
const FADE_MS = 250
const WALL_THICKNESS = 60
const TEXT_FONT_PX = 15
const TEXT_MAX_WIDTH = 180
const TEXT_PAD_X = 14
const TEXT_PAD_Y = 10
const TEXT_LINE_H = 20
const EMOJI_SIZE = 72
const EMOJI_FONT_PX = 42
const CARD_RADIUS = 32
const BORDER_WIDTH = 2
```
## Palettes (exact)
```ts
const PALETTE_DARK = ['#FDE68A','#BBF7D0','#FBCFE8','#C7D2FE','#BAE6FD','#FED7AA']
const PALETTE_LIGHT = ['#F59E0B','#34D399','#F472B6','#A78BFA','#38BDF8','#FB923C']
const STICKER_TEXT_COLOR_DARK = '#111827' // used when isDark === true
const STICKER_TEXT_COLOR_LIGHT = '#FFFFFF' // used when isDark === false
const BG_DARK = '#0F0F12'
const BG_LIGHT = '#F5F1E8'
```
## Seed content
10 text quotes:
`['love the new layout','prompts are 🔥','found a tiny bug on hover','please add a search','this saved me hours','fonts feel just right','mobile nav could be bigger','the physics here rules','more components please','onboarding was smooth']`
12 emoji stickers:
`['👏','💡','🙌','👀','💬','✅','🔥','💯','🎉','❤️','🤔','⭐']`
## Types
```ts
type StickerKind = 'text' | 'emoji'
interface Sticker {
body: Body
kind: StickerKind
content: string
w: number
h: number
color: string
lines: string[] // pre-wrapped for text; [] for emoji
createdAt: number
fadeStart?: number
}
type BodyWithPlugin = Body & { plugin: { sticker?: Sticker } }
```
## Theme detection
Inline a `useIsomorphicLayoutEffect` (= `useLayoutEffect` on the client, `useEffect` on the server).
- `isDark` state seeded during render: `document.documentElement.classList.contains('dark')` (guarded by `typeof window`).
- Effect: install a `MutationObserver` on `document.documentElement` watching `attributeFilter: ['class']`. On each change: `setIsDark(document.documentElement.classList.contains('dark'))`. Disconnect on cleanup.
- Keep an `isDarkRef = useRef(isDark)` kept in sync in a separate `useIsomorphicLayoutEffect([isDark])`. The canvas render loop reads `isDarkRef.current` so it never captures stale closure values.
- When `isDark` changes, re-assign each existing sticker's `color` field from the new palette (via a `useEffect([isDark])`). Keep `paletteRef` in sync too.
## Root layout
- Outer wrapper: `flex h-full w-full items-center justify-center` with inline `style={{ background: bg }}`. The component fills its parent — the parent must define height (e.g. `h-screen` or `h-[480px]`). Do NOT use `min-h-screen`: the matter-js floor wall sits at the canvas bottom edge; a taller-than-visible canvas pushes bodies below the fold.
- Inside: a `motion.div` (containerRef) `relative h-full w-full overflow-hidden` with `style={{ background: bg, touchAction: 'none' }}`. Mount animation: `initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.6, ease: 'easeOut' }}`.
- Children: `<canvas>` absolute inset-0 (display block, 100% w/h) + `<form>` overlay.
## Form overlay
`pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-5`, paddingLeft/Right 16, paddingBottom '18vh'.
### Header (non-interactive, pointer-events-none, flex-col items-center gap-2 text-center)
- h2 "Feedback Wall": `color: titleColor`, `fontSize: clamp(32px, 6vw, 56px)`, fontWeight 800, letterSpacing -0.02em, lineHeight 1, margin 0.
- `titleColor` = `rgba(255,255,255,0.95)` dark / `rgba(17,24,39,0.95)` light.
- `textShadow` = `0 2px 20px rgba(0,0,0,0.4)` dark / `0 2px 20px rgba(17,24,39,0.08)` light.
- p subtitle "Drop a note, toss an emoji, drag anything around. Real physics, no rules — just leave your mark on the wall.":
- `color: subtitleColor` = `rgba(255,255,255,0.7)` / `rgba(17,24,39,0.7)`. 16px, weight 500, letterSpacing -0.005em, lineHeight 1.45, maxWidth 46ch.
### Input pill (pointer-events-auto)
`flex w-full max-w-md items-center gap-2 rounded-full p-1` class `sticker-wall-pill`.
- `background`: `rgba(0,0,0,0.9)` dark / `rgba(255,255,255,0.9)` light.
- `border`: 2px solid `rgba(255,255,255,0.7)` dark / `rgba(17,24,39,0.12)` light.
- `boxShadow`: `0 6px 14px rgba(0,0,0,0.25), 0 2px 0 rgba(0,0,0,0.08)` dark / `0 6px 14px rgba(17,24,39,0.12), 0 2px 0 rgba(17,24,39,0.04)` light.
CSS class rules (emit via a scoped `<style>` block — needed for pseudo-states):
```css
.sticker-wall-pill {
transition: background 180ms ease, border-color 180ms ease,
transform 180ms ease, box-shadow 180ms ease;
}
.sticker-wall-pill:hover {
background: <pillHoverBg> !important; /* rgba(0,0,0,0.95) dark / rgba(255,255,255,0.95) light */
border-color: <pillHoverBorder> !important; /* rgba(255,255,255,0.85) / rgba(17,24,39,0.6) */
transform: translateY(-1px);
}
.sticker-wall-pill:focus-within {
background: <pillFocusBg> !important; /* rgba(0,0,0,1) / rgba(255,255,255,1) */
border-color: <pillFocusBorder> !important; /* #FFFFFF / #111827 */
transform: translateY(-1px) scale(1.015);
box-shadow: <pillFocusShadow> !important;
/* dark: 0 10px 24px rgba(0,0,0,0.3), 0 0 0 4px rgba(255,255,255,0.12) */
/* light: 0 10px 24px rgba(17,24,39,0.15), 0 0 0 4px rgba(17,24,39,0.08) */
}
.sticker-wall-pill:active { transform: translateY(0) scale(0.99); }
```
Inside the pill:
- `<input type="text" maxLength={80} placeholder="Leave feedback…">` — flex-1, transparent bg, px-4 py-2, text-sm (14px), weight 500, outline-none.
- `color`: `rgba(255,255,255,0.95)` dark / `rgba(17,24,39,0.95)` light.
- Placeholder color via `.sticker-wall-input::placeholder { color: <placeholderColor>; }` in the scoped style block.
- `placeholderColor`: `rgba(255,255,255,0.55)` dark / `rgba(17,24,39,0.5)` light.
### Send button
`<button type="submit">` — class `sticker-wall-send`, `flex items-center rounded-2xl px-5 py-2 text-sm tracking-wide`.
- `background`: `#8A9CF4` (periwinkle, same in both themes).
- `color` (text): `#111827` dark / `#FFFFFF` light.
- `border`: 1px solid rgba(255,255,255,0.12).
- No base box-shadow on the button itself.
CSS for the Send button (in the same scoped `<style>` block):
```css
.sticker-wall-send {
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
cursor: pointer;
}
.sticker-wall-send:hover {
transform: translateY(-1px);
filter: brightness(1.15);
}
.sticker-wall-send:active {
transform: translateY(3px);
filter: brightness(0.95);
}
```
## Physics engine (matter-js, dynamic-imported)
Boot sequence inside a single `useEffect([], [])` — dynamic-import matter-js, then:
1. Create engine: `Matter.Engine.create({ gravity: { x: 0, y: 1, scale: GRAVITY_SCALE } })`; set `engine.timing.timeScale = 0.6`.
2. `runner = Matter.Runner.create(); Matter.Runner.run(runner, engine)`.
3. Call `resize()` (sets up canvas size) THEN create mouse:
`mouse = Matter.Mouse.create(canvas); mouse.pixelRatio = dpr;`
4. MouseConstraint: `Matter.MouseConstraint.create(engine, { mouse, constraint: { stiffness: 0.2, damping: 0.1, render: { visible: false } } })`. Add to world.
5. Call `seed()` to populate initial stickers.
6. Install `ResizeObserver` on the container.
7. Start the `requestAnimationFrame` loop.
### Walls (invisible static bodies, 60px thick)
Four rectangles: top (above canvas), bottom (floor), left, right. Each extends ±WALL_THICKNESS on the cross-axis so corners overlap. Rebuild on every resize — remove old walls, create new ones.
### resize()
- Read `container.clientWidth / clientHeight` (fallback 480×480).
- Update `dpr = Math.min(devicePixelRatio || 1, 2)`.
- Set `canvas.width = round(w * dpr); canvas.height = round(h * dpr);` + `canvas.style.width/height`; reset transform `ctx.setTransform(dpr,0,0,dpr,0,0)`.
- Rebuild walls. Re-clamp out-of-bounds bodies to x ∈ [20, w-20], y ≤ h-20.
- `mouse.pixelRatio = dpr` (re-apply after resize so drag stays aligned on HiDPI).
- Store `sizeRef.current = { w, h }`.
### Seed algorithm
For each text quote `i`: color = `palette[i % palette.length]`, x = rand(100, max(120, w-100)), y = rand(80, max(120, h-120)), angular velocity ∈ [-0.05, 0.05], linear velocity x/y ∈ [-0.5, 0.5].
For each emoji `i`: color = `palette[(i+3) % palette.length]`, x = rand(80, max(100, w-80)), y same range, same velocities.
### Sticker cap & fade
When `stickersRef.current.length > STICKER_CAP`, find the first sticker with no `fadeStart` and set `fadeStart = performance.now()`. In the render loop, on each frame scan stickers in reverse: if `now - fadeStart >= FADE_MS`, call `Composite.remove(world, body)` and `splice` from the array.
### Word-wrap helper
`function wrapText(ctx, text, maxWidth)`: split on whitespace, greedy fit via `ctx.measureText`. If a single word exceeds maxWidth, push it as its own line. Returns `string[]`.
### measureTextCard
Set `ctx.font = '600 15px ui-sans-serif, system-ui, -apple-system, Segoe UI, Manrope, sans-serif'`. Compute lines via wrapText. `w = max(70, round(longestLine + TEXT_PAD_X * 2))`; `h = max(40, round(lineCount * TEXT_LINE_H + TEXT_PAD_Y * 2))`.
## Custom 2D canvas renderer (no Matter.Render)
Each frame via rAF:
1. `ctx.setTransform(dpr,0,0,dpr,0,0)`.
2. Fill canvas with `BG_DARK` or `BG_LIGHT` (from `isDarkRef.current`) — full clear, no trails.
3. Scan stickers in reverse for fade pruning (see Sticker cap section above).
4. For each sticker:
- `alpha = fadeStart ? max(0, 1 - dt/FADE_MS) : 1`.
- `ctx.save(); ctx.globalAlpha = alpha; ctx.translate(body.position.x, body.position.y); ctx.rotate(body.angle);`
- **Card fill** (no drop shadow): `ctx.fillStyle = sticker.color`, draw `roundedRect(ctx, -w/2, -h/2, w, h, CARD_RADIUS)`, then `ctx.fill()`.
- **Inner border**: `ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = BORDER_WIDTH`. Draw roundedRect inset by `BORDER_WIDTH` on each side with radius `max(1, CARD_RADIUS - BORDER_WIDTH)`, then `ctx.stroke()`.
- **Content**:
- text: `ctx.fillStyle = isDarkRef.current ? STICKER_TEXT_COLOR_DARK : STICKER_TEXT_COLOR_LIGHT`. Font 600 15px (same family). textAlign center, textBaseline middle. Draw pre-wrapped lines vertically centered (totalH = lineCount * TEXT_LINE_H; startY = -totalH/2 + TEXT_LINE_H/2).
- emoji: font `'42px ui-sans-serif, system-ui, -apple-system, Segoe UI, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'`. textAlign center, textBaseline middle. Draw at (0, 2).
- `ctx.restore()`.
`roundedRect` helper — draws a rounded rectangle path (no fill/stroke):
```ts
function roundedRect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.arcTo(x + w, y, x + w, y + h, rr)
ctx.arcTo(x + w, y + h, x, y + h, rr)
ctx.arcTo(x, y + h, x, y, rr)
ctx.arcTo(x, y, x + w, y, rr)
ctx.closePath()
}
```
## Submit handler (form onSubmit)
- preventDefault. Trim input; if empty, return without clearing.
- Use `measureTextCard` on the shared canvas ctx to compute lines, w, h.
- Pick a random color from `paletteRef.current`. x = rand(80, max(100, W-80)), y = -30.
- Build body with the same physics opts + random angle ∈ [-0.25, 0.25]. Set angular velocity ∈ [-0.03, 0.03] and velocity x ∈ [-0.3, 0.3], y = 0.
- Attach `(body as BodyWithPlugin).plugin = { sticker }`, add to world, push to stickersRef.
- If array exceeds STICKER_CAP, fade the oldest non-fading sticker.
- Clear `input.value`.
## Cleanup (effect return)
- Set `alive = false`.
- `cancelAnimationFrame(rafId)`.
- `ro.disconnect()` (ResizeObserver).
- `Matter.Runner.stop(runner); Matter.Composite.clear(world, false, true); Matter.Engine.clear(engine)`.
- Null out `matterRef`, `engineRef`, `worldRef`, `measureCtxRef`; reset `stickersRef.current = []`.
## Mobile + a11y
- `touchAction: 'none'` on the motion wrapper — prevents page scroll on touch-drag.
- matter-js Mouse handles touch events natively. No hover-only affordances.
- Input pill sits in the lower third of the viewport (paddingBottom 18vh) — reachable by thumbs.
- Canvas is DPR-aware via ResizeObserver; never hardcoded dimensions.
## Typography
- Font family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Manrope, sans-serif.
- Weights: 800 (title), 700 (Send), 600 (sticker text), 500 (subtitle, input), 400 (body).
- Sizes: title clamp(32–56px), subtitle 16px, input 14px, sticker text 15px, emoji 42px.