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.
Peel to Scan is a portrait card whose bottom-right corner peels back when you tap it, revealing a scannable Wi-Fi QR code underneath. The peel uses Motion to drive both the rotation of the curl and the shadow that follows it, so the gesture feels like real paper rather than a transform. Tap again to seal it. It is a small but memorable detail for event signage, hotel marketing pages, onboarding flows that need to surface a secondary asset, and anywhere a QR code deserves a little theater.
Built with
MotionTailwind CSS
Frequently asked questions
Is Peel to Scan free to use?
Yes. Peel to Scan 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 Peel to Scan?
One command: npx shadcn@latest add https://aicanvas.me/r/peel-corner-reveal.json, free with a free AI Canvas account. Or connect the AI Canvas MCP server and ask your AI editor to install Peel to Scan 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 Peel to Scan built with?
Peel to Scan is built with React and TypeScript, using Motion and Tailwind CSS. It ships with both light and dark styling.
Where would I use Peel to Scan?
Common uses include Marketing site, Onboarding, and Event. Like every AI Canvas component, it is self-contained and drops into any React project.
Can I remix Peel to Scan with AI?
Yes. Peel to Scan 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 Peel to Scan. 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.
Before writing code: verify this project has Tailwind CSS v4, TypeScript, and React set up. If any are missing, run the shadcn CLI to scaffold them (`npx shadcn@latest init`). This component relies on Tailwind utilities for layout and inline SVG styles for the theme-aware palette.
A single-file React client component called `PeelCornerReveal`. It renders a tall portrait card with the BR (bottom-right) corner folded up as a green triangular flap, revealing a scannable Wi-Fi QR code underneath. The peel is driven entirely by a spring and responds to hover (peek) and tap/click/keyboard (toggle fully open).
Deps: `framer-motion` and `qrcode.react`. Add `// npm install framer-motion qrcode.react` right after `'use client'`.
# File & export
- Path: `components-workspace/peel-corner-reveal/index.tsx`
- `'use client'` at the very top
- `export default function PeelCornerReveal()`
# Theme detection (inline hook — no external imports)
Define an inline `useTheme` hook that takes a `ref: RefObject<HTMLElement | null>` parameter, returns `'light' | 'dark'`, and reacts to theme changes. It checks both a `[data-card-theme]` parent element (for preview embedding) and `document.documentElement`. A single `MutationObserver` watches both targets:
```tsx
function useTheme(ref: RefObject<HTMLElement | null>): 'light' | 'dark' {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
if (typeof document === 'undefined') return
const el = ref.current
const update = () => {
const card = el?.closest('[data-card-theme]') ?? null
const dark = card
? card.classList.contains('dark')
: document.documentElement.classList.contains('dark')
setTheme(dark ? 'dark' : 'light')
}
update()
const observer = new MutationObserver(update)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
const cardWrapper = el?.closest('[data-card-theme]')
if (cardWrapper) {
observer.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] })
}
return () => observer.disconnect()
}, [ref])
return theme
}
```
Do NOT import `useTheme` from any external path. The hook must live in the same file as the component. Call it as `const theme = useTheme(containerRef)` where `containerRef` is the `useRef<HTMLDivElement>(null)` attached to the outer wrapper div.
# Palette
Module-level constants (do not change with theme):
- `PEEL_FILL = '#1A9D51'` — flat green face of the flap
- `PEEL_FILL_DEEP = '#127A3D'` — darker band along the fold (gradient stop 0%)
- `PEEL_INK = '#FFFFFF'`
Theme-aware values derived inside the component from `const theme = useTheme(containerRef)`:
- `PAGE_BG` = dark? `'#2E2E2C'` : `'#D0CCC4'`
- `CARD_FILL` = dark? `'#FFFFFF'` : `'#121212'` (card inverts: white in dark mode, near-black in light mode)
- `CARD_INK` = dark? `'#0A0A0A'` : `'#F5F5F0'` (ink always reads against the card)
- `FOLD_STROKE` = dark? `'rgba(0,0,0,0.28)'` : `'rgba(255,255,255,0.22)'` — thin highlight line along the fold
- `DIVIDER_STROKE` = dark? `CARD_INK` : `'#FFFFFF'` — always rendered at `opacity={0.15}`
- `DROP_SHADOW` = dark? `'4px 4px 24px rgba(0,0,0,0.55)'` : `'4px 4px 24px rgba(20,15,10,0.28)'`
# Layout & geometry constants (all SVG units)
```
VB_W = 500 // viewBox width
VB_H = 620 // viewBox height
CARD_W = 320
CARD_H = 440
CARD_X = 90 // top-left x of card within the viewBox
CARD_Y = 70 // top-left y of card within the viewBox
CARD_RADIUS = 12 // corner radius for TL, TR, BL corners (BR is carved by the peel — no arc)
```
Derived corners: `TL = {CARD_X, CARD_Y}`, `TR = {CARD_X+CARD_W, CARD_Y}`, `BR = {CARD_X+CARD_W, CARD_Y+CARD_H}`, `BL = {CARD_X, CARD_Y+CARD_H}`.
Peel progress endpoints (percent of card dimension):
```
REST_W_PCT = 0.22 REST_H_PCT = 0.18
OPEN_W_PCT = 0.78 OPEN_H_PCT = 0.9
```
Idle bob amplitude: `BOB_AMPLITUDE = 2.4` (SVG units of vertical travel).
# Root wrapper
A `<div ref={containerRef}>` with Tailwind classes `relative flex min-h-screen w-full items-center justify-center overflow-hidden px-6 py-10` and an inline `style={{ background: PAGE_BG }}`.
Inside it, a `motion.div` that wraps the SVG:
- `role="button"`, `tabIndex={0}`, `aria-pressed={isOpen}`, `aria-label` reads "Show Wi-Fi credentials" / "Hide Wi-Fi credentials"
- `onTap` toggles `isOpen`; `onKeyDown` toggles on Enter or Space (preventDefault)
- `onPointerEnter/Leave` set `isHovered`
- `whileHover={{ scale: 1.015 }}`, `transition={{ type: 'spring', stiffness: 260, damping: 22 }}`
- Tailwind: `relative w-full max-w-[440px] cursor-pointer select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1A9D51] focus-visible:ring-offset-4 focus-visible:ring-offset-transparent rounded-[20px]`
- `style={{ y: bobY, filter: \`drop-shadow(${DROP_SHADOW})\` }}` — idle bob MotionValue plus theme-aware CSS drop-shadow
# Interaction → progress
Keep a plain `target = useMotionValue(0)`. In a `useEffect([isOpen, isHovered])`:
- if `isOpen` → `target.set(1)`
- else if `isHovered` → `target.set(0.18)` (small peek)
- else → `target.set(0)`
Then `const progress = useSpring(target, { stiffness: 170, damping: 22, mass: 0.9 })`. This single 0..1 MotionValue drives every animated SVG attribute.
# Fold math
From `progress`:
- `w = useTransform(progress, [0,1], [REST_W_PCT*CARD_W, OPEN_W_PCT*CARD_W])`
- `h = useTransform(progress, [0,1], [REST_H_PCT*CARD_H, OPEN_H_PCT*CARD_H])`
Fold endpoints A (on bottom edge) and B (on right edge) of the card:
- `Ax = useTransform(w, v => BR.x - v)`, `Ay = useMotionValue(BR.y)`
- `Bx = useMotionValue(BR.x)`, `By = useTransform(h, v => BR.y - v)`
C is the reflection of BR across line A–B. Derive it via the standard reflection-of-point-across-line formula, computed inside `useTransform([Ax, By], ...)`:
```
dx = BR.x - ax; dy = by - BR.y; len2 = dx*dx + dy*dy
t = (dx*dx) / len2
footX = ax + t*dx ; footY = BR.y + t*dy
Cx = 2*footX - BR.x ; Cy = 2*footY - BR.y
```
Guard `len2 === 0` by returning BR.
Fold angle in degrees: `angle = useTransform([Ax, By], ([ax, by]) => Math.atan2(by - BR.y, BR.x - ax) * 180 / Math.PI)`.
# Rounded card path
The card shape is a `motion.path` (not a polygon) built with `useMotionTemplate`. TL, TR, and BL corners are rounded to `CARD_RADIUS` using quarter-circle arcs. The BR corner stays as straight line endpoints (points A and B) so the peel fold math works untouched.
```
cardPath = useMotionTemplate`M ${TL.x + CARD_RADIUS} ${TL.y}
L ${TR.x - CARD_RADIUS} ${TR.y}
A ${CARD_RADIUS} ${CARD_RADIUS} 0 0 1 ${TR.x} ${TR.y + CARD_RADIUS}
L ${Bx} ${By}
L ${Ax} ${Ay}
L ${BL.x + CARD_RADIUS} ${BL.y}
A ${CARD_RADIUS} ${CARD_RADIUS} 0 0 1 ${BL.x} ${BL.y - CARD_RADIUS}
L ${TL.x} ${TL.y + CARD_RADIUS}
A ${CARD_RADIUS} ${CARD_RADIUS} 0 0 1 ${TL.x + CARD_RADIUS} ${TL.y}
Z`
```
Path walkthrough:
1. Start at TL right-tangent point, run to TR left-tangent
2. Arc TR (clockwise, sweep-flag=1)
3. Line to B (right edge fold point), then to A (bottom edge fold point)
4. Line to BL right-tangent, arc BL (clockwise)
5. Line to TL bottom-tangent, arc TL (clockwise) back to start
Also keep `peelPoints = useMotionTemplate\`${Ax},${Ay} ${Bx},${By} ${Cx},${Cy}\`` for the green flap polygon.
# SVG
One `motion.svg viewBox="0 0 500 620"` with `className="block h-auto w-full"` and `style={{ rotate: 3, transformOrigin: '50% 50%' }}` (the whole card tilts +3° for personality). `aria-hidden`.
Inside `<defs>`:
1. `<motion.linearGradient id="pcr-peel-gradient" gradientUnits="userSpaceOnUse">` with `x1={foldMidX}` `y1={foldMidY}` `x2={Cx}` `y2={Cy}` — where `foldMidX/Y` are useTransforms of `(Ax+Bx)/2` and `(Ay+By)/2`. Stops: 0% `PEEL_FILL_DEEP`, 22% `PEEL_FILL`, 100% `PEEL_FILL`. This creates a darker band hugging the fold that fades into flat green across the face.
2. `<clipPath id="pcr-peel-clip">` wrapping a `motion.polygon points={peelPoints}`.
3. `<clipPath id="pcr-card-clip">` wrapping a `motion.path d={cardPath}` — uses the rounded path, NOT a polygon.
Render order (painter's algorithm):
1. **Card body** — `<motion.g>` wrapping `<motion.path d={cardPath} fill={CARD_FILL} />`. This draws the rounded card shape with the BR corner carved off.
2. **Card front content** — a `<g clipPath="url(#pcr-card-clip)">` containing:
- **Pulsing Wi-Fi icon** positioned at the top-left of the card. `<g transform={\`translate(${CARD_X + 24}, ${CARD_Y + 62}) scale(1.5)\`} stroke={PEEL_FILL} strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" fill="none">`. Inside:
- a filled dot: `<circle cx="8" cy="12.5" r="1.4" fill={PEEL_FILL} />`
- three nested arcs that pulse on a staggered 2.2-second loop, all using custom easing `[0.22, 1, 0.36, 1]`:
- `d="M4 9 Q8 5 12 9"` — `opacity: [0.15, 1, 0.15]`, `times: [0, 0.2, 1]`, delay 0
- `d="M1 6 Q8 -1 15 6"` — `opacity: [0.1, 0.9, 0.1]`, `times: [0, 0.25, 1]`, delay 0.25
- `d="M-2 3 Q8 -7 18 3"` — `opacity: [0.05, 0.6, 0.05]`, `times: [0, 0.3, 1]`, delay 0.5
- All three: `repeat: Infinity`, `duration: 2.2`. The asymmetric `times` arrays make the peaks lean early, so the signal reads as emanating outward.
- **Display type** — two stacked `<text>` lines at `x={CARD_X+24}` with `fill={CARD_INK}`, `fontFamily="var(--font-sans, ui-sans-serif, system-ui, sans-serif)"`, `fontSize={96}`, `fontWeight={900}`, `letterSpacing={-3}`, inline `style={{ lineHeight: 0.9 }}`. First line "Free" at `y={CARD_Y + 190}`, second "Wi-Fi" at `y={CARD_Y + 276}`.
- **Divider** — single `<line x1={CARD_X+40} y1={CARD_Y+316} x2={CARD_X+140} y2={CARD_Y+316} stroke={DIVIDER_STROKE} strokeWidth={1} opacity={0.15} />`. Subtle hairline under the title.
- **Bottom CTA** — `<text x={CARD_X+24} y={CARD_Y + CARD_H - 20} fill={PEEL_FILL} fontSize={9} fontWeight={900} letterSpacing={2.5}>` reading `TAP TO SCAN`. Same font-family var.
3. **Peel flap** — `<motion.g>` wrapping `<motion.polygon points={peelPoints} fill="url(#pcr-peel-gradient)" />`.
4. **Fold highlight line** — `<motion.line x1={Ax} y1={Ay} x2={Bx} y2={By} stroke={FOLD_STROKE} strokeWidth={1.1} strokeLinecap="round" />`. Thin stroke along the fold sells paper thickness.
5. **QR code group** — `<motion.g clipPath="url(#pcr-peel-clip)" style={{ opacity: revealOpacity }}>` with `revealOpacity = useTransform(progress, [0.35, 0.75], [0, 1])`, so the QR fades in after the flap has clearly started lifting.
There is NO caption or hint label below the card. The `motion.div` wrapper contains only the SVG.
# QR positioning
QR constants:
```
QR_SIZE = 110
QR_ALONG_FRAC = 0.3 // position along the fold A→B (0=at A, 1=at B)
QR_PERP_FRAC = 0.7 // perpendicular position from fold toward C (0=on fold, 1=at C)
QR_OFFSET_X = 54
QR_OFFSET_Y = 18
```
Anchor (center of QR) in SVG coords:
```
qrAnchorX = ax + QR_ALONG_FRAC*(bx - ax) + (QR_PERP_FRAC/2)*(cx - BR.x) + QR_OFFSET_X
qrAnchorY = ay + QR_ALONG_FRAC*(by - ay) + (QR_PERP_FRAC/2)*(cy - BR.y) + QR_OFFSET_Y
```
(Each is a `useTransform` over `[Ax, Bx, Cx]` / `[Ay, By, Cy]`.) Rotation: `qrAngle = useTransform(angle, a => a + 31)`.
The transform string `translate(qrAnchorX qrAnchorY) rotate(qrAngle)` is built with `useMotionTemplate` but applied imperatively to the inner `<g ref={qrGroupRef}>` via `useMotionValueEvent(qrTransform, 'change', latest => qrGroupRef.current?.setAttribute('transform', latest))` plus an `useEffect` that seeds the initial value. This is because Framer Motion's SVG transform attribute doesn't animate reliably through a template on a plain `<g>`.
Inside the inner `<g ref={qrGroupRef}>`, place a `<foreignObject x={-QR_SIZE/2} y={-QR_SIZE/2} width={QR_SIZE} height={QR_SIZE}>` containing a plain `<div>` styled `{ width: QR_SIZE, height: QR_SIZE, padding: 6, background: '#FFFFFF', borderRadius: 6, boxSizing: 'border-box' }`. Inside that div, render:
```tsx
<QRCodeSVG
value="WIFI:S:SlowBrew_4G;T:WPA;P:BREW_ME_BABY!;;"
size={QR_SIZE - 12}
level="M"
bgColor="#FFFFFF"
fgColor="#0A0A0A"
style={{ display: 'block' }}
/>
```
The `WIFI:S:…;T:WPA;P:…;;` format is the standard Wi-Fi QR payload — a real phone scan will join the network.
# Idle bob
Only on the closed card, fading out as it opens:
- `bobRaw = useMotionValue(0)`. In an effect, run a `requestAnimationFrame` loop that sets `bobRaw` to `Math.sin(elapsedSeconds * 1.2) * BOB_AMPLITUDE`. Clean up with `cancelAnimationFrame` and an `alive` flag.
- `bobGate = useTransform(progress, [0, 0.4], [1, 0])`.
- `bobY = useTransform([bobRaw, bobGate], ([b, g]) => b * g)` — combined with the drop-shadow in the outer motion.div: `style={{ y: bobY, filter: \`drop-shadow(${DROP_SHADOW})\` }}`.
# Mobile / resilience
- Component fills its container; no hardcoded widths on the wrapper. The SVG uses `w-full h-auto` so it scales cleanly from 320px to 1200px.
- Tap-to-toggle works on touch (Framer Motion's `onTap`). Hover peek is desktop-only bonus — on mobile the component is still fully usable via tap.
# Cleanup / correctness
- Cancel the bob RAF on unmount.
- Disconnect the theme MutationObserver on unmount.
- Use `useMotionValue` / `useTransform` for animation state — no `useState` for numeric animation values.
- TypeScript strict: event type is `React.KeyboardEvent<HTMLDivElement>`, QR group ref is `useRef<SVGGElement>(null)`, containerRef is `useRef<HTMLDivElement>(null)`, useTheme param is `RefObject<HTMLElement | null>`. No `any`.