diff --git a/site/app/globals.css b/site/app/globals.css
index 0f5810b..98c4feb 100644
--- a/site/app/globals.css
+++ b/site/app/globals.css
@@ -41,7 +41,7 @@ body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0;
- height: 14px;
+ height: 44px;
background: linear-gradient(90deg, #295df6, #c6a0fd, #5cd500, #ff7a45, #ff3e8c, #00c9a7);
filter: blur(33px);
opacity: 0.2;
diff --git a/site/components/Hero.tsx b/site/components/Hero.tsx
index 6ca85d7..ecf2bb2 100644
--- a/site/components/Hero.tsx
+++ b/site/components/Hero.tsx
@@ -1,8 +1,56 @@
'use client'
+import { useEffect, useRef, useState } from 'react'
import { StampStack } from 'stampstack'
import { DEMO_ITEMS, demoColor } from './demo-stamps'
+import { StampToaster, type ToastData } from './StampToast'
export function Hero() {
+ // The stack of tapped stamps, newest last. Capped to the visible depth so the
+ // stack stays tidy; each toast self-dismisses (see StampToaster).
+ const [toasts, setToasts] = useState([])
+
+ // One-time intro flourish: once the hero settles, riffle the fan quickly out to
+ // the last stamp and back to the first, then stop. The library listens for arrow
+ // keys on window, so we synthesize a fast burst of ArrowRight then ArrowLeft
+ // keydowns — its own 0.45s card transition smooths the rapid steps into one
+ // continuous sweep (no library control API needed). Skipped under reduced motion;
+ // aborts the moment the user takes over (real keys / pointer set pausedRef).
+ const pausedRef = useRef(false)
+
+ useEffect(() => {
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
+
+ // Real (user) arrow/enter keys abort the flourish. Our synthetic keys carry
+ // isTrusted=false, so they slip past this guard.
+ const onUserKey = (e: KeyboardEvent) => {
+ if (e.isTrusted && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter')) {
+ pausedRef.current = true
+ }
+ }
+ window.addEventListener('keydown', onUserKey)
+
+ const last = DEMO_ITEMS.length - 1
+ const STEP_MS = 70 // gap per step — far under the 0.45s card transition, so it flows
+ const START_MS = 700 // let the hero's entrance rise finish before the sweep
+
+ // Out to the last stamp, then back to the first — a single rapid pass.
+ const keys: ('ArrowRight' | 'ArrowLeft')[] = [
+ ...Array(last).fill('ArrowRight'),
+ ...Array(last).fill('ArrowLeft'),
+ ]
+ const timers = keys.map((key, i) =>
+ window.setTimeout(() => {
+ if (pausedRef.current || document.hidden) return
+ window.dispatchEvent(new KeyboardEvent('keydown', { key }))
+ }, START_MS + i * STEP_MS),
+ )
+
+ return () => {
+ timers.forEach((t) => window.clearTimeout(t))
+ window.removeEventListener('keydown', onUserKey)
+ }
+ }, [])
+
return (
@@ -26,7 +74,7 @@ export function Hero() {
className="rise"
style={{ display: 'flex', alignItems: 'center', alignSelf: 'stretch', whiteSpace: 'nowrap', marginTop: -10, animationDelay: '160ms' }}
>
-
+ stampstackv0.2.0
@@ -45,9 +93,10 @@ export function Hero() {
className="rise"
style={{ margin: 0, maxWidth: 486, color: '#252525', fontWeight: 500, fontSize: 14, letterSpacing: '-0.1px', lineHeight: '23px', fontFeatureSettings: '"swsh" 1', animationDelay: '200ms' }}
>
- stampstack is a postage-styled 3D carousel component. Install and drop in whatever content you want on the stamps.
- no dependencies beyond React 18.
+ stampstack is a postage-styled 3D carousel component. install and drop in whatever content you want on the stamps. no deps beyond react 18
+
+
@@ -57,6 +106,7 @@ export function Hero() {
then spreads symmetrically and clips cleanly at both edges. */}
+
+ {/* Tap a stamp → a Sonner-style toast joins the stack (swatch + title +
+ subtitle). Newest sits in front; hover expands, swipe-down dismisses. */}
+ setToasts((t) => t.filter((x) => x.key !== key))}
+ />
)
}
diff --git a/site/components/InstallTabs.tsx b/site/components/InstallTabs.tsx
index 55ca9d0..a0c8dc5 100644
--- a/site/components/InstallTabs.tsx
+++ b/site/components/InstallTabs.tsx
@@ -9,11 +9,17 @@ const COMMANDS: Record = {
bun: 'bun add stampstack',
}
-// Copy-this-to-your-agent prompt (react-grab style).
-const PROMPT = `Set up stampstack in this React project.
+// Copy-this-to-your-agent prompt (react-grab style). Written to handle any
+// starting point — empty directory, fresh scaffold, or existing React project.
+const PROMPT = `Set up stampstack — handle whichever case applies to this directory.
-1. Detect the package manager from the lockfile and install stampstack (e.g. npm i stampstack).
-2. Where you use it, import the component and its styles:
+1. Install:
+ - Existing React project: install with the project's package manager. Detect it
+ from the lockfile (package-lock.json → npm, pnpm-lock.yaml → pnpm, yarn.lock → yarn,
+ bun.lockb → bun); if there's no lockfile, use npm. e.g. npm i stampstack
+ - Empty directory (no package.json): scaffold a React app first, then install —
+ npm create vite@latest . -- --template react-ts && npm install && npm i stampstack
+2. Import the component and its styles where you use it:
import { StampStack } from 'stampstack'
import 'stampstack/styles.css'
3. Render it with your data — each item only needs an id:
@@ -21,7 +27,8 @@ const PROMPT = `Set up stampstack in this React project.
4. Optional: frameColor={(item) => '#...'} for a per-stamp frame color, and
onSelect={(item) => ...} to open a card on tap.
-It renders real DOM (not a canvas), so any content — text, images, links — works inside renderStamp.`
+Works in any React 18+ app (Vite, Next.js, CRA). It renders real DOM (not a canvas),
+so any content — text, images, links — works inside renderStamp.`
const TABS = ['npm', 'pnpm', 'yarn', 'bun', 'Prompt'] as const
diff --git a/site/components/StampToast.tsx b/site/components/StampToast.tsx
new file mode 100644
index 0000000..6ffc223
--- /dev/null
+++ b/site/components/StampToast.tsx
@@ -0,0 +1,225 @@
+'use client'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import type { DemoItem } from './demo-stamps'
+
+// A Sonner-style stacked toaster, handrolled (no dependency) to match the
+// site's zero-dep ethos. Each tap pushes a toast; the newest sits in front and
+// older ones scale back + peek behind. Hovering expands the stack into a list;
+// the front toast can be swiped down to dismiss; each auto-dismisses after 4s
+// (paused while hovered). Motion uses Sonner's springy ease.
+
+export type ToastData = { key: number; item: DemoItem; color: string }
+
+const MAX_VISIBLE = 3 // toasts rendered in the collapsed stack
+const COLLAPSE_GAP = 16 // px each older toast rises behind the front
+const SCALE_STEP = 0.06 // scale lost per step back
+const TOAST_H = 80 // approx uniform toast height (content is fixed)
+const EXPAND_GAP = 12 // gap between toasts when the stack is expanded
+const ENTER_FROM = 28 // px below resting spot a new toast slides up from
+const AUTO_MS = 4000
+const EXIT_MS = 200
+const SWIPE_DISMISS = 45 // px of downward drag that commits a dismiss
+const EASE = 'cubic-bezier(0.21, 1.02, 0.73, 1)' // Sonner's slight-overshoot spring
+
+export function StampToaster({
+ toasts,
+ onDismiss,
+}: {
+ toasts: ToastData[]
+ onDismiss: (key: number) => void
+}) {
+ // Expanded while the pointer is over any toast. Debounced on leave so sliding
+ // between stacked toasts (leave A → enter B) doesn't collapse for a frame.
+ const [expanded, setExpanded] = useState(false)
+ const leaveTimer = useRef(undefined)
+ const onEnter = useCallback(() => {
+ if (leaveTimer.current) clearTimeout(leaveTimer.current)
+ setExpanded(true)
+ }, [])
+ const onLeave = useCallback(() => {
+ leaveTimer.current = window.setTimeout(() => setExpanded(false), 60)
+ }, [])
+
+ return (
+
+ {toasts.map((t, i) => (
+
+ ))}
+
+ )
+}
+
+function ToastItem({
+ data,
+ pos,
+ expanded,
+ onEnter,
+ onLeave,
+ onDismiss,
+}: {
+ data: ToastData
+ pos: number
+ expanded: boolean
+ onEnter: () => void
+ onLeave: () => void
+ onDismiss: (key: number) => void
+}) {
+ const [phase, setPhase] = useState<'enter' | 'rest' | 'exit'>('enter')
+ const [dragging, setDragging] = useState(false)
+ const [dragY, setDragY] = useState(0)
+ const startY = useRef(0)
+ const isFront = pos === 0
+
+ // Enter on the next frame (so the from-state paints first → transition runs).
+ useEffect(() => {
+ const r = requestAnimationFrame(() => setPhase('rest'))
+ return () => cancelAnimationFrame(r)
+ }, [])
+
+ // Animate out, then let the parent drop us from the list.
+ const dismiss = useCallback(() => {
+ setPhase('exit')
+ const t = setTimeout(() => onDismiss(data.key), EXIT_MS)
+ return () => clearTimeout(t)
+ }, [onDismiss, data.key])
+
+ // Auto-dismiss — paused while the stack is hovered (expanded) or being dragged.
+ useEffect(() => {
+ if (phase !== 'rest' || expanded || dragging) return
+ const t = setTimeout(dismiss, AUTO_MS)
+ return () => clearTimeout(t)
+ }, [phase, expanded, dragging, dismiss])
+
+ // Swipe-to-dismiss — front toast only.
+ const onPointerDown = (e: React.PointerEvent) => {
+ if (!isFront) return
+ startY.current = e.clientY
+ setDragging(true)
+ e.currentTarget.setPointerCapture(e.pointerId)
+ }
+ const onPointerMove = (e: React.PointerEvent) => {
+ if (!dragging) return
+ // Follow downward freely; resist upward (this stack dismisses downward).
+ const dy = e.clientY - startY.current
+ setDragY(dy > 0 ? dy : dy * 0.3)
+ }
+ const onPointerUp = () => {
+ if (!dragging) return
+ setDragging(false)
+ if (dragY > SWIPE_DISMISS) dismiss()
+ else setDragY(0) // snap back
+ }
+
+ // Resting position from depth: rise + shrink (collapsed) or full list (expanded).
+ const baseY = expanded ? -pos * (TOAST_H + EXPAND_GAP) : -pos * COLLAPSE_GAP
+ const baseScale = expanded ? 1 : 1 - pos * SCALE_STEP
+
+ let transform: string
+ let opacity: number
+ if (phase === 'enter') {
+ transform = `translateY(${baseY + ENTER_FROM}px) scale(0.96)`
+ opacity = 0
+ } else if (phase === 'exit') {
+ transform = `translateY(${baseY + dragY + 48}px) scale(0.96)`
+ opacity = 0
+ } else {
+ transform = `translateY(${baseY + dragY}px) scale(${baseScale})`
+ // Beyond the visible count, fade out behind the stack; fade while swiping.
+ const swipeFade = dragY > 0 ? Math.max(0, 1 - dragY / (TOAST_H * 1.4)) : 1
+ opacity = (pos < MAX_VISIBLE ? 1 : 0) * swipeFade
+ }
+
+ return (
+
+ {/* Swatch — the stamp's frame color */}
+
+
+ {/* Title + subtitle */}
+
+
+ {data.item.title}
+
+
+ {data.item.subtitle}
+
+
+
+ {/* Close — stopPropagation so pressing it never starts a swipe. */}
+
+