Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion site/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 61 additions & 5 deletions site/components/Hero.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastData[]>([])

// 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 (
<header style={{ paddingTop: 36, textAlign: 'left' }}>

Expand All @@ -26,7 +74,7 @@ export function Hero() {
className="rise"
style={{ display: 'flex', alignItems: 'center', alignSelf: 'stretch', whiteSpace: 'nowrap', marginTop: -10, animationDelay: '160ms' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 5, color: '#484747', fontWeight: 500 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5, color: '#252525', fontWeight: 500 }}>
<span style={{ fontSize: 18, fontWeight: 700, lineHeight: '44px', letterSpacing: '-0.8px', opacity: 0.92 }}>stampstack</span>
<span style={{ fontSize: 14, lineHeight: '24px', letterSpacing: '-0.1px', opacity: 0.22 }}>v0.2.0</span>
</span>
Expand All @@ -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

</p>

</div>
</div>

Expand All @@ -57,6 +106,7 @@ export function Hero() {
then spreads symmetrically and clips cleanly at both edges. */}
<div
className="rise"
onPointerDown={() => { pausedRef.current = true }}
style={{
position: 'relative',
overflow: 'visible',
Expand All @@ -69,7 +119,6 @@ export function Hero() {
cardWidth={240}
style={{ width: 240, height: 340 }}
frameColor={(item) => demoColor(item.id)}
onSelect={(item) => window.alert(`Tapped ${item.label}`)}
renderStamp={(item, state) => (
<div
style={{
Expand All @@ -84,11 +133,18 @@ export function Hero() {
opacity: state.focused ? 1 : 0.85,
}}
>
{item.label}
{item.title}
</div>
)}
/>
</div>

{/* Tap a stamp → a Sonner-style toast joins the stack (swatch + title +
subtitle). Newest sits in front; hover expands, swipe-down dismisses. */}
<StampToaster
toasts={toasts}
onDismiss={(key) => setToasts((t) => t.filter((x) => x.key !== key))}
/>
</header>
)
}
17 changes: 12 additions & 5 deletions site/components/InstallTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,26 @@ const COMMANDS: Record<string, string> = {
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:
<StampStack items={items} renderStamp={(item, state) => <div>{item.title}</div>} />
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

Expand Down
225 changes: 225 additions & 0 deletions site/components/StampToast.tsx
Original file line number Diff line number Diff line change
@@ -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<number | undefined>(undefined)
const onEnter = useCallback(() => {
if (leaveTimer.current) clearTimeout(leaveTimer.current)
setExpanded(true)
}, [])
const onLeave = useCallback(() => {
leaveTimer.current = window.setTimeout(() => setExpanded(false), 60)
}, [])

return (
<div
style={{
position: 'fixed',
bottom: 24,
left: '50%',
transform: 'translateX(-50%)',
width: 'min(380px, calc(100vw - 32px))',
zIndex: 200, // above the rainbow wash (z-index 100)
pointerEvents: 'none', // empty area never blocks page clicks; toasts re-enable
}}
>
{toasts.map((t, i) => (
<ToastItem
key={t.key}
data={t}
pos={toasts.length - 1 - i} // 0 = newest/front
expanded={expanded}
onEnter={onEnter}
onLeave={onLeave}
onDismiss={onDismiss}
/>
))}
</div>
)
}

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 (
<div
role="status"
aria-live="polite"
onPointerEnter={onEnter}
onPointerLeave={onLeave}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'auto',
touchAction: 'none', // let us own the vertical drag gesture
zIndex: 100 - pos, // newest on top
transformOrigin: 'center bottom',
transform,
opacity,
transition: dragging
? 'none'
: `transform 400ms ${EASE}, opacity 300ms ease`,
cursor: isFront ? 'grab' : 'default',
display: 'flex',
alignItems: 'center',
gap: 14,
padding: 14,
background: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: 16,
boxShadow: '0 10px 30px -10px rgba(0, 0, 0, 0.22)',
fontFamily: 'var(--font-ui)',
}}
>
{/* Swatch — the stamp's frame color */}
<div style={{ flexShrink: 0, width: 52, height: 52, borderRadius: 13, background: data.color }} />

{/* Title + subtitle */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 650, fontSize: 17, lineHeight: '22px', letterSpacing: '-0.02em', color: '#171717' }}>
{data.item.title}
</div>
<div style={{ fontWeight: 500, fontSize: 14, lineHeight: '20px', letterSpacing: '-0.01em', color: 'var(--muted)' }}>
{data.item.subtitle}
</div>
</div>

{/* Close — stopPropagation so pressing it never starts a swipe. */}
<button
className="ss-tap"
onPointerDown={(e) => e.stopPropagation()}
onClick={dismiss}
aria-label="Dismiss"
style={{
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
border: 'none',
borderRadius: 9,
background: '#f4f4f3',
color: '#333',
fontSize: 15,
lineHeight: 1,
cursor: 'pointer',
}}
>
{/* multiplication sign — a true × glyph, not the letter x */}
×
</button>
</div>
)
}
Loading