# AI Canvas > AI Canvas is an open-core, shadcn-compatible registry of 86 animated React components, design systems, and templates built with Tailwind CSS and Motion. The free library is MIT, and Premium components, design systems, and templates are proprietary. Many components ship with a comprehensive AI remix prompt, so developers can install the code directly or recreate their own variation in any AI builder. On free components that prompt is public in full; on premium components and blocks only part of it is public (the setup and the constants), and the rest of the build spec requires a Premium subscription. ## Overview - [Homepage](https://aicanvas.me): Browse all components. - [All components](https://aicanvas.me/components): Full component list. - [About](https://aicanvas.me/about): About AI Canvas. - [Registry index](https://aicanvas.me/r/registry.json): Machine-readable registry index. ## Install Install command: `npx shadcn@latest add @aicanvas/`. One-command installs require a free AI Canvas account: signed out, the command exits 0 but writes a placeholder file titled "(free account required)" instead of the real component. To authenticate, sign in and copy your token from https://aicanvas.me/account/settings, set AICANVAS_TOKEN in .env.local, and add `{ "registries": { "@aicanvas": { "url": "https://aicanvas.me/r/{name}.json", "params": { "token": "${AICANVAS_TOKEN}" } } } }` to components.json. Full setup notes: https://aicanvas.me/llms.txt No account needed to read: every free component's complete source is inlined below and can be copied into a project directly. ## Components --- ## Diamond Grid Category: Backgrounds Slug: `diamond-grid` URL: https://aicanvas.me/components/diamond-grid A faint canvas grid of diamonds. Seeded sparks send light outward until fronts meet. Install (free account): ```bash npx shadcn@latest add @aicanvas/diamond-grid ``` ```tsx 'use client' /** * Renders a seeded diamond grid with traveling ignition pulses. * It adapts its canvas animation to visibility, theme, and reduced motion. */ import { useEffect, useRef } from 'react' type DiamondGridProps = { /** Extra classes merged onto the outermost root element. */ className?: string /** Determines grid placement, ignition positions and the complete loop sequence. */ seed?: number } const LOOP_MS = 64000 // tune: raise the event count to increase concurrent ignitions const EVENT_COUNT = 34 const STATIC_TIME_MS = 27000 const STAR_SIZE = 30 const TAU = Math.PI * 2 const GRID_COS = Math.SQRT1_2 const DARK = { ground: '#000000', ink: '255,255,255', line: 0.3, pulse: 0.7, comp: 'lighter' as GlobalCompositeOperation, } const LIGHT = { ground: '#FAF8F5', ink: '14,14,16', line: 0.34, pulse: 0.5, comp: 'source-over' as GlobalCompositeOperation, } type Palette = typeof DARK | typeof LIGHT type Ignition = { col: number row: number start: number duration: number decayStart: number travel: number reach: number phase: number } type GridTransform = { a: number b: number c: number d: number e: number f: number } type Field = { cell: number x0: number y0: number cols: number rows: number transform: GridTransform events: Ignition[] } function makeAlphaStyles(ink: string) { const styles: string[] = [] for (let i = 0; i < 256; i++) styles.push(`rgba(${ink},${i / 255})`) return styles } const DARK_STYLES = makeAlphaStyles(DARK.ink) const LIGHT_STYLES = makeAlphaStyles(LIGHT.ink) function mulberry32(a: number) { return function () { a |= 0 a = (a + 0x6d2b79f5) | 0 let t = Math.imul(a ^ (a >>> 15), 1 | a) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } const clamp = (value: number, low: number, high: number) => value < low ? low : value > high ? high : value const clampInt = (value: number, low: number, high: number) => Math.round(clamp(value, low, high)) const smoothstep = (value: number) => { const p = clamp(value, 0, 1) return p * p * (3 - 2 * p) } function diagonalStrength(x: number, y: number, W: number, H: number) { const distance = Math.abs(x / W - y / H) return 1 - smoothstep((distance - 0.16) / 0.64) } function makeGridTransform(W: number, H: number): GridTransform { return { a: GRID_COS, b: GRID_COS, c: -GRID_COS, d: GRID_COS, e: W * 0.5, f: H * 0.5, } } function gridToScreen(transform: GridTransform, x: number, y: number) { return { x: transform.a * x + transform.c * y + transform.e, y: transform.b * x + transform.d * y + transform.f, } } function screenToGrid(transform: GridTransform, x: number, y: number) { const dx = x - transform.e const dy = y - transform.f return { x: transform.a * dx + transform.b * dy, y: transform.c * dx + transform.d * dy, } } function buildField(W: number, H: number, seed: number): Field { const rnd = mulberry32(seed) // tune: raise the scale factor to widen the grid spacing const cell = clamp(Math.min(W, H) * 0.113, 65, 124) const transform = makeGridTransform(W, H) const corners = [ screenToGrid(transform, 0, 0), screenToGrid(transform, W, 0), screenToGrid(transform, 0, H), screenToGrid(transform, W, H), ] const minX = Math.min(...corners.map((point) => point.x)) const maxX = Math.max(...corners.map((point) => point.x)) const minY = Math.min(...corners.map((point) => point.y)) const maxY = Math.max(...corners.map((point) => point.y)) const x0 = minX - cell * (1 + rnd()) const y0 = minY - cell * (1 + rnd()) const cols = Math.ceil((maxX + cell - x0) / cell) + 1 const rows = Math.ceil((maxY + cell - y0) / cell) + 1 const gaps = new Float32Array(EVENT_COUNT) let gapTotal = 0 for (let i = 0; i < EVENT_COUNT; i++) { const gap = i % 3 === 0 ? 1200 + rnd() * 1400 : 3600 + rnd() * 3200 gaps[i] = gap gapTotal += gap } const gapScale = LOOP_MS / gapTotal const events: Ignition[] = [] let cursor = 0 const pickVisibleNode = () => { let col = 1 let row = 1 for (let attempt = 0; attempt < 40; attempt++) { col = 1 + Math.floor(rnd() * Math.max(1, cols - 2)) row = 1 + Math.floor(rnd() * Math.max(1, rows - 2)) const point = gridToScreen(transform, x0 + col * cell, y0 + row * cell) if ( point.x >= 0 && point.x <= W && point.y >= 0 && point.y <= H && diagonalStrength(point.x, point.y, W, H) > 0.28 ) { break } } return { col, row } } for (let i = 0; i < EVENT_COUNT; i++) { let position = pickVisibleNode() if (i % 3 === 1 && events.length > 0) { const previous = events[i - 1] const offset = (rnd() < 0.5 ? -1 : 1) * (2 + Math.floor(rnd() * 2)) if (rnd() < 0.5) { position = { col: clampInt(previous.col + offset, 1, cols - 2), row: previous.row, } } else { position = { col: previous.col, row: clampInt(previous.row + offset, 1, rows - 2), } } const point = gridToScreen( transform, x0 + position.col * cell, y0 + position.row * cell, ) if ( point.x < 0 || point.x > W || point.y < 0 || point.y > H || diagonalStrength(point.x, point.y, W, H) < 0.2 ) { position = pickVisibleNode() } } const duration = 25000 + rnd() * 7000 events.push({ col: position.col, row: position.row, start: cursor, duration, decayStart: 10500 + rnd() * 4500, travel: 3200 + rnd() * 900, reach: 2.5 + rnd() * 1.25, phase: rnd() * TAU, }) cursor += gaps[i] * gapScale } return { cell, x0, y0, cols, rows, transform, events } } function addStarPath( context: CanvasRenderingContext2D, x: number, y: number, radius: number, inner: number, ) { context.moveTo(x, y - radius) context.lineTo(x + inner, y - inner) context.lineTo(x + radius, y) context.lineTo(x + inner, y + inner) context.lineTo(x, y + radius) context.lineTo(x - inner, y + inner) context.lineTo(x - radius, y) context.lineTo(x - inner, y - inner) context.closePath() } export default function DiamondGrid({ className, seed = 1337 }: DiamondGridProps) { const hostRef = useRef(null) const canvasRef = useRef(null) useEffect(() => { const host = hostRef.current const canvas = canvasRef.current if (!host || !canvas) return const ctx = canvas.getContext('2d') if (!ctx) return const isDark = () => { const cardTheme = host.closest('[data-card-theme]')?.getAttribute('data-card-theme') if (cardTheme === 'light') return false if (cardTheme === 'dark') return true return document.documentElement.classList.contains('dark') } const baseCanvas = document.createElement('canvas') const liveCanvas = document.createElement('canvas') const maskCanvas = document.createElement('canvas') const starCanvas = document.createElement('canvas') const baseCtx = baseCanvas.getContext('2d') const liveCtx = liveCanvas.getContext('2d') const maskCtx = maskCanvas.getContext('2d') const starCtx = starCanvas.getContext('2d') if (!baseCtx || !liveCtx || !maskCtx || !starCtx) return const motionMq = window.matchMedia('(prefers-reduced-motion: reduce)') let W = 0 let H = 0 let dpr = 1 let dark = isDark() let reduce = motionMq.matches let field: Field | null = null let elapsed = 0 let last = 0 let raf = 0 let running = false let onScreen = false const theme = (): Palette => (dark ? DARK : LIGHT) const styles = () => (dark ? DARK_STYLES : LIGHT_STYLES) const styleAt = (alpha: number) => styles()[Math.round(clamp(alpha, 0, 1) * 255)] const sizeSurface = (surface: HTMLCanvasElement, width: number, height: number) => { surface.width = Math.max(1, Math.round(width * dpr)) surface.height = Math.max(1, Math.round(height * dpr)) } const setGridTransform = (target: CanvasRenderingContext2D, f: Field) => { const transform = f.transform target.setTransform( dpr * transform.a, dpr * transform.b, dpr * transform.c, dpr * transform.d, dpr * transform.e, dpr * transform.f, ) } const buildMask = () => { maskCtx.setTransform(1, 0, 0, 1, 0, 0) maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height) maskCtx.setTransform(dpr, 0, 0, dpr, 0, 0) const gx = 1 / W const gy = -1 / H const scale = 1 / (gx * gx + gy * gy) const cx = W * 0.5 const cy = H * 0.5 const gradient = maskCtx.createLinearGradient( cx - gx * scale, cy - gy * scale, cx + gx * scale, cy + gy * scale, ) gradient.addColorStop(0, 'rgba(255,255,255,0)') gradient.addColorStop(0.1, 'rgba(255,255,255,0)') gradient.addColorStop(0.16, 'rgba(255,255,255,0.12)') gradient.addColorStop(0.24, 'rgba(255,255,255,0.55)') gradient.addColorStop(0.325, 'rgba(255,255,255,0.92)') gradient.addColorStop(0.42, 'rgba(255,255,255,1)') gradient.addColorStop(0.58, 'rgba(255,255,255,1)') gradient.addColorStop(0.675, 'rgba(255,255,255,0.92)') gradient.addColorStop(0.76, 'rgba(255,255,255,0.55)') gradient.addColorStop(0.84, 'rgba(255,255,255,0.12)') gradient.addColorStop(0.9, 'rgba(255,255,255,0)') gradient.addColorStop(1, 'rgba(255,255,255,0)') maskCtx.fillStyle = gradient maskCtx.fillRect(0, 0, W, H) } const applyMask = ( target: CanvasRenderingContext2D, surface: HTMLCanvasElement, ) => { target.setTransform(1, 0, 0, 1, 0, 0) target.globalAlpha = 1 target.globalCompositeOperation = 'destination-in' target.drawImage(maskCanvas, 0, 0, surface.width, surface.height) target.globalCompositeOperation = 'source-over' } const buildStarSprite = () => { const C = theme() starCanvas.width = Math.max(1, Math.round(STAR_SIZE * dpr)) starCanvas.height = Math.max(1, Math.round(STAR_SIZE * dpr)) starCtx.setTransform(dpr, 0, 0, dpr, 0, 0) starCtx.clearRect(0, 0, STAR_SIZE, STAR_SIZE) starCtx.globalCompositeOperation = C.comp const center = STAR_SIZE * 0.5 const bloom = starCtx.createRadialGradient(center, center, 0, center, center, 13) bloom.addColorStop(0, styleAt(C.pulse * 0.34)) bloom.addColorStop(0.32, styleAt(C.pulse * 0.12)) bloom.addColorStop(1, styleAt(0)) starCtx.fillStyle = bloom starCtx.fillRect(0, 0, STAR_SIZE, STAR_SIZE) starCtx.fillStyle = styleAt(C.pulse) starCtx.beginPath() addStarPath(starCtx, center, center, 10.5, 1.45) starCtx.fill() starCtx.beginPath() starCtx.arc(center, center, 1.65, 0, TAU) starCtx.fill() starCtx.globalCompositeOperation = 'source-over' } const buildRestingLayer = () => { if (!field) return const C = theme() const f = field baseCtx.setTransform(1, 0, 0, 1, 0, 0) baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height) setGridTransform(baseCtx, f) baseCtx.globalCompositeOperation = C.comp baseCtx.lineCap = 'butt' baseCtx.lineWidth = 1 baseCtx.strokeStyle = styleAt(C.line * 0.2) baseCtx.beginPath() for (let col = 0; col < f.cols; col++) { const x = f.x0 + col * f.cell baseCtx.moveTo(x, f.y0) baseCtx.lineTo(x, f.y0 + (f.rows - 1) * f.cell) } for (let row = 0; row < f.rows; row++) { const y = f.y0 + row * f.cell baseCtx.moveTo(f.x0, y) baseCtx.lineTo(f.x0 + (f.cols - 1) * f.cell, y) } baseCtx.stroke() baseCtx.fillStyle = styleAt(C.line * 0.55) baseCtx.beginPath() for (let row = 0; row < f.rows; row++) { const y = f.y0 + row * f.cell for (let col = 0; col < f.cols; col++) { const x = f.x0 + col * f.cell addStarPath(baseCtx, x, y, 3.3, 0.68) } } baseCtx.fill() applyMask(baseCtx, baseCanvas) } const rebuildCaches = () => { buildMask() buildStarSprite() buildRestingLayer() } const eventAge = (event: Ignition, time: number) => { const age = time - event.start return age < 0 ? age + LOOP_MS : age } const eventLevel = (event: Ignition, age: number) => { if (age >= event.duration) return 0 const attack = smoothstep(age / 900) const decay = age <= event.decayStart ? 1 : 1 - smoothstep((age - event.decayStart) / (event.duration - event.decayStart)) return attack * decay * (0.94 + 0.06 * Math.sin(age * 0.00055 + event.phase)) } const frontDistance = (event: Ignition, age: number) => { const span = event.travel * event.reach * 2.6 const p = Math.min(1, age / span) return event.reach * (1 - Math.pow(1 - p, 3)) } const paintRay = ( x: number, y: number, dx: number, dy: number, distance: number, level: number, ) => { if (!field || distance <= 0.002 || level <= 0.002) return const C = theme() const endX = x + dx * distance * field.cell const endY = y + dy * distance * field.cell liveCtx.lineCap = 'butt' liveCtx.strokeStyle = styleAt(C.pulse * level * 0.13) liveCtx.lineWidth = 3.2 liveCtx.beginPath() liveCtx.moveTo(x, y) liveCtx.lineTo(endX, endY) liveCtx.stroke() liveCtx.strokeStyle = styleAt(C.pulse * level * 0.54) liveCtx.lineWidth = 0.95 liveCtx.beginPath() liveCtx.moveTo(x, y) liveCtx.lineTo(endX, endY) liveCtx.stroke() liveCtx.fillStyle = styleAt(C.pulse * level * 0.72) liveCtx.beginPath() liveCtx.arc(endX, endY, 1.1, 0, TAU) liveCtx.fill() } const paintIgnition = (event: Ignition, time: number) => { if (!field) return const age = eventAge(event, time) const level = eventLevel(event, age) if (level <= 0.002) return const f = field const x = f.x0 + event.col * f.cell const y = f.y0 + event.row * f.cell const front = frontDistance(event, age) const left = Math.min(front, event.col) const right = Math.min(front, f.cols - 1 - event.col) const up = Math.min(front, event.row) const down = Math.min(front, f.rows - 1 - event.row) paintRay(x, y, -1, 0, left, level) paintRay(x, y, 1, 0, right, level) paintRay(x, y, 0, -1, up, level) paintRay(x, y, 0, 1, down, level) const starAlpha = clamp(level * (0.82 + 0.18 * smoothstep(age / 1800)), 0, 1) liveCtx.globalAlpha = starAlpha liveCtx.drawImage( starCanvas, x - STAR_SIZE * 0.5, y - STAR_SIZE * 0.5, STAR_SIZE, STAR_SIZE, ) liveCtx.globalAlpha = 1 } const drawScene = (time: number) => { if (!field) return const C = theme() liveCtx.setTransform(1, 0, 0, 1, 0, 0) liveCtx.globalCompositeOperation = 'source-over' liveCtx.globalAlpha = 1 liveCtx.clearRect(0, 0, liveCanvas.width, liveCanvas.height) setGridTransform(liveCtx, field) liveCtx.globalCompositeOperation = C.comp for (let i = 0; i < field.events.length; i++) { paintIgnition(field.events[i], time) } applyMask(liveCtx, liveCanvas) ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.globalCompositeOperation = 'source-over' ctx.globalAlpha = 1 ctx.fillStyle = C.ground ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.drawImage(baseCanvas, 0, 0) ctx.globalCompositeOperation = C.comp ctx.drawImage(liveCanvas, 0, 0) ctx.globalCompositeOperation = 'source-over' } const drawIdle = () => drawScene(reduce ? STATIC_TIME_MS : elapsed) const rebuild = () => { W = Math.max(1, Math.round(host.clientWidth)) H = Math.max(1, Math.round(host.clientHeight)) dpr = Math.min(window.devicePixelRatio || 1, 2) sizeSurface(canvas, W, H) sizeSurface(baseCanvas, W, H) sizeSurface(liveCanvas, W, H) sizeSurface(maskCanvas, W, H) canvas.style.width = `${W}px` canvas.style.height = `${H}px` field = buildField(W, H, seed) rebuildCaches() drawIdle() } const frame = (now: number) => { raf = requestAnimationFrame(frame) elapsed = (elapsed + Math.min(33, last ? now - last : 16)) % LOOP_MS last = now drawScene(elapsed) } const start = () => { if (running || reduce || !onScreen || document.hidden) return running = true last = 0 raf = requestAnimationFrame(frame) } const stop = () => { running = false cancelAnimationFrame(raf) } rebuild() let debounce = 0 const ro = new ResizeObserver(() => { window.clearTimeout(debounce) debounce = window.setTimeout(() => { if ( host.clientWidth === W && host.clientHeight === H && dpr === Math.min(window.devicePixelRatio || 1, 2) ) { return } rebuild() }, 120) }) ro.observe(host) const io = new IntersectionObserver( (entries) => { onScreen = entries[0].isIntersecting if (onScreen) start() else stop() }, { threshold: 0.01 }, ) io.observe(host) const onVisibility = () => { if (document.hidden) stop() else start() } document.addEventListener('visibilitychange', onVisibility) const onMotion = () => { reduce = motionMq.matches stop() drawIdle() start() } motionMq.addEventListener('change', onMotion) const mo = new MutationObserver(() => { const next = isDark() if (next === dark) return dark = next rebuildCaches() if (!running) drawIdle() }) mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) const cardWrapper = host.closest('[data-card-theme]') if (cardWrapper) { mo.observe(cardWrapper, { attributes: true, attributeFilter: ['class', 'data-card-theme'], }) } return () => { stop() window.clearTimeout(debounce) ro.disconnect() io.disconnect() mo.disconnect() document.removeEventListener('visibilitychange', onVisibility) motionMq.removeEventListener('change', onMotion) baseCanvas.width = 0 baseCanvas.height = 0 liveCanvas.width = 0 liveCanvas.height = 0 maskCanvas.width = 0 maskCanvas.height = 0 starCanvas.width = 0 starCanvas.height = 0 field = null } }, [seed]) return (
) } ``` --- ## Scroll Wipe Gallery Category: Blocks Slug: `scroll-wipe-gallery` URL: https://aicanvas.me/components/scroll-wipe-gallery A full-bleed, scroll-driven photo gallery. Each photo wipes in from a different edge. Install (free account): ```bash npx shadcn@latest add @aicanvas/scroll-wipe-gallery ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Presents a full-frame photo gallery driven by scroll progress. * Each section wipes over the previous image while updating its title. */ import { useEffect, useRef, useState, type RefObject } from 'react' import { cubicBezier, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue, } from 'framer-motion' import { CaretDown } from '@phosphor-icons/react' // customize: replace the gallery images and titles below const PHOTOS = [ { src: 'https://images.unsplash.com/photo-1634573826817-27d9e8da08df?w=1920&h=1080&fit=crop&crop=center&auto=format', alt: 'Modern structure mirrored in still water', title: 'Break Patterns', }, { src: 'https://images.unsplash.com/photo-1527576539890-dfa815648363?w=1920&h=1080&fit=crop&crop=center&auto=format', alt: 'Concrete facade seen from below in hard grayscale light', title: 'Push Forward', }, { src: 'https://images.unsplash.com/photo-1532456745301-b2c645d8b80d?w=1920&h=1080&fit=crop&crop=center&auto=format', alt: 'Repeating white concrete balconies forming a dense pattern', title: 'Cut Through', }, { src: 'https://images.unsplash.com/photo-1522743791393-522312deeebf?w=1920&h=1080&fit=crop&crop=center&auto=format', alt: 'Monolithic gray concrete mass against an open sky', title: 'Hold Steady', }, { src: 'https://images.unsplash.com/photo-1483366774565-c783b9f70e2c?w=1920&h=1080&fit=crop&crop=center&auto=format', alt: 'Sharp geometric underside of a concrete building', title: 'Leave Marks', }, ] as const const TYPEFACE = "'Manrope', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif" // tune: lower to shorten the scroll sequence const SECTION_HEIGHT = '1100vh' const HIDDEN_CLIP = { down: 'inset(0% 0% 100% 0%)', right: 'inset(0% 100% 0% 0%)', up: 'inset(100% 0% 0% 0%)', left: 'inset(0% 0% 0% 100%)', } as const const REVEALED_CLIP = 'inset(0% 0% 0% 0%)' const DIRECTIONS = ['down', 'right', 'up', 'left'] as const type Direction = (typeof DIRECTIONS)[number] const WIPE_SEQUENCE = PHOTOS.slice(1).map((photo, index) => { const segment = 1 / (PHOTOS.length - 1) const segmentStart = index * segment const from = segmentStart + segment * 0.18 const to = segmentStart + segment * 0.72 return { photo, direction: DIRECTIONS[index % DIRECTIONS.length], from, to, settled: to + (to - from) * 0.45, } }) const TOTAL_LABEL = String(PHOTOS.length).padStart(2, '0') // tune: raise to spread incoming title letters farther apart const ENTER_TRACKING = 0.16 const SETTLED_TRACKING = 0.02 // tune: raise to increase title entrance travel const ENTER_OFFSET = 48 const ENTER_EASE = cubicBezier(0.16, 1, 0.3, 1) const ENTER_FROM = { down: { axis: 'y', sign: -1 }, right: { axis: 'x', sign: -1 }, up: { axis: 'y', sign: 1 }, left: { axis: 'x', sign: 1 }, } as const function findScrollContext(element: HTMLElement): { scroller: HTMLElement | null clipper: HTMLElement | null } { let ancestor = element.parentElement let clipper: HTMLElement | null = null while (ancestor) { const { overflowY } = window.getComputedStyle(ancestor) if (overflowY !== 'visible') { if (!clipper) clipper = ancestor if ( (overflowY === 'auto' || overflowY === 'scroll') && ancestor.scrollHeight > ancestor.clientHeight ) { return { scroller: ancestor, clipper: clipper ?? ancestor } } } ancestor = ancestor.parentElement } return { scroller: null, clipper } } function useElementScrollProgress(targetRef: RefObject): { progress: MotionValue viewportHeight: number | null } { const progress = useMotionValue(0) const [viewportHeight, setViewportHeight] = useState(null) useEffect(() => { const target = targetRef.current if (!target) return const { scroller: scrollContainer, clipper } = findScrollContext(target) let animationFrame: number | null = null const updateProgress = () => { animationFrame = null const rect = target.getBoundingClientRect() const containerTop = scrollContainer ? scrollContainer.getBoundingClientRect().top : 0 const availableHeight = scrollContainer ? scrollContainer.clientHeight : window.innerHeight const nextViewportHeight = clipper && clipper !== scrollContainer ? Math.min(availableHeight, clipper.clientHeight) : availableHeight const cropped = !!clipper && clipper !== scrollContainer && clipper.clientHeight < rect.height const scrollDistance = Math.max(rect.height - nextViewportHeight, 1) const nextProgress = cropped ? 0 : Math.min(Math.max((containerTop - rect.top) / scrollDistance, 0), 1) setViewportHeight(nextViewportHeight) progress.set(nextProgress) } const requestUpdate = () => { if (animationFrame === null) { animationFrame = window.requestAnimationFrame(updateProgress) } } updateProgress() scrollContainer?.addEventListener('scroll', requestUpdate, { passive: true }) window.addEventListener('scroll', requestUpdate, { passive: true }) window.addEventListener('resize', requestUpdate) const observer = new ResizeObserver(requestUpdate) observer.observe(scrollContainer ?? clipper ?? document.documentElement) if (clipper && clipper !== scrollContainer) observer.observe(clipper) return () => { scrollContainer?.removeEventListener('scroll', requestUpdate) window.removeEventListener('scroll', requestUpdate) window.removeEventListener('resize', requestUpdate) observer.disconnect() if (animationFrame !== null) { window.cancelAnimationFrame(animationFrame) } } }, [progress, targetRef]) return { progress, viewportHeight } } function useWipeClip( progress: MotionValue, from: number, to: number, hidden: string, reduceMotion: boolean, ): MotionValue { const wipe = useTransform(progress, [from, to], [hidden, REVEALED_CLIP]) const snap = useTransform(progress, (value) => value < (from + to) / 2 ? hidden : REVEALED_CLIP, ) return reduceMotion ? snap : wipe } type WipeStep = (typeof WIPE_SEQUENCE)[number] function PhotoLayer({ step, progress, reduceMotion, }: { step: WipeStep progress: MotionValue reduceMotion: boolean }) { const clipPath = useWipeClip( progress, step.from, step.to, HIDDEN_CLIP[step.direction], reduceMotion, ) const enter = useTransform(progress, [step.from, step.settled], [0, 1], { ease: ENTER_EASE, }) return ( {} {step.photo.alt} ) } function FrameTitle({ title, enter, direction, }: { title: string enter?: MotionValue direction?: Direction }) { const atRest = useMotionValue(1) const arrival = enter ?? atRest const from = direction ? ENTER_FROM[direction] : null const tracking = useTransform( arrival, [0, 1], [ENTER_TRACKING, SETTLED_TRACKING], ) const letterSpacing = useTransform(tracking, (value) => `${value}em`) const trailing = useTransform(tracking, (value) => `${-value}em`) const offset = useTransform( arrival, [0, 1], [from ? ENTER_OFFSET * from.sign : 0, 0], ) return ( ) } export default function ScrollWipeGallery() { const wrapperRef = useRef(null) const shouldReduceMotion = useReducedMotion() ?? false const { progress: scrollYProgress, viewportHeight } = useElementScrollProgress(wrapperRef) const panelHeight = viewportHeight ? `${viewportHeight}px` : '100vh' const cueOpacity = useTransform(scrollYProgress, [0, 0.03], [1, 0]) const frameLabel = useTransform(scrollYProgress, (value) => { const passed = WIPE_SEQUENCE.filter( (step) => value >= (step.from + step.to) / 2, ).length return String(passed + 1).padStart(2, '0') }) return (
{}

{PHOTOS.map((photo) => photo.title).join('. ')}

{} {PHOTOS[0].alt}
{WIPE_SEQUENCE.map((step) => ( ))} {}
) } ``` --- ## Expanding Tabs Category: Navigation Slug: `expanding-tabs` URL: https://aicanvas.me/components/expanding-tabs A monochrome tab bar of icon circles. Click one and it expands into a labeled pill. Install (free account): ```bash npx shadcn@latest add @aicanvas/expanding-tabs ``` ```tsx 'use client' // npm install framer-motion @phosphor-icons/react /** * Displays a pill-shaped tab list with icon-only inactive tabs. * Selecting a tab expands it to reveal its label with layout animation. */ import { useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { Bell, CalendarBlank, EnvelopeSimple, MagnifyingGlass, } from '@phosphor-icons/react' const SPRING = { type: 'spring' as const, stiffness: 420, damping: 30, mass: 0.7 } const tabs = [ { label: 'Inbox', icon: EnvelopeSimple }, { label: 'Calendar', icon: CalendarBlank }, { label: 'Alerts', icon: Bell }, { label: 'Search', icon: MagnifyingGlass }, ] export default function ExpandingTabs() { const [activeTab, setActiveTab] = useState('Inbox') return (
{tabs.map((tab) => { const Icon = tab.icon const isActive = activeTab === tab.label return ( setActiveTab(tab.label)} whileHover={isActive ? undefined : { scale: 1.045 }} whileTap={{ scale: 0.94 }} transition={SPRING} className={`relative flex h-10 shrink-0 cursor-pointer items-center justify-start overflow-hidden rounded-full pl-2.5 outline-none focus-visible:ring-2 focus-visible:ring-black/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#E3E3E8] dark:focus-visible:ring-white/80 dark:focus-visible:ring-offset-[#0E0E0F] ${ isActive ? 'gap-2 bg-[#FCFCFD] pr-4 shadow-[0_7px_18px_rgba(32,32,36,0.14),0_1px_2px_rgba(32,32,36,0.08),inset_0_2px_0_rgba(255,255,255,0.98)] dark:bg-[#29292C] dark:shadow-[0_7px_18px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.06),inset_0_2px_0_rgba(255,255,255,0.18)]' : 'w-10 bg-[#F8F8FA] shadow-[0_3px_9px_rgba(32,32,36,0.09),0_1px_1px_rgba(32,32,36,0.06),inset_0_2px_0_rgba(255,255,255,0.92)] dark:bg-[#202023] dark:shadow-[0_3px_9px_rgba(0,0,0,0.25),inset_0_1px_0_rgba(255,255,255,0.035),inset_0_2px_0_rgba(255,255,255,0.15)]' }`} > {isActive && ( {tab.label} )} ) })}
) } ``` --- ## Delete Button Category: Buttons & Toggles Slug: `delete-button` URL: https://aicanvas.me/components/delete-button A red button that starts a five-second delete countdown, with a one-tap undo. Install (free account): ```bash npx shadcn@latest add @aicanvas/delete-button ``` ```tsx 'use client' // npm install framer-motion @phosphor-icons/react /** * Presents a staged account deletion control with a five-second countdown. * The pending state offers undo before showing a temporary confirmation. */ import { useEffect, useState } from 'react' import { AnimatePresence, motion, type Transition } from 'framer-motion' import { ArrowUUpLeft, Check } from '@phosphor-icons/react' type DeletionState = 'default' | 'counting' | 'deleted' const pillTransition: Transition = { type: 'spring', stiffness: 420, damping: 30, mass: 0.7, } export default function DeleteButton() { const [deletionState, setDeletionState] = useState('default') const [countdown, setCountdown] = useState(5) useEffect(() => { if (deletionState !== 'counting') return const intervalId = window.setInterval(() => { setCountdown((current) => { if (current <= 1) { setDeletionState('deleted') return 0 } return current - 1 }) }, 1000) return () => window.clearInterval(intervalId) }, [deletionState]) useEffect(() => { if (deletionState !== 'deleted') return const resetId = window.setTimeout(() => { setCountdown(5) setDeletionState('default') }, 2800) return () => window.clearTimeout(resetId) }, [deletionState]) const beginDeletion = () => { setCountdown(5) setDeletionState('counting') } const undoDeletion = () => { setDeletionState('default') setCountdown(5) } return (
{deletionState === 'default' && ( Delete Account )} {deletionState === 'counting' && ( Cancel Deletion )} {deletionState === 'deleted' && ( Account Deleted )}
) } ``` --- ## Mood Tracker Category: Cards & Modals Slug: `mood-tracker` URL: https://aicanvas.me/components/mood-tracker A feelings check-in card with six expressive faces. Drag the segmented slider and the face, label, and liquid color wash shift with your mood. Install (free account): ```bash npx shadcn@latest add @aicanvas/mood-tracker ``` ```tsx 'use client' // npm install framer-motion // font: Manrope /** * Presents a weekly mood tracker with an editable daily rating. * Selecting a mood animates the chart, summary, and current-day control. */ import { useState, useRef, useEffect, useLayoutEffect, useMemo, useId, useCallback, } from 'react' import { motion, AnimatePresence, useReducedMotion, } from 'framer-motion' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect type Theme = 'light' | 'dark' function readTheme(el: HTMLElement | null): Theme { if (typeof document === 'undefined') return 'dark' const card = el?.closest('[data-card-theme]') if (card) return card.classList.contains('dark') ? 'dark' : 'light' return document.documentElement.classList.contains('dark') ? 'dark' : 'light' } function useTheme(rootRef: React.RefObject): Theme { const [theme, setTheme] = useState(() => readTheme(rootRef.current)) useIsomorphicLayoutEffect(() => { const el = rootRef.current setTheme(readTheme(el)) if (typeof document === 'undefined') return const update = () => setTheme(readTheme(el)) const observer = new MutationObserver(update) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }) const card = el?.closest('[data-card-theme]') if (card) { observer.observe(card, { attributes: true, attributeFilter: ['class', 'data-card-theme'], }) } return () => observer.disconnect() }, [rootRef]) return theme } function clamp(n: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, n)) } function hexToRgb(hex: string): [number, number, number] { const h = hex.replace('#', '') return [ parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), ] } function rgbToHex([r, g, b]: [number, number, number]): string { const c = (v: number) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0') return `#${c(r)}${c(g)}${c(b)}` } function lerpHex(a: string, b: string, t: number): string { const ca = hexToRgb(a) const cb = hexToRgb(b) return rgbToHex([ ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t, ]) } function tintHex(hex: string, t: number): string { return lerpHex(hex, '#FFFFFF', t) } const DARK_NEUTRAL = '#1B1B22' function shadeHex(hex: string, t: number): string { return lerpHex(hex, DARK_NEUTRAL, t) } function inkHex(hex: string): string { return lerpHex(hex, '#14140F', 0.82) } interface FaceProps { size: number } const STROKE_W = 2.6 function eyeWhite() { return '#FFFFFF' } function FrustratedFace({ size }: FaceProps) { const ink = '#5A1E0E' return ( {} {} {} ) } function SurprisedFace({ size }: FaceProps) { const body = '#E5A85E' const ink = '#6B3F12' const lobes = 9 let d = '' for (let i = 0; i < lobes; i++) { const a0 = (i / lobes) * Math.PI * 2 const a1 = ((i + 1) / lobes) * Math.PI * 2 const am = (a0 + a1) / 2 const rIn = 21 const rOut = 26 const x0 = 32 + Math.cos(a0) * rIn const y0 = 32 + Math.sin(a0) * rIn const xm = 32 + Math.cos(am) * rOut const ym = 32 + Math.sin(am) * rOut const x1 = 32 + Math.cos(a1) * rIn const y1 = 32 + Math.sin(a1) * rIn if (i === 0) d += `M ${x0.toFixed(1)} ${y0.toFixed(1)} ` d += `Q ${xm.toFixed(1)} ${ym.toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)} ` } d += 'Z' return ( {} {} ) } function HappyFace({ size }: FaceProps) { const ink = '#6E5806' return ( {} {} {} ) } function UneasyFace({ size }: FaceProps) { const ink = '#3F551A' const d = 'M32 8 C45 8 52 16 53 27 C54 37 50 44 53 50 C49 56 40 55 32 56 ' + 'C24 57 14 56 11 49 C13 43 10 36 11 27 C13 16 19 8 32 8 Z' return ( {} {} ) } function SadFace({ size }: FaceProps) { const ink = '#1E5663' const d = 'M9 40 C9 21 22 8 32 8 C42 8 55 21 55 40 L55 50 Q55 56 49 56 L15 56 Q9 56 9 50 Z' return ( {} {} {} ) } function AnxiousFace({ size }: FaceProps) { const ink = '#4A2A66' const d = 'M32 7 Q39 7 43 13 L51 21 Q57 25 57 32 Q57 39 51 43 L43 51 Q39 57 32 57 ' + 'Q25 57 21 51 L13 43 Q7 39 7 32 Q7 25 13 21 L21 13 Q25 7 32 7 Z' return ( {} {} {} ) } interface Mood { id: string label: string color: string Svg: (props: FaceProps) => React.ReactElement } // customize: replace the mood labels and colors below const MOODS: readonly Mood[] = [ { id: 'frustrated', label: 'Frustrated', color: '#CB5E3E', Svg: FrustratedFace }, { id: 'surprised', label: 'Surprised', color: '#E5A85E', Svg: SurprisedFace }, { id: 'happy', label: 'Happy', color: '#F2D44E', Svg: HappyFace }, { id: 'uneasy', label: 'Uneasy', color: '#A9C95E', Svg: UneasyFace }, { id: 'sad', label: 'Sad', color: '#8AC7D8', Svg: SadFace }, { id: 'anxious', label: 'Anxious', color: '#BA8FD4', Svg: AnxiousFace }, ] // tune: change to select the initial mood const INITIAL_INDEX = 2 interface Blob { x: number y: number size: number tint: 'base' | 'soft' dx: number dy: number dur: number } const BLOBS: readonly Blob[] = [ { x: 24, y: 30, size: 72, tint: 'base', dx: 8, dy: 6, dur: 11 }, { x: 76, y: 26, size: 66, tint: 'soft', dx: 7, dy: 8, dur: 13 }, { x: 34, y: 76, size: 78, tint: 'soft', dx: 9, dy: 5, dur: 15 }, { x: 80, y: 74, size: 60, tint: 'base', dx: 6, dy: 9, dur: 12 }, { x: 52, y: 50, size: 54, tint: 'base', dx: 10, dy: 7, dur: 17 }, ] export default function MoodTracker() { const rootRef = useRef(null) const theme = useTheme(rootRef) const isDark = theme === 'dark' const reduced = useReducedMotion() ?? false const uid = useId() const sliderId = `${uid}-mood` const labelId = `${uid}-label` const [index, setIndex] = useState(INITIAL_INDEX) const mood = MOODS[index] const CONFIRM_GREEN = '#3FA66A' const [saved, setSaved] = useState(false) const savedTimer = useRef | null>(null) useEffect( () => () => { if (savedTimer.current) clearTimeout(savedTimer.current) }, [], ) const onSave = useCallback(() => { if (saved) return setSaved(true) savedTimer.current = setTimeout(() => setSaved(false), 1600) }, [saved]) const baseTint = useMemo( () => (isDark ? shadeHex(mood.color, 0.5) : tintHex(mood.color, 0.5)), [mood.color, isDark], ) const softTint = useMemo( () => (isDark ? shadeHex(mood.color, 0.44) : tintHex(mood.color, 0.66)), [mood.color, isDark], ) const panelWash = useMemo( () => (isDark ? shadeHex(mood.color, 0.56) : tintHex(mood.color, 0.62)), [mood.color, isDark], ) const panelInk = useMemo( () => (isDark ? tintHex(mood.color, 0.88) : inkHex(mood.color)), [mood.color, isDark], ) const cardTint = useMemo( () => isDark ? lerpHex('#1A1A1E', mood.color, 0.1) : lerpHex('#FFFFFF', mood.color, 0.06), [mood.color, isDark], ) const deepAccent = useMemo(() => lerpHex(mood.color, '#000000', 0.18), [mood.color]) const setMood = useCallback((i: number) => { setIndex((prev) => { const next = clamp(i, 0, MOODS.length - 1) return next === prev ? prev : next }) }, []) const onSliderKey = useCallback( (e: React.KeyboardEvent) => { let next = index switch (e.key) { case 'ArrowLeft': case 'ArrowDown': next = index - 1 break case 'ArrowRight': case 'ArrowUp': next = index + 1 break case 'Home': next = 0 break case 'End': next = MOODS.length - 1 break default: return } e.preventDefault() setMood(next) }, [index, setMood], ) const trackRef = useRef(null) const draggingRef = useRef(false) const indexFromClientX = useCallback((clientX: number): number => { const el = trackRef.current if (!el) return index const rect = el.getBoundingClientRect() if (rect.width === 0) return index const ratio = clamp((clientX - rect.left) / rect.width, 0, 1) return clamp(Math.round(ratio * MOODS.length - 0.5), 0, MOODS.length - 1) }, [index]) const onTrackPointerDown = useCallback( (e: React.PointerEvent) => { draggingRef.current = true e.currentTarget.setPointerCapture(e.pointerId) setMood(indexFromClientX(e.clientX)) }, [indexFromClientX, setMood], ) const onTrackPointerMove = useCallback( (e: React.PointerEvent) => { if (!draggingRef.current) return setMood(indexFromClientX(e.clientX)) }, [indexFromClientX, setMood], ) const onTrackPointerUp = useCallback( (e: React.PointerEvent) => { draggingRef.current = false if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId) } }, [], ) const handlePct = ((index + 0.5) / MOODS.length) * 100 const titleColor = isDark ? '#F2F2F0' : '#16160F' const subColor = isDark ? '#8A8A86' : '#6B6B62' const legendIdle = isDark ? '#26262C' : '#F1F1EC' const cardShadow = isDark ? '0 24px 60px rgba(0,0,0,0.5)' : '0 24px 60px rgba(20,20,18,0.12)' const panelEdge = isDark ? `inset 0 0 0 1px ${tintHex(mood.color, 0.4)}33` : `inset 0 0 0 1px ${inkHex(mood.color)}1F` const enter = reduced ? { initial: { opacity: 1 }, animate: { opacity: 1 } } : { initial: { opacity: 0, y: 16 }, animate: { opacity: 1, y: 0 }, } const facePop = reduced ? { initial: { opacity: 1 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : { initial: { opacity: 0, scale: 0.78, y: 8 }, animate: { opacity: 1, scale: 1, y: 0 }, exit: { opacity: 0, scale: 0.78, y: -8 }, } const BigSvg = mood.Svg return (
{}
{}
{}
How are you feeling? Today · 6-day streak
{} {saved ? ( ) : ( )} {saved ? 'Recorded' : 'Save'}
{}
{}
{BLOBS.map((blob, i) => { const color = blob.tint === 'base' ? baseTint : softTint const driftAnim = reduced ? { x: 0, y: 0 } : { x: [0, blob.dx, -blob.dx * 0.6, 0], y: [0, -blob.dy, blob.dy * 0.7, 0], } return ( ) })}
{}
{}
{mood.label}
{}
Drag to set your mood
{}
{MOODS.map((m) => ( ))}
{}
{} {}
{MOODS.map((m, i) => { const active = i === index return ( ) })}
) } ``` --- ## Crypto Swap Category: Widgets Slug: `crypto-swap` URL: https://aicanvas.me/components/crypto-swap A crypto token-swap widget with live exchange rates, price impact, and an animated swap button. Install (free account): ```bash npx shadcn@latest add @aicanvas/crypto-swap ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion react-dom /** * Presents a token swap card with live quotes, token pickers, and configurable slippage. * Selecting a duplicate token flips the pair, while submission animates confirmation. */ import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo, useId, forwardRef, } from 'react' import { createPortal } from 'react-dom' import { motion, AnimatePresence, useMotionValue, useTransform, animate, useReducedMotion, } from 'framer-motion' import { CaretDown, CaretUp, ArrowsDownUp, ArrowsLeftRight, CheckCircle, GearSix, } from '@phosphor-icons/react' type Theme = 'light' | 'dark' function readTheme(el: HTMLElement | null): Theme { if (typeof document === 'undefined') return 'dark' const card = el?.closest('[data-card-theme]') if (card) return card.classList.contains('dark') ? 'dark' : 'light' return document.documentElement.classList.contains('dark') ? 'dark' : 'light' } function useTheme(rootRef: React.RefObject): { theme: Theme } { const [theme, setTheme] = useState(() => readTheme(rootRef.current)) useEffect(() => { const el = rootRef.current setTheme(readTheme(el)) if (typeof document === 'undefined') return const update = () => setTheme(readTheme(el)) const observer = new MutationObserver(update) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }) const card = el?.closest('[data-card-theme]') if (card) { observer.observe(card, { attributes: true, attributeFilter: ['class', 'data-card-theme'], }) } return () => observer.disconnect() }, [rootRef]) return { theme } } interface Token { symbol: string name: string usdPrice: number basePrice: number balance: number change24h: number gradient: [string, string] } type SwapStatus = 'idle' | 'swapping' | 'success' type Side = 'sell' | 'buy' const INITIAL_TOKENS: Token[] = [ { symbol: 'ETH', name: 'Ethereum', usdPrice: 3412.55, basePrice: 3412.55, balance: 52.32, change24h: 2.41, gradient: ['#627EEA', '#8FA7FF'] }, { symbol: 'BTC', name: 'Bitcoin', usdPrice: 67840.12, basePrice: 67840.12, balance: 1.284, change24h: 1.07, gradient: ['#F7931A', '#FFC56E'] }, { symbol: 'USDC', name: 'USD Coin', usdPrice: 1.0, basePrice: 1.0, balance: 12480.5, change24h: 0.01, gradient: ['#2775CA', '#4FA3FF'] }, { symbol: 'USDT', name: 'Tether', usdPrice: 1.0, basePrice: 1.0, balance: 8920.0, change24h: -0.02, gradient: ['#26A17B', '#54D6A8'] }, { symbol: 'SOL', name: 'Solana', usdPrice: 168.42, basePrice: 168.42, balance: 312.7, change24h: 5.83, gradient: ['#9945FF', '#19FB9B'] }, { symbol: 'AAVE', name: 'Aave', usdPrice: 102.18, basePrice: 102.18, balance: 47.9, change24h: -1.64, gradient: ['#B6509E', '#2EBAC6'] }, { symbol: 'MATIC', name: 'Polygon', usdPrice: 0.7234, basePrice: 0.7234, balance: 9430.2, change24h: -3.12, gradient: ['#8247E5', '#A77BFF'] }, { symbol: 'LINK', name: 'Chainlink', usdPrice: 14.86, basePrice: 14.86, balance: 880.4, change24h: 0.42, gradient: ['#2A5ADA', '#6E97FF'] }, ] const PRICE_DRIFT_MS = 3000 // tune: raise to extend the swapping state const SWAP_SWAPPING_MS = 900 // tune: raise to extend the success state const SWAP_SUCCESS_MS = 1600 // tune: raise to reduce quoted price impact const POOL_DEPTH_USD = 25_000_000 // tune: raise to increase the minimum quoted price impact const IMPACT_FLOOR = 0.0005 // tune: raise to increase the maximum quoted price impact const IMPACT_CAP = 0.05 // tune: raise to increase the baseline network fee const NETWORK_FEE_BASE_USD = 2.4 interface SlippageOption { label: string value: number } const SLIPPAGE_OPTIONS: SlippageOption[] = [ { label: 'Auto', value: 0.005 }, { label: '0.1%', value: 0.001 }, { label: '0.5%', value: 0.005 }, { label: '1.0%', value: 0.01 }, ] const DEFAULT_SLIPPAGE = SLIPPAGE_OPTIONS[0].value // tune: change to set the initial sell amount const DEFAULT_SELL_INPUT = '1' const SUCCESS_SPARKS: number[] = Array.from( { length: 6 }, (_, i) => (i / 6) * Math.PI * 2, ) function clamp(n: number, min: number, max: number): number { return Math.min(max, Math.max(min, n)) } interface TrendColors { up: string down: string flat: string } function trendColors(isDark: boolean): TrendColors { return isDark ? { up: '#16C784', down: '#EA3943', flat: '#8E8E84' } : { up: '#0FA968', down: '#E5484D', flat: '#6E6E66' } } function formatUsd(value: number): string { if (!Number.isFinite(value)) return '$0.00' return value.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }) } function adaptiveFractionDigits(abs: number): number { if (abs >= 1) return 2 if (abs >= 0.01) return 4 return 8 } function formatTokenAmount(value: number): string { if (!Number.isFinite(value) || value === 0) return '0' return value.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: adaptiveFractionDigits(Math.abs(value)), }) } function capForInput(value: number): string { if (!Number.isFinite(value) || value === 0) return '' const digits = adaptiveFractionDigits(Math.abs(value)) const factor = 10 ** digits const truncated = Math.trunc(value * factor) / factor if (truncated === 0) { return String(Number(value.toPrecision(4))) } return String(truncated) } function formatRate(value: number): string { if (!Number.isFinite(value) || value === 0) return '0' const abs = Math.abs(value) let maxFrac: number if (abs >= 1000) maxFrac = 2 else if (abs >= 1) maxFrac = 4 else if (abs >= 0.01) maxFrac = 6 else maxFrac = 8 return value.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: maxFrac, }) } function formatChange(value: number): string { return `${Math.abs(value).toFixed(2)}%` } function sanitizeDecimal(raw: string): string { let cleaned = raw.replace(/[^0-9.]/g, '') const firstDot = cleaned.indexOf('.') if (firstDot !== -1) { cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, '') } const dot = cleaned.indexOf('.') if (dot !== -1) { cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1, dot + 9) } if (cleaned.length > 1 && cleaned[0] === '0' && cleaned[1] !== '.') { cleaned = cleaned.replace(/^0+/, '') if (cleaned === '' || cleaned[0] === '.') cleaned = '0' + cleaned } return cleaned } // tune: raise to widen the neutral trend band const TREND_FLAT_EPS = 0.05 function classifyTrend(change: number): 'up' | 'down' | 'flat' { if (change > TREND_FLAT_EPS) return 'up' if (change < -TREND_FLAT_EPS) return 'down' return 'flat' } // Coin marks are adapted from spothq/cryptocurrency-icons under CC0-1.0. // Trademarks belong to their respective projects. const COIN_SVGS: Record = { ETH: ( ), BTC: ( ), USDC: ( ), USDT: ( ), SOL: ( ), AAVE: ( ), MATIC: ( ), LINK: ( ), } function CoinIcon({ token, size }: { token: Token; size: number }) { const mark = COIN_SVGS[token.symbol] if (!mark) return return ( {mark} ) } function CoinDisc({ token, size }: { token: Token; size: number }) { const letters = token.symbol.slice(0, token.symbol.length <= 3 ? 1 : 2) return ( {letters} ) } function TrendChip({ token, isDark }: { token: Token; isDark: boolean }) { const dir = classifyTrend(token.change24h) const colors = trendColors(isDark) const color = dir === 'up' ? colors.up : dir === 'down' ? colors.down : colors.flat return ( {dir === 'up' && } {dir === 'down' && } {formatChange(token.change24h)} ) } function AnimatedNumber({ value, format, className, style, reduced, }: { value: number format: (n: number) => string className?: string style?: React.CSSProperties reduced: boolean }) { const mv = useMotionValue(value) const display = useTransform(mv, (n) => format(n)) const last = useRef(value) useEffect(() => { if (reduced) { mv.set(value) last.current = value return } // tune: raise the threshold to snap more price changes const rel = Math.abs(value - last.current) / (Math.abs(last.current) || 1) if (rel < 0.001) { mv.set(value) last.current = value return } last.current = value const controls = animate(mv, value, { duration: 0.5, ease: [0.22, 1, 0.36, 1], }) return () => controls.stop() }, [value, mv, reduced]) return ( {display} ) } // tune: raise to make token rows taller const ROW_HEIGHT = 48 // tune: raise to show more token rows before scrolling const VISIBLE_ROWS = 3 // tune: raise to increase spacing between token rows const ROW_GAP = 4 // tune: raise to increase the dropdown's vertical padding const PANEL_PAD_Y = 12 const LIST_DEFAULT_HEIGHT = ROW_HEIGHT * VISIBLE_ROWS + ROW_GAP * (VISIBLE_ROWS - 1) const PANEL_DEFAULT_HEIGHT = LIST_DEFAULT_HEIGHT + PANEL_PAD_Y // tune: raise to move the dropdown farther from its trigger const DROPDOWN_GAP = 8 type Placement = 'down' | 'up' interface PopoverPosition { top: number left: number width: number maxHeight: number placement: Placement } function usePopoverPosition( triggerRef: React.RefObject | undefined, boundRef: React.RefObject | undefined, open: boolean, ): PopoverPosition | null { const [pos, setPos] = useState(null) useLayoutEffect(() => { if (!open) { setPos(null) return } let last: PopoverPosition | null = null // tune: raise to ignore larger dropdown position shifts const EPS = 0.5 const measure = (): PopoverPosition | null => { const pill = triggerRef?.current const card = boundRef?.current if (!pill || !card || typeof window === 'undefined') return null const pillRect = pill.getBoundingClientRect() const cardRect = card.getBoundingClientRect() const width = cardRect.width const left = cardRect.left const spaceBelow = cardRect.bottom - pillRect.bottom - DROPDOWN_GAP const spaceAbove = pillRect.top - cardRect.top - DROPDOWN_GAP const cardHeight = cardRect.height const desired = Math.min(PANEL_DEFAULT_HEIGHT, cardHeight) let placement: Placement if (desired <= spaceBelow) placement = 'down' else if (desired <= spaceAbove) placement = 'up' else placement = spaceBelow >= spaceAbove ? 'down' : 'up' let top: number let maxHeight: number if (placement === 'down') { top = pillRect.bottom + DROPDOWN_GAP maxHeight = Math.min(desired, Math.max(0, spaceBelow)) } else { maxHeight = Math.min(desired, Math.max(0, spaceAbove)) top = pillRect.top - DROPDOWN_GAP - maxHeight } maxHeight = Math.min(maxHeight, cardHeight) return { top, left, width, maxHeight, placement } } const changed = ( prev: PopoverPosition | null, next: PopoverPosition, ): boolean => { if (!prev) return true if (prev.placement !== next.placement) return true return ( Math.abs(prev.top - next.top) > EPS || Math.abs(prev.left - next.left) > EPS || Math.abs(prev.width - next.width) > EPS || Math.abs(prev.maxHeight - next.maxHeight) > EPS ) } const compute = () => { const next = measure() if (!next) return if (changed(last, next)) { last = next setPos(next) } } compute() const card = boundRef?.current ?? null const pill = triggerRef?.current ?? null let ro: ResizeObserver | null = null if (typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(() => compute()) if (card) ro.observe(card) if (pill) ro.observe(pill) } window.addEventListener('scroll', compute, true) window.addEventListener('resize', compute) return () => { ro?.disconnect() window.removeEventListener('scroll', compute, true) window.removeEventListener('resize', compute) } }, [triggerRef, boundRef, open]) return pos } function TokenPicker({ tokens, activeSymbol, isDark, reduced, listId, optionId, triggerRef, boundRef, onSelect, }: { tokens: Token[] activeSymbol: string isDark: boolean reduced: boolean listId: string optionId: (symbol: string) => string triggerRef?: React.RefObject boundRef?: React.RefObject onSelect: (symbol: string) => void }) { const position = usePopoverPosition(triggerRef, boundRef, true) const activeStart = Math.max( 0, tokens.findIndex((t) => t.symbol === activeSymbol), ) const [activeIndex, setActiveIndex] = useState(activeStart) const optionRefs = useRef<(HTMLButtonElement | null)[]>([]) const panelRef = useRef(null) const safeIndex = Math.min(activeIndex, tokens.length - 1) useEffect(() => { if (!position) return optionRefs.current[safeIndex]?.focus({ preventScroll: true }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [position !== null]) useEffect(() => { const trigger = triggerRef?.current const panel = panelRef.current return () => { if (panel && panel.contains(document.activeElement)) { trigger?.focus({ preventScroll: true }) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const focusIndex = useCallback( (i: number) => { const clamped = clamp(i, 0, tokens.length - 1) setActiveIndex(clamped) optionRefs.current[clamped]?.focus({ preventScroll: true }) }, [tokens.length], ) const onPanelKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault() focusIndex(safeIndex + 1) break case 'ArrowUp': e.preventDefault() focusIndex(safeIndex - 1) break case 'Home': e.preventDefault() focusIndex(0) break case 'End': e.preventDefault() focusIndex(tokens.length - 1) break case 'Enter': case ' ': case 'Spacebar': e.preventDefault() onSelect(tokens[safeIndex].symbol) break case 'Tab': { const last = tokens.length - 1 if (e.shiftKey && safeIndex === 0) { e.preventDefault() focusIndex(last) } else if (!e.shiftKey && safeIndex === last) { e.preventDefault() focusIndex(0) } else { e.preventDefault() focusIndex(safeIndex + (e.shiftKey ? -1 : 1)) } break } default: break } }, [focusIndex, onSelect, safeIndex, tokens], ) const surface = isDark ? '#26262A' : '#FFFFFF' const titleColor = isDark ? '#ECECEC' : '#16160F' const subColor = isDark ? '#8A8A86' : '#6B6B62' const rowHover = isDark ? '#2F2F34' : '#F2F2EF' const activeFill = isDark ? 'rgba(174,182,236,0.16)' : 'rgba(154,166,234,0.16)' const scrollThumb = isDark ? '#46464C' : '#C9C9BC' const SCROLL_BASE = 'overflow-y-auto [scrollbar-gutter:stable] ' + '[&::-webkit-scrollbar]:w-2 ' + '[&::-webkit-scrollbar-track]:bg-transparent ' + '[&::-webkit-scrollbar-thumb]:rounded-full' const scrollClass = isDark ? `${SCROLL_BASE} [&::-webkit-scrollbar-thumb]:bg-[#46464C] [&::-webkit-scrollbar-thumb:hover]:bg-[#5A5A62]` : `${SCROLL_BASE} [&::-webkit-scrollbar-thumb]:bg-[#C9C9BC] [&::-webkit-scrollbar-thumb:hover]:bg-[#B5B5A6]` const scrollStyle: React.CSSProperties = { scrollbarWidth: 'thin', scrollbarColor: `${scrollThumb} transparent`, } const panelTransition = reduced ? { duration: 0.15 } : ({ type: 'spring', stiffness: 360, damping: 30 } as const) const renderList = (listStyle: React.CSSProperties, className: string) => (
{tokens.map((t, i) => { const isActive = t.symbol === activeSymbol const isCurrent = i === safeIndex return ( ) })}
) if (typeof document === 'undefined' || !position) return null const listMaxHeight = Math.max(0, position.maxHeight - PANEL_PAD_Y) return createPortal( {renderList({ maxHeight: listMaxHeight }, 'min-h-0 flex-1')} , document.body, ) } function SwapCard({ side, token, sellInput, buyAmount, hasAmount, isDark, reduced, locked, pickerOpen, tokens, cardRef, onInputChange, onMax, onTogglePicker, onSelect, }: { side: Side token: Token sellInput: string buyAmount: number hasAmount: boolean isDark: boolean reduced: boolean locked: boolean pickerOpen: boolean tokens: Token[] cardRef: React.RefObject onInputChange?: (value: string) => void onMax?: () => void onTogglePicker: () => void onSelect: (symbol: string) => void }) { const isSell = side === 'sell' const pillRef = useRef(null) const idBase = useId() const listId = `${idBase}-list` const optionId = useCallback( (symbol: string) => `${idBase}-opt-${symbol}`, [idBase], ) const cardBg = isDark ? '#1D1D20' : '#F2F2EF' const labelColor = isDark ? '#8A8A86' : '#6B6B62' const amountColor = isDark ? '#ECECEC' : '#16160F' const usdColor = isDark ? '#8A8A86' : '#6E6E66' return (
{isSell ? 'Sell' : 'Buy'}
{}
{isSell ? ( onInputChange?.(e.target.value)} inputMode="decimal" placeholder="0" disabled={locked} className="w-full min-w-0 bg-transparent font-bold tabular-nums outline-none disabled:cursor-not-allowed" style={{ color: amountColor, fontSize: 'clamp(20px, 6vw, 28px)', lineHeight: 1.1, }} aria-label="Sell amount" /> ) : (
)}
{}
{}
{isSell && (
{formatTokenAmount(token.balance)} {token.symbol}
)}
{} {pickerOpen && ( )}
) } const TokenPill = forwardRef< HTMLButtonElement, { token: Token isDark: boolean open: boolean reduced: boolean disabled: boolean listId: string onClick: () => void } >(function TokenPill({ token, isDark, open, reduced, disabled, listId, onClick }, ref) { const pillBg = isDark ? '#2A2A2E' : '#FFFFFF' const pillHoverBg = isDark ? '#34343A' : '#F4F4F1' const caretColor = isDark ? '#8A8A86' : '#6B6B62' return ( {token.symbol} {} ) }) interface SwapQuote { sellToken: Token buyToken: Token midRate: number priceImpact: number networkFeeUsd: number buyAmount: number minReceived: number slippage: number } function DetailsPanel({ quote, hasAmount, isDark, reduced, slippageOptions, slippage, onSlippageChange, }: { quote: SwapQuote hasAmount: boolean isDark: boolean reduced: boolean slippageOptions: SlippageOption[] slippage: number onSlippageChange: (value: number) => void }) { const [expanded, setExpanded] = useState(false) const [inverted, setInverted] = useState(false) const [slipOpen, setSlipOpen] = useState(false) const slipRef = useRef(null) const labelColor = isDark ? '#8A8A86' : '#6B6B62' const valueColor = isDark ? '#ECECEC' : '#16160F' const panelBg = isDark ? '#1D1D20' : '#F2F2EF' const rowHoverFill = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(20,20,18,0.045)' const dividerColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(20,20,18,0.08)' const chipBg = isDark ? '#26262A' : '#FFFFFF' const chipHoverBg = isDark ? '#303036' : '#F4F4F1' const chipShadow = isDark ? '0 1px 2px rgba(0,0,0,0.3)' : '0 1px 2px rgba(20,20,18,0.08)' const chipActiveBg = isDark ? 'rgba(174,182,236,0.16)' : 'rgba(154,166,234,0.16)' const chipActiveText = isDark ? '#C7CDF2' : '#4A57B8' const colors = trendColors(isDark) const impactPct = quote.priceImpact * 100 const amber = isDark ? '#F5A524' : '#C77700' const impactColor = impactPct > 2 ? colors.down : impactPct >= 0.5 ? amber : colors.up const fromTok = inverted ? quote.buyToken : quote.sellToken const toTok = inverted ? quote.sellToken : quote.buyToken const rate = inverted ? 1 / quote.midRate : quote.midRate useEffect(() => { if (!slipOpen) return function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setSlipOpen(false) } function onPointer(e: MouseEvent | TouchEvent) { if (slipRef.current && !slipRef.current.contains(e.target as Node)) { setSlipOpen(false) } } document.addEventListener('keydown', onKey) document.addEventListener('mousedown', onPointer) document.addEventListener('touchstart', onPointer) return () => { document.removeEventListener('keydown', onKey) document.removeEventListener('mousedown', onPointer) document.removeEventListener('touchstart', onPointer) } }, [slipOpen]) const activeSlipLabel = slippageOptions.find((o) => o.value === slippage)?.label ?? '0.5%' const slipDisplay = activeSlipLabel === 'Auto' ? `Auto (${(slippage * 100).toFixed(1)}%)` : `${(slippage * 100).toFixed(1)}%` const rateTransition = reduced ? { duration: 0 } : { duration: 0.26, ease: [0.22, 1, 0.36, 1] as const } return (
{}
{} {expanded && ( {}
Price impact {hasAmount ? `-${impactPct.toFixed(2)}%` : '—'}
Network fee `~${formatUsd(n)}`} reduced={reduced} className="text-[12px] font-semibold tabular-nums" style={{ color: valueColor }} />
{}
Max slippage {slipOpen && ( {slippageOptions.map((o) => { const active = o.value === slippage return ( ) })} )}
Min received {hasAmount ? `${formatTokenAmount(quote.minReceived)} ${quote.buyToken.symbol}` : '—'}
)}
) } export default function CryptoSwap() { const rootRef = useRef(null) const { theme } = useTheme(rootRef) const isDark = theme === 'dark' const prefersReduced = useReducedMotion() const reduced = prefersReduced ?? false const [tokens, setTokens] = useState(INITIAL_TOKENS) const [sellSymbol, setSellSymbol] = useState('ETH') const [buySymbol, setBuySymbol] = useState('BTC') const [sellInput, setSellInput] = useState(DEFAULT_SELL_INPUT) const [picker, setPicker] = useState(null) const [status, setStatus] = useState('idle') const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE) const [networkFeeUsd, setNetworkFeeUsd] = useState(NETWORK_FEE_BASE_USD) const cardRef = useRef(null) const flipAngle = useMotionValue(0) const flipCount = useRef(0) const flipControls = useRef | null>(null) useEffect(() => () => flipControls.current?.stop(), []) const sellToken = useMemo( () => tokens.find((t) => t.symbol === sellSymbol) ?? tokens[0], [tokens, sellSymbol], ) const buyToken = useMemo( () => tokens.find((t) => t.symbol === buySymbol) ?? tokens[1], [tokens, buySymbol], ) const sellAmount = parseFloat(sellInput) || 0 const sellUsd = sellAmount * sellToken.usdPrice const midRate = buyToken.usdPrice > 0 ? sellToken.usdPrice / buyToken.usdPrice : 0 const idealBuy = sellAmount * midRate const priceImpact = sellAmount > 0 ? clamp(sellUsd / POOL_DEPTH_USD, IMPACT_FLOOR, IMPACT_CAP) : 0 const buyAmount = idealBuy * (1 - priceImpact) const minReceived = buyAmount * (1 - slippage) const hasAmount = sellAmount > 0 const locked = status !== 'idle' const quote: SwapQuote = useMemo( () => ({ sellToken, buyToken, midRate, priceImpact, networkFeeUsd, buyAmount, minReceived, slippage, }), [sellToken, buyToken, midRate, priceImpact, networkFeeUsd, buyAmount, minReceived, slippage], ) useEffect(() => { if (reduced) return const id = window.setInterval(() => { setTokens((prev) => prev.map((t) => { if (t.symbol === 'USDC' || t.symbol === 'USDT') return t const drift = 1 + (Math.random() - 0.5) * 0.006 // tune: raise the multiplier to increase price drift const next = t.usdPrice * drift return { ...t, usdPrice: next, change24h: ((next - t.basePrice) / t.basePrice) * 100, } }), ) // tune: widen the jitter and bounds to increase fee variation setNetworkFeeUsd( clamp(NETWORK_FEE_BASE_USD + (Math.random() - 0.5) * 1.6, 1.6, 3.2), ) }, PRICE_DRIFT_MS) return () => window.clearInterval(id) }, [reduced]) useEffect(() => { if (!picker) return function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setPicker(null) } function onPointer(e: MouseEvent | TouchEvent) { const target = e.target as Node | null const inCard = cardRef.current?.contains(target ?? null) ?? false const inDropdown = target instanceof Element && target.closest('[data-token-dropdown]') !== null if (!inCard && !inDropdown) { setPicker(null) } } document.addEventListener('keydown', onKey) document.addEventListener('mousedown', onPointer) document.addEventListener('touchstart', onPointer) return () => { document.removeEventListener('keydown', onKey) document.removeEventListener('mousedown', onPointer) document.removeEventListener('touchstart', onPointer) } }, [picker]) const handleFlip = useCallback(() => { if (locked) return setSellSymbol(buySymbol) setBuySymbol(sellSymbol) setSellInput(buyAmount > 0 ? capForInput(buyAmount) : '') flipCount.current += 1 const target = flipCount.current * 180 flipControls.current?.stop() if (reduced) { flipAngle.set(target) flipControls.current = null } else { flipControls.current = animate(flipAngle, target, { type: 'spring', stiffness: 320, damping: 22, }) } }, [locked, buySymbol, sellSymbol, buyAmount, flipAngle, reduced]) const handleSelect = useCallback( (side: Side, symbol: string) => { if (locked) return setPicker(null) if (side === 'sell') { if (symbol === buySymbol) { handleFlip() } else if (symbol !== sellSymbol) { setSellSymbol(symbol) setSellInput(DEFAULT_SELL_INPUT) } } else { if (symbol === sellSymbol) { handleFlip() } else { setBuySymbol(symbol) } } }, [locked, buySymbol, sellSymbol, handleFlip], ) useEffect(() => { if (status === 'swapping') { const t = window.setTimeout(() => setStatus('success'), SWAP_SWAPPING_MS) return () => window.clearTimeout(t) } if (status === 'success') { const t = window.setTimeout(() => setStatus('idle'), SWAP_SUCCESS_MS) return () => window.clearTimeout(t) } return undefined }, [status]) const handleSwap = useCallback(() => { if (!hasAmount || status !== 'idle') return setStatus('swapping') }, [hasAmount, status]) const handleMax = useCallback(() => { if (locked) return setSellInput(capForInput(sellToken.balance)) }, [locked, sellToken.balance]) const pageBg = isDark ? '#0A0A0A' : '#E6E6E3' const trayBg = isDark ? '#141416' : '#FFFFFF' const trayShadow = isDark ? '0 24px 60px rgba(0,0,0,0.6)' : '0 20px 50px rgba(20,20,18,0.12)' const flipBtnBg = isDark ? '#2A2A2E' : '#0A0A0A' const flipBtnHoverBg = isDark ? '#34343A' : '#1F1F1F' const flipIconColor = isDark ? '#ECECEC' : '#FFFFFF' const swapBg = isDark ? '#AEB6EC' : '#9AA6EA' const swapHoverBg = isDark ? '#C2C8F2' : '#AEB8F0' const swapText = '#0A0A0A' const swapDisabledBg = isDark ? '#26262A' : '#DCDCD8' const swapDisabledText = isDark ? '#9A9A95' : '#5F5F58' const swapSuccessBg = isDark ? '#22C55E' : '#0FA968' const swapSuccessText = '#0A0A0A' const swapSuccessGlow = isDark ? 'rgba(34,197,94,0.55)' : 'rgba(15,169,104,0.45)' const buttonEnabled = hasAmount && status === 'idle' let buttonBg = swapBg let buttonText = swapText let buttonCursor: React.CSSProperties['cursor'] = 'pointer' if (status === 'success') { buttonBg = swapSuccessBg buttonText = swapSuccessText buttonCursor = 'default' } else if (status === 'swapping') { buttonBg = swapBg buttonText = swapText buttonCursor = 'progress' } else if (!hasAmount) { buttonBg = swapDisabledBg buttonText = swapDisabledText buttonCursor = 'not-allowed' } return (
{}
{}
{}
setSellInput(sanitizeDecimal(v))} onMax={handleMax} onTogglePicker={() => { if (locked) return setPicker((prev) => (prev === 'sell' ? null : 'sell')) }} onSelect={(symbol) => handleSelect('sell', symbol)} /> {}
{ if (locked) return setPicker((prev) => (prev === 'buy' ? null : 'buy')) }} onSelect={(symbol) => handleSelect('buy', symbol)} />
{} {} {} {status === 'success' && !reduced && ( )} {status === 'idle' && ( {hasAmount ? 'Swap' : 'Enter an amount'} )} {status === 'swapping' && ( Swapping… )} {status === 'success' && ( {} {!reduced && SUCCESS_SPARKS.map((s, i) => ( ))} Swapped! )}
) } ``` --- ## Product Card Deck Category: Cards & Modals Slug: `product-card-deck` URL: https://aicanvas.me/components/product-card-deck A draggable card deck you flick through one card at a time. The top card leans toward your drag, then spins away with momentum to reveal the next, and the deck loops endlessly. Every card pairs a picture with a title and a pill action button with hover and press states. Install (free account): ```bash npx shadcn@latest add @aicanvas/product-card-deck ``` ```tsx 'use client' // npm install framer-motion /** * Displays a looping deck of product cards with a focused front item. * Dragging or using the controls cycles cards through the animated stack. */ import { useEffect, useRef, useState } from 'react' import { AnimatePresence, animate, motion, usePresence, useMotionValue, useTransform, type PanInfo, } from 'framer-motion' interface CardData { title: string image: string label?: string } // customize: replace the deck images and labels below const CARDS: CardData[] = [ { title: '', image: 'https://ik.imagekit.io/aitoolkit/product-card-deck/mural.jpg?tr=w-600', }, { title: 'Dreamer backpack', label: 'Shop', image: 'https://ik.imagekit.io/aitoolkit/product-card-deck/backpack.jpg?tr=w-600', }, { title: 'Creator graffiti tee', label: 'Shop', image: 'https://ik.imagekit.io/aitoolkit/product-card-deck/tee.jpg?tr=w-600', }, { title: 'Dreamer high-tops', label: 'Shop', image: 'https://ik.imagekit.io/aitoolkit/product-card-deck/sneaker.jpg?tr=w-600', }, { title: 'Denim jacket', label: 'Shop', image: 'https://ik.imagekit.io/aitoolkit/product-card-deck/jacket.jpg?tr=w-600', }, ] // tune: change to control the number of rendered deck slots const VISIBLE = 4 // tune: adjust these arrays to change the depth spacing const SLOT_Y = [0, 12, 24, 36] const SLOT_SCALE = [1, 0.95, 0.9, 0.86] const SLOT_OPACITY = [1, 1, 0.92, 0.82] const SPRING = { type: 'spring' as const, stiffness: 300, damping: 30 } function CardFace({ card, isTop }: { card: CardData; isTop: boolean }) { return (
{}
{card.title
{} {card.title && (

{card.title}

{card.label && ( event.stopPropagation()} onClick={(event) => event.stopPropagation()} whileHover={{ scale: 1.06, backgroundColor: '#2C2825' }} whileTap={{ scale: 0.93, backgroundColor: '#000000' }} transition={{ type: 'spring', stiffness: 500, damping: 30 }} style={{ flexShrink: 0, border: 'none', cursor: 'pointer', backgroundColor: '#141312', color: '#F5F1E8', borderRadius: 9999, padding: '8px 16px', fontFamily: 'var(--font-sans, sans-serif)', fontSize: 12, fontWeight: 600, letterSpacing: '0.01em', }} > {card.label} )}
)}
) } function FlickCard({ card, slot, isTop, onFlick, }: { card: CardData slot: number isTop: boolean onFlick: () => void }) { const [isPresent, safeToRemove] = usePresence() const x = useMotionValue(0) const y = useMotionValue(SLOT_Y[slot]) const scale = useMotionValue(SLOT_SCALE[slot]) const opacity = useMotionValue(0) const rotate = useTransform(x, [-200, 200], [-18, 18], { clamp: true }) const flickVel = useRef({ x: 0, y: 0 }) useEffect(() => { if (!isPresent) return const controls = [ animate(y, SLOT_Y[slot], SPRING), animate(scale, SLOT_SCALE[slot], SPRING), animate(opacity, SLOT_OPACITY[slot], { duration: 0.3, ease: 'easeOut' }), ] if (!isTop) controls.push(animate(x, 0, SPRING)) return () => controls.forEach((c) => c.stop()) }, [slot, isTop, isPresent, x, y, scale, opacity]) useEffect(() => { if (isPresent) return const v = flickVel.current const mag = Math.hypot(v.x, v.y) || 1 animate(x, (v.x / mag) * 1500, { duration: 0.5, ease: 'easeOut' }) animate(y, (v.y / mag) * 1500, { duration: 0.5, ease: 'easeOut' }) animate(opacity, 0, { duration: 0.45, ease: 'easeOut' }) const last = animate(scale, 0.85, { duration: 0.5, ease: 'easeOut', onComplete: () => safeToRemove?.(), }) return () => last.stop() }, [isPresent, safeToRemove, x, y, scale, opacity]) const handleDragEnd = (_: unknown, info: PanInfo) => { const speed = Math.hypot(info.velocity.x, info.velocity.y) const dist = Math.hypot(info.offset.x, info.offset.y) if (speed > 500 || dist > 130) { flickVel.current = speed > 220 ? { x: info.velocity.x, y: info.velocity.y } : { x: info.offset.x * 9, y: info.offset.y * 9 } onFlick() } else { animate(x, 0, SPRING) animate(y, SLOT_Y[0], SPRING) } } return ( ) } interface DeckCard { key: number content: number } export default function ProductCardDeck() { const [deck, setDeck] = useState(() => Array.from({ length: VISIBLE }, (_, i) => ({ key: i, content: i })), ) const nextKey = useRef(VISIBLE) const handleFlick = () => { setDeck((prev) => { const rest = prev.slice(1) const lastContent = prev[prev.length - 1].content const newCard = { key: nextKey.current++, content: (lastContent + 1) % CARDS.length, } return [...rest, newCard] }) } return (
{deck.map((item, i) => ( ))}

grab the top card and flick it away

) } ``` --- ## Interactive Card Stack Category: Cards & Modals Slug: `interactive-card-stack` URL: https://aicanvas.me/components/interactive-card-stack A scattered stack of photo cards. Drag, click, or arrow keys to focus any card. Install (free account): ```bash npx shadcn@latest add @aicanvas/interactive-card-stack ``` ```tsx 'use client' // npm install framer-motion /** * Displays a responsive stack of bird cards with drag, tap, and keyboard cycling. * The focused card can expose an optional external link. */ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { motion, useReducedMotion, type PanInfo } from 'framer-motion' interface Card { id: number orientation: 'portrait' | 'landscape' title?: string image: string /** Opens the focused card chip in a new tab when provided. */ href?: string } interface Slot { x: number y: number rotate: number scale: number zIndex: number } const CARDS: Card[] = [ { id: 0, orientation: 'portrait', title: 'Scarlet macaw', image: 'https://ik.imagekit.io/aitoolkit/interactive-card-stack/scarlet-macaw-rainforest-branch.jpg', }, { id: 1, orientation: 'landscape', title: 'Toco toucan', image: 'https://ik.imagekit.io/aitoolkit/interactive-card-stack/toco-toucan-rainforest-canopy.jpg', }, { id: 2, orientation: 'portrait', title: 'Blue and gold macaw', image: 'https://ik.imagekit.io/aitoolkit/interactive-card-stack/blue-and-gold-macaw-jungle-perch.jpg', }, { id: 3, orientation: 'landscape', title: 'Green-headed tanager', image: 'https://ik.imagekit.io/aitoolkit/interactive-card-stack/green-headed-tanager-mossy-branch.jpg', }, { id: 4, orientation: 'portrait', title: 'Northern mockingbird', image: 'https://ik.imagekit.io/aitoolkit/interactive-card-stack/northern-mockingbird-autumn-woodland.jpg', }, ] // tune: adjust the slot tables to change desktop and mobile spread const SLOTS_DESKTOP: Slot[] = [ { x: 0, y: 0, rotate: 1.5, scale: 1.00, zIndex: 50 }, { x: 160, y: -30, rotate: 12, scale: 0.90, zIndex: 40 }, { x: -150, y: -10, rotate: -14, scale: 0.89, zIndex: 30 }, { x: 90, y: 70, rotate: 8, scale: 0.86, zIndex: 20 }, { x: -110, y: 60, rotate: -9, scale: 0.84, zIndex: 10 }, ] const SLOTS_MOBILE: Slot[] = [ { x: 0, y: 0, rotate: 1, scale: 1.00, zIndex: 50 }, { x: 90, y: -15, rotate: 6, scale: 0.92, zIndex: 40 }, { x: -85, y: 20, rotate: -7, scale: 0.91, zIndex: 30 }, { x: 55, y: 35, rotate: 4, scale: 0.88, zIndex: 20 }, { x: -55, y: 25, rotate: -4.5, scale: 0.87, zIndex: 10 }, ] const SPRING = { type: 'spring' as const, stiffness: 280, damping: 26 } const MOUNT_SPRING = { type: 'spring' as const, stiffness: 200, damping: 22 } const STAGGER_S = 0.08 const BREATH_Y_FOCUS = [0, -14, 0, 10, 0] const BREATH_Y_REST = [0, -8, 0, 6, 0] const BREATH_ROTATE_FOCUS = [0, 1.5, 0, -1.5, 0] const BREATH_ROTATE_REST = [0, 1, 0, -1, 0] const SHADOW_FOCUS = '0 24px 48px rgba(0,0,0,0.28), 0 6px 14px rgba(0,0,0,0.16)' const SHADOW_REST = '0 12px 28px rgba(0,0,0,0.18), 0 4px 8px rgba(0,0,0,0.12)' const RING = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#A8B94D]' const TITLE_STYLE: CSSProperties = { margin: 0, paddingRight: 34, fontFamily: 'var(--font-sans, sans-serif)', fontWeight: 600, fontSize: '15px', lineHeight: 1.3, letterSpacing: '-0.01em', color: '#1a1a19', textAlign: 'left', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', minHeight: '2.6em', } const CHIP_POSITION: CSSProperties = { position: 'absolute', top: 10, right: 10, lineHeight: 0 } const CHIP_STYLE: CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'center', width: 'clamp(28px, 7.5vw, 36px)', height: 'clamp(28px, 7.5vw, 36px)', backgroundColor: '#141312', borderRadius: 11, boxShadow: '0 8px 18px rgba(0,0,0,0.42), 0 3px 6px rgba(0,0,0,0.30)', } const OpenChip = ( ) export default function InteractiveCardStack() { const [order, setOrder] = useState([0, 1, 2, 3, 4]) const [mounted, setMounted] = useState(false) const [isMobile, setIsMobile] = useState(false) const containerRef = useRef(null) const dragDelta = useRef(0) const reduceMotion = useReducedMotion() useEffect(() => { setMounted(true) }, []) useEffect(() => { if (typeof window === 'undefined') return const mq = window.matchMedia('(min-width: 640px)') const apply = () => setIsMobile(!mq.matches) apply() mq.addEventListener('change', apply) return () => mq.removeEventListener('change', apply) }, []) const focusCard = useCallback((cardId: number) => { setOrder((prev) => { const idx = prev.indexOf(cardId) if (idx <= 0) return prev return [cardId, ...prev.slice(0, idx), ...prev.slice(idx + 1)] }) }, []) const step = useCallback((dir: 1 | -1) => { setOrder((prev) => dir === 1 ? [...prev.slice(1), prev[0]] : [prev[prev.length - 1], ...prev.slice(0, prev.length - 1)], ) }, []) useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return const root = containerRef.current if (!root || !root.contains(document.activeElement)) return event.preventDefault() step(event.key === 'ArrowRight' ? 1 : -1) } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [step]) const handleDragEnd = useCallback( (_: unknown, info: PanInfo) => { const distance = info.offset.x const velocity = info.velocity.x if (distance < -80 || velocity < -400) step(1) else if (distance > 80 || velocity > 400) step(-1) }, [step], ) const slots = isMobile ? SLOTS_MOBILE : SLOTS_DESKTOP const frontCardId = order[0] const frontTitle = CARDS.find((c) => c.id === frontCardId)?.title ?? '' return (
{}
{CARDS.map((card) => { const slotIndex = order.indexOf(card.id) const slot = slots[slotIndex] const isFocus = slotIndex === 0 const isLandscape = card.orientation === 'landscape' const transition = !reduceMotion && !mounted ? { ...MOUNT_SPRING, delay: slotIndex * STAGGER_S } : SPRING const widthClass = isLandscape ? isMobile ? 'w-[clamp(200px,60vw,260px)]' : 'w-[clamp(220px,28vw,320px)]' : isMobile ? 'w-[clamp(130px,42vw,180px)]' : 'w-[clamp(160px,20vw,220px)]' const breathY = reduceMotion ? 0 : isFocus ? BREATH_Y_FOCUS : BREATH_Y_REST const breathRotate = reduceMotion ? 0 : isFocus ? BREATH_ROTATE_FOCUS : BREATH_ROTATE_REST return ( { event.preventDefault() if (Math.abs(dragDelta.current) >= 8) return focusCard(card.id) } } onKeyDown={ isFocus ? undefined : (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() focusCard(card.id) } } } onPointerDown={() => { dragDelta.current = 0 }} drag={isFocus ? 'x' : false} dragConstraints={{ left: 0, right: 0 }} dragElastic={0.6} onDrag={(_, info) => { dragDelta.current = info.offset.x }} onDragEnd={handleDragEnd} className={`absolute ${widthClass} rounded-[18px] outline-none ${isFocus ? '' : RING}`} style={{ cursor: isFocus ? 'grab' : 'pointer', zIndex: slot.zIndex }} initial={reduceMotion ? false : { opacity: 0, scale: 0.5, y: 60 }} animate={{ x: slot.x, y: slot.y, rotate: slot.rotate, scale: slot.scale, opacity: 1 }} transition={transition} whileTap={isFocus ? { cursor: 'grabbing' } : undefined} > {} {} {card.title && (

{card.title}

{/* customize: set href on a card to enable the open link */} {isFocus && (card.href ? ( event.stopPropagation()} onClick={(event) => event.stopPropagation()} className={`rounded-[11px] outline-none ${RING}`} style={CHIP_POSITION} initial={{ opacity: 0, scale: 0.85 }} animate={{ opacity: 1, scale: 1 }} transition={SPRING} whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} > {OpenChip} ) : ( event.stopPropagation()} style={CHIP_POSITION} initial={{ opacity: 0, scale: 0.85 }} animate={{ opacity: 1, scale: 1 }} transition={SPRING} whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} > {OpenChip} ))}
)} {}
) })}
{}

{frontTitle ? `${frontTitle} in focus` : ''}

{}
{CARDS.map((card) => { const isCurrent = frontCardId === card.id return ( ) })}

drag, click, or use the arrow keys

) } ``` --- ## Tilted Coverflow Category: Cards & Modals Slug: `tilted-coverflow` URL: https://aicanvas.me/components/tilted-coverflow 3D coverflow card carousel of seven tilted photos. Drag or arrows to focus any card. Install (free account): ```bash npx shadcn@latest add @aicanvas/tilted-coverflow ``` ```tsx 'use client' // npm install framer-motion /** * Displays a tilted coverflow of captioned image cards. * Dragging, tapping, or using the keyboard changes the centered slide. */ import { useCallback, useEffect, useRef, useState } from 'react' import { motion, type PanInfo } from 'framer-motion' interface Slide { id: number caption: string image: string } // customize: replace the coverflow images and captions below const SLIDES: Slide[] = [ { id: 0, caption: 'Alley Sentinel', image: 'https://images.unsplash.com/photo-1550532422-378e93ec379c?w=600&h=750&fit=crop&auto=format', }, { id: 1, caption: 'Sticker Riot', image: 'https://images.unsplash.com/photo-1700222720939-60f0e91d691d?w=600&h=750&fit=crop&auto=format', }, { id: 2, caption: 'Quiet Vandals', image: 'https://images.unsplash.com/photo-1597355797858-35ffba85673c?w=600&h=750&fit=crop&auto=format', }, { id: 3, caption: 'Soft Beast', image: 'https://images.unsplash.com/photo-1612486524816-d7aaa8ac7bd6?w=600&h=750&fit=crop&auto=format', }, { id: 4, caption: 'City Gaze', image: 'https://images.unsplash.com/photo-1644424428722-b6f950e4b22d?w=600&h=750&fit=crop&auto=format', }, { id: 5, caption: 'Loud Letters', image: 'https://images.unsplash.com/photo-1581010105372-caf9ed5ab50f?w=600&h=750&fit=crop&auto=format', }, { id: 6, caption: 'Color Crash', image: 'https://images.unsplash.com/photo-1589236095092-1f7ea6f09cdd?w=600&h=750&fit=crop&auto=format', }, ] const TOTAL = 7 const HALF = 3 // tune: raise to increase card tilt between slots const ROTATION_PER_STEP = 14 // tune: raise to deepen the vertical arc const ARC_Y = 8 // tune: raise to spread cards farther apart const GAP_PX = 30 const SCALE_BY_OFFSET = [1.0, 0.88, 0.76, 0.64] const SPRING = { type: 'spring' as const, stiffness: 240, damping: 30 } const MOUNT_SPRING = { type: 'spring' as const, stiffness: 180, damping: 18 } const STAGGER_MS = 0.09 function visibleOffset(cardIndex: number, focus: number, total: number) { const half = Math.floor(total / 2) let off = cardIndex - focus if (off > half) off -= total if (off < -half) off += total return off } function buildXPositions(scales: number[], baseWidth: number, gap: number) { const positions = new Map() positions.set(0, 0) let cursor = 0 for (let i = 1; i <= 3; i++) { const step = (scales[i - 1] / 2 + scales[i] / 2) * baseWidth + gap cursor += step positions.set(i, cursor) positions.set(-i, -cursor) } return positions } export default function TiltedCoverflow() { const [focus, setFocus] = useState(3) const [maxSide, setMaxSide] = useState(3) const [cardWidth, setCardWidth] = useState(180) const [mounted, setMounted] = useState(false) const cardRef = useRef(null) useEffect(() => { setMounted(true) }, []) useEffect(() => { if (typeof window === 'undefined') return const mq = window.matchMedia('(min-width: 640px)') const apply = () => setMaxSide(mq.matches ? 3 : 1) apply() mq.addEventListener('change', apply) return () => mq.removeEventListener('change', apply) }, []) useEffect(() => { if (typeof window === 'undefined') return const measure = () => { if (cardRef.current) { const w = cardRef.current.getBoundingClientRect().width if (w > 0) setCardWidth(w) } } measure() const ro = new ResizeObserver(measure) if (cardRef.current) ro.observe(cardRef.current) window.addEventListener('resize', measure) return () => { ro.disconnect() window.removeEventListener('resize', measure) } }, []) const step = useCallback((dir: 1 | -1) => { setFocus((current) => { const len = SLIDES.length return (current + dir + len) % len }) }, []) useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === 'ArrowRight') { event.preventDefault() step(1) } else if (event.key === 'ArrowLeft') { event.preventDefault() step(-1) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [step]) const handleDragEnd = useCallback( (_: unknown, info: PanInfo) => { const distance = info.offset.x const velocity = info.velocity.x if (distance < -80 || velocity < -500) { step(1) } else if (distance > 80 || velocity > 500) { step(-1) } }, [step], ) return (
{(() => { const positions = buildXPositions(SCALE_BY_OFFSET, cardWidth, GAP_PX) return SLIDES.map((slide) => { const offset = visibleOffset(slide.id, focus, TOTAL) const absOffset = Math.abs(offset) const hidden = absOffset > maxSide const isFocus = offset === 0 const scale = SCALE_BY_OFFSET[absOffset] ?? 0.6 const rotateY = -offset * ROTATION_PER_STEP const translateX = positions.get(offset) ?? 0 const translateY = absOffset * ARC_Y const mountDelay = (HALF - absOffset) * STAGGER_MS const transition = mounted ? SPRING : { ...MOUNT_SPRING, delay: mountDelay } const breathDuration = 7 + slide.id * 0.6 const words = slide.caption.split(' ') return ( { event.preventDefault() if (!hidden && !isFocus) setFocus(slide.id) }} className="absolute aspect-[4/5] w-[clamp(160px,17vw,220px)]" style={{ transformStyle: 'preserve-3d', transformOrigin: 'center center', pointerEvents: hidden ? 'none' : 'auto', cursor: isFocus ? 'grab' : 'pointer', zIndex: TOTAL - absOffset, }} initial={{ opacity: 0, scale: 0.45, y: 70, x: 0, rotateY: 0 }} animate={{ x: translateX, y: translateY, rotateY, scale, opacity: hidden ? 0 : 1, }} transition={transition} whileTap={isFocus ? { cursor: 'grabbing' } : undefined} > {} {slide.caption} {}
{isFocus ? words.map((word, i) => ( {word} {i < words.length - 1 ? ' ' : ''} )) : words.map((word, i) => ( {word} {i < words.length - 1 ? ' ' : ''} ))}
) }) })()} {}
{SLIDES.map((slide) => { const isCurrent = focus === slide.id return ( setFocus(slide.id)} animate={{ width: isCurrent ? 22 : 6, opacity: isCurrent ? 1 : 0.35, }} transition={{ type: 'spring', stiffness: 400, damping: 30 }} className="h-1.5 rounded-full bg-[#21211F] dark:bg-[#FAFAF0]" /> ) })}

drag, click, or use the arrow keys

) } ``` --- ## Cube Carousel Category: 3D & Shaders Slug: `cube-carousel` URL: https://aicanvas.me/components/cube-carousel An interactive 3D photo cube with six placeholder images on its faces. Drag with your cursor to spin it freely on both axes; release and it coasts naturally to a stop. No buttons, no snap, no controls, just the cube and your hand. Install (free account): ```bash npx shadcn@latest add @aicanvas/cube-carousel ``` ```tsx 'use client' // npm install framer-motion /** * Rotatable photo cube controlled by dragging or the arrow keys. * Momentum carries the cube after a pointer gesture ends. */ import { useRef } from 'react' import { motion, useMotionValue, animate, type AnimationPlaybackControls, type MotionValue, type PanInfo } from 'framer-motion' type FaceShape = 'wide' | 'side' // Photos by Jeremy Bishop, giacomo ambrosini, Luke Paris, Jonny Gios, and Cloris Chou via Unsplash. const wide = (id: string) => `https://images.unsplash.com/photo-${id}?w=960&h=540&fit=crop&auto=format&q=80` const square = (id: string) => `https://images.unsplash.com/photo-${id}?w=720&h=720&fit=crop&auto=format&q=80` const P00 = '1602303832953-05d841ee21f7' const P01 = '1560841650-fa45ffd48b77' const P02 = '1568464992136-fae8c7322eee' const P03 = '1565105337533-d23f47c0fd58' const P04 = '1661632359984-0954ccc8a149' const P05 = '1637909837540-80e1b9198ea9' const FACES: { src: string; shape: FaceShape; transform: string }[] = [ { src: wide(P00), shape: 'wide', transform: 'translateZ(var(--half-d))' }, { src: wide(P01), shape: 'wide', transform: 'rotateY(180deg) translateZ(var(--half-d))' }, { src: wide(P04), shape: 'wide', transform: 'rotateX(90deg) translateZ(var(--half-h))' }, { src: wide(P05), shape: 'wide', transform: 'rotateX(-90deg) translateZ(var(--half-h))' }, { src: square(P02), shape: 'side', transform: 'rotateY(90deg) translateZ(var(--half-w))' }, { src: square(P03), shape: 'side', transform: 'rotateY(-90deg) translateZ(var(--half-w))' }, ] const DRAG_SENSITIVITY = 0.5 const KEY_STEP = 30 // tune: raise to increase each keyboard rotation const COAST = { type: 'spring' as const, stiffness: 40, damping: 22 } export default function CubeCarousel() { const rotateX = useMotionValue(-14) const rotateY = useMotionValue(-22) const start = useRef({ rotX: 0, rotY: 0 }) const xAnim = useRef(null) const yAnim = useRef(null) const stopAnims = () => { xAnim.current?.stop() yAnim.current?.stop() xAnim.current = null yAnim.current = null } const onPanStart = () => { stopAnims() start.current = { rotX: rotateX.get(), rotY: rotateY.get() } } const onPan = (_: PointerEvent, info: PanInfo) => { rotateY.set(start.current.rotY + info.offset.x * DRAG_SENSITIVITY) rotateX.set(start.current.rotX - info.offset.y * DRAG_SENSITIVITY) } const onPanEnd = (_: PointerEvent, info: PanInfo) => { const vy = info.velocity.x * DRAG_SENSITIVITY const vx = -info.velocity.y * DRAG_SENSITIVITY const projectY = rotateY.get() + vy * 0.18 const projectX = rotateX.get() + vx * 0.18 yAnim.current = animate(rotateY, projectY, { ...COAST, velocity: vy }) xAnim.current = animate(rotateX, projectX, { ...COAST, velocity: vx }) } const onKeyDown = (e: React.KeyboardEvent) => { let target: MotionValue | null = null let delta = 0 switch (e.key) { case 'ArrowLeft': target = rotateY delta = -KEY_STEP break case 'ArrowRight': target = rotateY delta = KEY_STEP break case 'ArrowUp': target = rotateX delta = KEY_STEP break case 'ArrowDown': target = rotateX delta = -KEY_STEP break default: return } e.preventDefault() stopAnims() const anim = animate(target, target.get() + delta, COAST) if (target === rotateY) yAnim.current = anim else xAnim.current = anim } return (
{FACES.map((face, i) => { const isSide = face.shape === 'side' return (
{/* eslint-disable-next-line @next/next/no-img-element */} {`Cube
) })}
) } ``` --- ## Curious AI Category: 3D & Shaders Slug: `curious-ai` URL: https://aicanvas.me/components/curious-ai A morphing 3D AI orb in Three.js with cyan and magenta rim lights, iridescent pink speckles on a wrinkled surface, and glowing pink robot-eye pills that follow your cursor and blink between idle looks. Install (free account): ```bash npx shadcn@latest add @aicanvas/curious-ai ``` ```tsx 'use client' // npm install framer-motion three /** * Renders a shader-driven orb that tracks pointer movement with animated eyes. * Idle gaze and blink cycles continue when the pointer leaves the component. */ import { useEffect, useRef, useState } from 'react' import { motion, useMotionValue, useSpring } from 'framer-motion' import * as THREE from 'three' function useScopedTheme(ref: React.RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const readTheme = () => { const scope = element.closest('[data-card-theme]') as HTMLElement | null if (scope) { setTheme(scope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } readTheme() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const obs = new MutationObserver(readTheme) obs.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(obs) current = current.parentElement } const htmlObs = new MutationObserver(readTheme) htmlObs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) observers.push(htmlObs) return () => observers.forEach((o) => o.disconnect()) }, [ref]) return theme } const VERT = /* glsl */ ` uniform float uTime; uniform float uActive; // 0..1 lerp toward "alert" uniform vec2 uLook; // -1..1, current look direction (cursor or idle script) uniform float uReduce; // 1 = motion allowed, 0 = motion frozen varying vec3 vNormal; varying vec3 vViewDir; varying vec3 vLocalPos; // original mesh position — for stable surface speckles varying vec3 vViewPos; // view-space position — fragment derives the displaced normal from this // Classic 3D simplex noise — Ashima Arts / Stefan Gustavson, MIT. vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;} vec4 mod289(vec4 x){return x-floor(x*(1./289.))*289.;} vec4 permute(vec4 x){return mod289(((x*34.)+1.)*x);} vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;} float snoise(vec3 v){ const vec2 C=vec2(1./6.,1./3.); const vec4 D=vec4(0.,.5,1.,2.); vec3 i=floor(v+dot(v,C.yyy)); vec3 x0=v-i+dot(i,C.xxx); vec3 g=step(x0.yzx,x0.xyz); vec3 l=1.-g; vec3 i1=min(g.xyz,l.zxy); vec3 i2=max(g.xyz,l.zxy); vec3 x1=x0-i1+C.xxx; vec3 x2=x0-i2+C.yyy; vec3 x3=x0-D.yyy; i=mod289(i); vec4 p=permute(permute(permute( i.z+vec4(0.,i1.z,i2.z,1.)) + i.y+vec4(0.,i1.y,i2.y,1.)) + i.x+vec4(0.,i1.x,i2.x,1.)); float n_=1./7.; vec3 ns=n_*D.wyz-D.xzx; vec4 j=p-49.*floor(p*ns.z*ns.z); vec4 x_=floor(j*ns.z); vec4 y_=floor(j-7.*x_); vec4 x=x_*ns.x+ns.yyyy; vec4 y=y_*ns.x+ns.yyyy; vec4 h=1.-abs(x)-abs(y); vec4 b0=vec4(x.xy,y.xy); vec4 b1=vec4(x.zw,y.zw); vec4 s0=floor(b0)*2.+1.; vec4 s1=floor(b1)*2.+1.; vec4 sh=-step(h,vec4(0.)); vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy; vec4 a1=b1.xzyw+s1.xzyw*sh.zzww; vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w); vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3))); p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w; vec4 m=max(.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.); m=m*m; return 42.*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3))); } void main(){ vLocalPos = position; // stable surface coord — speckles stick here vec3 pos = position; float t = uTime * uReduce; // Slow anisotropic stretch — orb morphs through ovoid orientations over time. float ax = sin(t * 0.27 + 0.0) * 0.16; float ay = sin(t * 0.19 + 1.7) * 0.20; float az = sin(t * 0.23 + 3.1) * 0.12; pos *= vec3(1.0 + ax, 1.0 + ay, 1.0 + az); // Two octaves — keep the surface glossier/smoother for the new aesthetic. float nLow = snoise(pos * 0.55 + vec3( t * 0.18, t * 0.13, -t * 0.15)); float nMid = snoise(pos * 1.45 + vec3(-t * 0.22, t * 0.20, t * 0.18)); // Look direction biases the noise sample — the "face" bulges where it looks. vec3 lookDir = vec3(uLook, 0.55); float facing = clamp(dot(normalize(pos), normalize(lookDir)), 0.0, 1.0); float bulge = pow(facing, 2.5) * (0.08 + uActive * 0.10); // Gentle breath — overall radial pulse, very slow. float breath = sin(t * 0.55) * 0.022; float amp = mix(0.20, 0.34, uActive); float displacement = (nLow * 0.66 + nMid * 0.34) * amp + bulge + breath; pos += normal * displacement; vec4 mv = modelViewMatrix * vec4(pos, 1.0); vNormal = normalize(normalMatrix * normal); vViewDir = normalize(-mv.xyz); vViewPos = mv.xyz; gl_Position = projectionMatrix * mv; } ` const FRAG = /* glsl */ ` precision highp float; uniform vec3 uBase; uniform vec3 uRimA; // cyan rim (upper-left light) uniform vec3 uRimB; // magenta rim (lower-right light) uniform vec3 uSpeckA; // electric pink speckle uniform vec3 uSpeckB; // electric cyan speckle uniform float uActive; varying vec3 vNormal; varying vec3 vViewDir; varying vec3 vLocalPos; varying vec3 vViewPos; // Simplex 3D noise — same as vertex, needed for speckle sampling. vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;} vec4 mod289(vec4 x){return x-floor(x*(1./289.))*289.;} vec4 permute(vec4 x){return mod289(((x*34.)+1.)*x);} vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;} float snoise(vec3 v){ const vec2 C=vec2(1./6.,1./3.); const vec4 D=vec4(0.,.5,1.,2.); vec3 i=floor(v+dot(v,C.yyy)); vec3 x0=v-i+dot(i,C.xxx); vec3 g=step(x0.yzx,x0.xyz); vec3 l=1.-g; vec3 i1=min(g.xyz,l.zxy); vec3 i2=max(g.xyz,l.zxy); vec3 x1=x0-i1+C.xxx; vec3 x2=x0-i2+C.yyy; vec3 x3=x0-D.yyy; i=mod289(i); vec4 p=permute(permute(permute( i.z+vec4(0.,i1.z,i2.z,1.)) + i.y+vec4(0.,i1.y,i2.y,1.)) + i.x+vec4(0.,i1.x,i2.x,1.)); float n_=1./7.; vec3 ns=n_*D.wyz-D.xzx; vec4 j=p-49.*floor(p*ns.z*ns.z); vec4 x_=floor(j*ns.z); vec4 y_=floor(j-7.*x_); vec4 x=x_*ns.x+ns.yyyy; vec4 y=y_*ns.x+ns.yyyy; vec4 h=1.-abs(x)-abs(y); vec4 b0=vec4(x.xy,y.xy); vec4 b1=vec4(x.zw,y.zw); vec4 s0=floor(b0)*2.+1.; vec4 s1=floor(b1)*2.+1.; vec4 sh=-step(h,vec4(0.)); vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy; vec4 a1=b1.xzyw+s1.xzyw*sh.zzww; vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w); vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3))); p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w; vec4 m=max(.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.); m=m*m; return 42.*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3))); } void main(){ // True surface normal derived from view-space position derivatives — picks // up the actual wrinkles/ridges from the displaced geometry instead of the // smooth icosahedron normal. Gives proper shadow-in-valley, highlight-on- // ridge shading. vec3 dx = dFdx(vViewPos); vec3 dy = dFdy(vViewPos); vec3 n = normalize(cross(dx, dy)); vec3 v = normalize(-vViewPos); // Fresnel — bright at glancing angles, dark where the surface faces us. float fres = 1.0 - clamp(dot(n, v), 0.0, 1.0); // ── Diffuse 3D lighting on the body ─────────────────────────────────────── // Two soft lights: warm-cyan key from upper-left-front, cool-magenta fill // from lower-right. Combined with derivative normal, this makes wrinkle // ridges catch light and valleys fall into shadow. vec3 keyDir = normalize(vec3(-0.45, 0.70, 0.85)); vec3 fillDir = normalize(vec3( 0.65, -0.35, 0.55)); float diffKey = max(0.0, dot(n, keyDir)); float diffFill = max(0.0, dot(n, fillDir)); // Ambient + key + fill — wrap the body in directional light so ridges and // valleys actually read as 3D. vec3 lit = uBase * (0.30 + diffKey * 0.95 + diffFill * 0.45); // ── Two-tone rim lights ────────────────────────────────────────────────── vec3 dirCyan = normalize(vec3(-0.70, 0.55, 0.50)); vec3 dirMagenta = normalize(vec3( 0.75, -0.30, 0.50)); float cyanWrap = max(0.0, dot(n, dirCyan)); float magentaWrap = max(0.0, dot(n, dirMagenta)); float rimCoreP = mix(2.2, 1.7, uActive); float rimCyan = pow(cyanWrap, 1.3) * pow(fres, rimCoreP); float rimMagenta = pow(magentaWrap, 1.3) * pow(fres, rimCoreP); // ── Specular highlight on ridges ───────────────────────────────────────── // Blinn-half-vector style spec from the key light — puts a moving glint on // ridges that face the light, sells the glossy / wet quality. vec3 halfKey = normalize(keyDir + v); float specKey = pow(max(0.0, dot(n, halfKey)), 32.0) * 0.55; vec3 col = lit + uRimA * rimCyan * mix(1.10, 1.55, uActive) + uRimB * rimMagenta * mix(1.00, 1.45, uActive) + specKey * vec3(0.80, 0.95, 1.00); // ── Iridescent speckles (sparser than before) ──────────────────────────── // Higher thresholds → ~half the previous density. Single big-scale layer // with a small-scale accent. float speckBig = snoise(vLocalPos * 10.0); float speckSmall = snoise(vLocalPos * 24.0 + 1.7); float maskBig = smoothstep(0.66, 0.74, speckBig); float maskSmall = smoothstep(0.72, 0.78, speckSmall) * 0.40; float speckMask = max(maskBig, maskSmall); // Per-cluster colour pick — biased toward pink, with cyan accents. float colorPick = snoise(vLocalPos * 4.0 + 5.3); vec3 speckColor = mix(uSpeckA, uSpeckB, smoothstep(0.55, 0.75, colorPick)); // Fade speckles in valley shadows so they read as surface, not stickers. float speckBody = 1.0 - smoothstep(0.55, 0.95, fres); col += speckColor * speckMask * speckBody * 0.85; gl_FragColor = vec4(col, 1.0); } ` type Palette = { base: [number, number, number] rimA: [number, number, number] rimB: [number, number, number] speckA: [number, number, number] speckB: [number, number, number] eye: string eyeGlow: string } const PALETTE: Record<'dark' | 'light', Palette> = { dark: { base: [0.050, 0.075, 0.085], rimA: [0.380, 0.860, 0.940], rimB: [0.730, 0.330, 0.940], speckA: [0.880, 0.275, 0.985], speckB: [0.400, 0.910, 1.000], eye: 'rgba(255, 140, 245, 0.85)', eyeGlow: 'rgba(225, 90, 230, 0.70)', }, light: { base: [0.075, 0.105, 0.120], rimA: [0.400, 0.870, 0.940], rimB: [0.745, 0.355, 0.940], speckA: [0.895, 0.290, 0.985], speckB: [0.420, 0.915, 1.000], eye: 'rgba(255, 150, 248, 0.88)', eyeGlow: 'rgba(225, 100, 230, 0.75)', }, } // tune: adjust coordinates and durations to change the idle gaze pattern const LOOK_SEQUENCE: { x: number; y: number; dur: number }[] = [ { x: -0.65, y: 0.00, dur: 2400 }, { x: 0.32, y: 0.00, dur: 2800 }, { x: -0.24, y: 0.00, dur: 2200 }, { x: 0.00, y: -0.55, dur: 2800 }, { x: 0.00, y: 0.00, dur: 2600 }, ] export default function CuriousAi() { const containerRef = useRef(null) const stageRef = useRef(null) const canvasRef = useRef(null) const sizeRef = useRef({ w: 320, h: 320 }) const lookTargetRef = useRef({ x: 0, y: 0 }) const lookCurrentRef = useRef({ x: 0, y: 0 }) const hoverActiveRef = useRef(false) const activeRef = useRef(0) const targetRef = useRef(0) const eyeX = useMotionValue(0) const eyeY = useMotionValue(0) const sx = useSpring(eyeX, { stiffness: 200, damping: 22, mass: 0.4 }) const sy = useSpring(eyeY, { stiffness: 200, damping: 22, mass: 0.4 }) const [blinkAt, setBlinkAt] = useState(0) const [open, setOpen] = useState(0.85) const theme = useScopedTheme(containerRef) useEffect(() => { const host = canvasRef.current if (!host) return const W = host.clientWidth || 320 const H = host.clientHeight || 320 sizeRef.current = { w: W, h: H } const palette = PALETTE[theme] const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(38, W / H, 0.1, 100) camera.position.z = 4.4 // Throws outright when the browser cannot hand back a WebGL context: // hardware acceleration switched off, an older or virtualised machine, or // too many live contexts on one page. Thrown from inside an effect it takes // the surrounding page down with it, so an empty frame is the better // failure. let renderer: THREE.WebGLRenderer try { renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) } catch { return } renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) renderer.setSize(W, H) renderer.setClearColor(0x000000, 0) host.appendChild(renderer.domElement) const detail = W < 400 ? 32 : 64 const geo = new THREE.IcosahedronGeometry(1, detail) const uniforms: Record = { uTime: { value: 0 }, uActive: { value: 0 }, uLook: { value: new THREE.Vector2(0, 0) }, uReduce: { value: 1 }, uBase: { value: new THREE.Color(...palette.base) }, uRimA: { value: new THREE.Color(...palette.rimA) }, uRimB: { value: new THREE.Color(...palette.rimB) }, uSpeckA: { value: new THREE.Color(...palette.speckA) }, uSpeckB: { value: new THREE.Color(...palette.speckB) }, } const mat = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms, transparent: true, }) const mesh = new THREE.Mesh(geo, mat) scene.add(mesh) const mql = window.matchMedia('(prefers-reduced-motion: reduce)') const applyMotion = () => { uniforms.uReduce.value = mql.matches ? 0 : 1 } applyMotion() mql.addEventListener('change', applyMotion) const ro = new ResizeObserver((entries) => { const r = entries[0]?.contentRect if (!r) return const nw = Math.max(1, Math.floor(r.width)) const nh = Math.max(1, Math.floor(r.height)) sizeRef.current = { w: nw, h: nh } renderer.setSize(nw, nh) camera.aspect = nw / nh camera.updateProjectionMatrix() }) ro.observe(host) let raf = 0 const clock = new THREE.Clock() function tick() { raf = requestAnimationFrame(tick) const dt = Math.min(clock.getDelta(), 0.05) uniforms.uTime.value += dt // tune: raise the response rate to sharpen alert transitions const ka = 1 - Math.exp(-dt * 6) activeRef.current += (targetRef.current - activeRef.current) * ka uniforms.uActive.value = activeRef.current // tune: raise either rate to make gaze tracking more responsive const speed = hoverActiveRef.current ? 7 : 2.2 const kl = 1 - Math.exp(-dt * speed) const lc = lookCurrentRef.current const lt = lookTargetRef.current lc.x += (lt.x - lc.x) * kl lc.y += (lt.y - lc.y) * kl uniforms.uLook.value.set(lc.x, -lc.y) // tune: raise the lean range to increase orb travel const lean = 0.12 + activeRef.current * 0.06 mesh.position.x += (lc.x * lean - mesh.position.x) * kl mesh.position.y += (-lc.y * lean - mesh.position.y) * kl // tune: raise the range factor to increase eye travel const range = sizeRef.current.w * 0.18 eyeX.set(lc.x * range) eyeY.set(lc.y * range) mesh.rotation.y += dt * 0.04 mesh.rotation.x += dt * 0.015 renderer.render(scene, camera) } tick() return () => { cancelAnimationFrame(raf) mql.removeEventListener('change', applyMotion) ro.disconnect() geo.dispose() mat.dispose() // Hand the GPU context back explicitly: dispose() frees the scene but // leaves the context alive, and a browser grants only about sixteen per // page, so remounting a few times exhausts them. try { renderer.forceContextLoss() } catch {} renderer.dispose() if (host.contains(renderer.domElement)) host.removeChild(renderer.domElement) } }, [theme, eyeX, eyeY]) useEffect(() => { const container = containerRef.current const stage = stageRef.current if (!container || !stage) return function update(clientX: number, clientY: number) { const rect = stage!.getBoundingClientRect() const centerX = rect.left + rect.width / 2 const centerY = rect.top + rect.height / 2 const radius = rect.width / 2 const dx = clientX - centerX const dy = clientY - centerY const nx = Math.max(-1, Math.min(1, dx / radius)) const ny = Math.max(-1, Math.min(1, dy / radius)) const dist = Math.sqrt(nx * nx + ny * ny) // tune: raise the threshold to enlarge the focused pointer region const onOrb = dist < 0.62 hoverActiveRef.current = true if (onOrb) { targetRef.current = 1 lookTargetRef.current = { x: nx, y: ny } setOpen(0.32) } else { targetRef.current = 0.35 // tune: raise the multiplier to increase off-orb eye tracking lookTargetRef.current = { x: nx * 0.40, y: ny * 0.40 } setOpen(0.70) } } function onMove(e: PointerEvent) { update(e.clientX, e.clientY) } function onEnter(e: PointerEvent) { update(e.clientX, e.clientY) setBlinkAt((v) => v + 1) } function onLeave() { hoverActiveRef.current = false targetRef.current = 0 setOpen(0.85) setBlinkAt((v) => v + 1) } function onDown(e: PointerEvent) { update(e.clientX, e.clientY) } function onUp() { // tune: raise the delay to extend the touch-release grace period window.setTimeout(() => { if (!container?.matches(':hover')) onLeave() }, 500) } container.addEventListener('pointermove', onMove) container.addEventListener('pointerenter', onEnter) container.addEventListener('pointerleave', onLeave) container.addEventListener('pointerdown', onDown) container.addEventListener('pointerup', onUp) container.addEventListener('pointercancel', onLeave) return () => { container.removeEventListener('pointermove', onMove) container.removeEventListener('pointerenter', onEnter) container.removeEventListener('pointerleave', onLeave) container.removeEventListener('pointerdown', onDown) container.removeEventListener('pointerup', onUp) container.removeEventListener('pointercancel', onLeave) } }, []) useEffect(() => { if (typeof window === 'undefined') return let idx = 0 let timer: number function step() { const s = LOOK_SEQUENCE[idx] if (!hoverActiveRef.current) { lookTargetRef.current = { x: s.x, y: s.y } } idx = (idx + 1) % LOOK_SEQUENCE.length timer = window.setTimeout(step, s.dur) } step() return () => window.clearTimeout(timer) }, []) useEffect(() => { if (typeof window === 'undefined') return if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return let t: number function schedule() { // tune: raise either bound to reduce blink frequency const wait = 3800 + Math.random() * 3200 t = window.setTimeout(() => { setBlinkAt((v) => v + 1) schedule() }, wait) } schedule() return () => window.clearTimeout(t) }, []) const palette = PALETTE[theme] const bgColor = theme === 'dark' ? '#0A0A09' : '#E8E8DF' return (
{}
) } function Eye({ open, blinkKey, palette, }: { open: number blinkKey: number palette: Palette }) { return ( ) } ``` --- ## Signature Pad Category: Widgets Slug: `signature-pad` URL: https://aicanvas.me/components/signature-pad A signature pad widget: pill button morphs into a canvas to draw with mouse or touch. Install (free account): ```bash npx shadcn@latest add @aicanvas/signature-pad ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Presents a signature modal with a velocity-sensitive drawing canvas. * Pointer strokes can be erased, canceled, or confirmed into the trigger pill. */ import { useState, useRef, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { X, Check, Eraser, Signature } from '@phosphor-icons/react' type Pt = { x: number; y: number; t: number } type Stroke = Pt[] type ButtonRect = { x: number; y: number; w: number; h: number } // tune: adjust these bounds to change stroke-width variation const MIN_W = 1.1 const MAX_W = 2.6 const MORPH = { type: 'spring' as const, stiffness: 320, damping: 30, mass: 1 } function widthForVelocity(prev: Pt, curr: Pt) { const d = Math.hypot(curr.x - prev.x, curr.y - prev.y) const dt = Math.max(1, curr.t - prev.t) const v = d / dt return Math.max(MIN_W, MAX_W - v * 0.55) } function buildPillPalette(isDark: boolean) { if (isDark) { return { pillBg: '#e0dfd8', pillHover: '#d4d3cc', pillText: '#1a1a18', iconTileBg: '#1a1a18', iconTileFg: '#f1f1f0', } } return { pillBg: '#1a1a18', pillHover: '#2d2d2b', pillText: '#f1f1f0', iconTileBg: '#f1f1f0', iconTileFg: '#1a1a18', } } const MODAL_PALETTE = { surfaceBg: '#f1f1f0', fieldBg: '#f8f8f8', fieldHover: '#ececec', labelColor: '#6c6c6c', titleColor: '#1a1a18', primaryBg: '#1a1a18', primaryFg: '#f2f1ec', inkColor: '#1a1a18', baselineColor: 'rgba(26,26,24,0.14)', } as const export default function SignaturePad() { const rootRef = useRef(null) const buttonRef = useRef(null) const [open, setOpen] = useState(false) const [origin, setOrigin] = useState(null) const [isDark, setIsDark] = useState(false) useEffect(() => { const el = rootRef.current const check = () => { const card = el?.closest('[data-card-theme]') as HTMLElement | null setIsDark( card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark'), ) } check() const observer = new MutationObserver(check) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }) const card = el?.closest('[data-card-theme]') if (card) observer.observe(card, { attributes: true, attributeFilter: ['class'] }) return () => observer.disconnect() }, []) const pill = buildPillPalette(isDark) function handleOpen() { if (buttonRef.current) { const r = buttonRef.current.getBoundingClientRect() setOrigin({ x: r.left, y: r.top, w: r.width, h: r.height }) } setOpen(true) } function close() { setOpen(false) } return (
Create your digital signature {open && ( )} {open && origin && ( )}
) } function ModalCard({ origin, onClose, }: { origin: ButtonRect onClose: () => void }) { const palette = MODAL_PALETTE const surfaceRef = useRef(null) const canvasContainerRef = useRef(null) const canvasRef = useRef(null) const strokesRef = useRef([]) const currentRef = useRef([]) const drawingRef = useRef(false) const sizeRef = useRef({ w: 0, h: 0 }) const [hasInk, setHasInk] = useState(false) const [confirming, setConfirming] = useState(false) const [today, setToday] = useState('') useEffect(() => { setToday( new Date().toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric', }), ) }, []) useEffect(() => { surfaceRef.current?.focus() const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() onClose() } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [onClose]) const drawSegment = useCallback( (stroke: Stroke) => { const canvas = canvasRef.current if (!canvas || stroke.length < 2) return const ctx = canvas.getContext('2d') if (!ctx) return const prev = stroke[stroke.length - 2] const curr = stroke[stroke.length - 1] const mid = { x: (prev.x + curr.x) / 2, y: (prev.y + curr.y) / 2 } ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.strokeStyle = palette.inkColor ctx.lineWidth = widthForVelocity(prev, curr) ctx.beginPath() if (stroke.length === 2) { ctx.moveTo(prev.x, prev.y) } else { const prev2 = stroke[stroke.length - 3] const prevMid = { x: (prev2.x + prev.x) / 2, y: (prev2.y + prev.y) / 2 } ctx.moveTo(prevMid.x, prevMid.y) } ctx.quadraticCurveTo(prev.x, prev.y, mid.x, mid.y) ctx.stroke() }, [palette.inkColor], ) const redrawAll = useCallback(() => { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return const { w, h } = sizeRef.current ctx.clearRect(0, 0, w, h) ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.strokeStyle = palette.inkColor ctx.fillStyle = palette.inkColor for (const stroke of strokesRef.current) { if (stroke.length === 1) { const p = stroke[0] ctx.beginPath() ctx.arc(p.x, p.y, MIN_W, 0, Math.PI * 2) ctx.fill() continue } for (let i = 1; i < stroke.length; i++) { const prev = stroke[i - 1] const curr = stroke[i] const mid = { x: (prev.x + curr.x) / 2, y: (prev.y + curr.y) / 2 } ctx.lineWidth = widthForVelocity(prev, curr) ctx.beginPath() if (i === 1) { ctx.moveTo(prev.x, prev.y) } else { const prev2 = stroke[i - 2] const prevMid = { x: (prev2.x + prev.x) / 2, y: (prev2.y + prev.y) / 2 } ctx.moveTo(prevMid.x, prevMid.y) } ctx.quadraticCurveTo(prev.x, prev.y, mid.x, mid.y) ctx.stroke() } } }, [palette.inkColor]) const setupCanvas = useCallback(() => { const canvas = canvasRef.current const container = canvasContainerRef.current if (!canvas || !container) return const dpr = window.devicePixelRatio || 1 const width = container.clientWidth const height = container.clientHeight if (width === 0 || height === 0) return sizeRef.current = { w: width, h: height } canvas.width = Math.floor(width * dpr) canvas.height = Math.floor(height * dpr) canvas.style.width = `${width}px` canvas.style.height = `${height}px` const ctx = canvas.getContext('2d') if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0) redrawAll() }, [redrawAll]) useEffect(() => { setupCanvas() const ro = new ResizeObserver(setupCanvas) const el = canvasContainerRef.current if (el) ro.observe(el) return () => ro.disconnect() }, [setupCanvas]) useEffect(() => { redrawAll() }, [redrawAll]) function getPoint(e: React.PointerEvent): Pt { const rect = e.currentTarget.getBoundingClientRect() return { x: e.clientX - rect.left, y: e.clientY - rect.top, t: performance.now() } } function handlePointerDown(e: React.PointerEvent) { if (confirming) return e.preventDefault() try { e.currentTarget.setPointerCapture(e.pointerId) } catch {} drawingRef.current = true const p = getPoint(e) currentRef.current = [p] strokesRef.current.push(currentRef.current) if (!hasInk) setHasInk(true) } function handlePointerMove(e: React.PointerEvent) { if (!drawingRef.current) return const p = getPoint(e) currentRef.current.push(p) drawSegment(currentRef.current) } function handlePointerUp(e: React.PointerEvent) { if (!drawingRef.current) return drawingRef.current = false try { e.currentTarget.releasePointerCapture(e.pointerId) } catch {} } function clear() { strokesRef.current = [] currentRef.current = [] setHasInk(false) redrawAll() } function save() { if (!hasInk || confirming) return setConfirming(true) setTimeout(() => onClose(), 1100) } const vw = typeof window !== 'undefined' ? window.innerWidth : 1024 const vh = typeof window !== 'undefined' ? window.innerHeight : 768 const targetW = Math.min(480, vw - 32) const initialOffsetX = origin.x + origin.w / 2 - vw / 2 const initialOffsetY = origin.y + origin.h / 2 - vh / 2 const initialScaleX = origin.w / targetW return (
e.stopPropagation()} className="w-full max-w-[480px] px-6 pb-6 pt-6" > {}
Create your digital signature Draw using your mouse or finger
{}
{!hasInk && ( Sign here )}
{today || '—'} {hasInk && !confirming && ( Clear )}
{} Cancel
) } function SaveButton({ hasInk, confirming, onSave, primaryBg, primaryFg, }: { hasInk: boolean confirming: boolean onSave: () => void primaryBg: string primaryFg: string }) { const enabled = hasInk && !confirming return ( {confirming ? ( ) : ( Save signature )} ) } ``` --- ## Upload Progress Category: Widgets Slug: `upload-progress` URL: https://aicanvas.me/components/upload-progress A collapsible file upload widget with a shimmer progress bar (indigo while uploading, amber on pause). Expand to see per-file rows with live progress, time remaining, and pause, resume, refresh, and stop controls. Install (free account): ```bash npx shadcn@latest add @aicanvas/upload-progress ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Presents simulated file uploads in a collapsible progress card. * Each transfer can be paused, resumed, canceled, or restarted after completion. */ import { useState, useEffect, useRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Pause, Play, ArrowCounterClockwise, X, ArrowsOutSimple, ArrowsInSimple, } from '@phosphor-icons/react' // customize: replace the simulated files and durations below const FILES = [ { name: 'Brand reel.mp4', durationMs: 8000 }, { name: 'Product demo.mp4', durationMs: 12500 }, { name: 'Hero animation.mp4', durationMs: 16000 }, ] type Status = 'uploading' | 'paused' | 'complete' | 'idle' const SPRING = { type: 'spring' as const, stiffness: 380, damping: 30 } const CARD_SHADOW = '0px 16px 56px rgba(0,0,0,0.14)' function useDarkMode(ref: React.RefObject) { const [isDark, setIsDark] = useState(false) useEffect(() => { const el = ref.current if (!el) return const update = () => { const scope = el.closest('[data-card-theme]') as HTMLElement | null if (scope) { setIsDark(scope.dataset.cardTheme === 'dark'); return } setIsDark(document.documentElement.classList.contains('dark')) } update() const obs = new MutationObserver(update) obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) // Watch the card wrapper too. Its own light/dark switch changes only the // wrapper, never , so observing the document alone leaves this stuck // on whatever it read first. const scope = el.closest('[data-card-theme]') if (scope) obs.observe(scope, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) return () => obs.disconnect() }, [ref]) return isDark } export default function UploadProgress() { const rootRef = useRef(null) const isDark = useDarkMode(rootRef) const [status, setStatus] = useState('uploading') const [expanded, setExpanded] = useState(false) const [progress, setProgress] = useState([0, 0, 0]) const progressRef = useRef([0, 0, 0]) const intervalRef = useRef | null>(null) useEffect(() => { if (status !== 'uploading') { if (intervalRef.current) { clearInterval(intervalRef.current) intervalRef.current = null } return } intervalRef.current = setInterval(() => { const next = progressRef.current.map((p, i) => Math.min(100, p + (100 / FILES[i].durationMs) * 80) ) progressRef.current = next setProgress([...next]) if (next.every(p => p >= 100)) setStatus('complete') }, 80) return () => { if (intervalRef.current) { clearInterval(intervalRef.current) intervalRef.current = null } } }, [status]) useEffect(() => { if (status !== 'complete') return const t = setTimeout(() => { progressRef.current = [0, 0, 0] setProgress([0, 0, 0]) setStatus('idle') }, 2000) return () => clearTimeout(t) }, [status]) const overallPct = Math.round(progress.reduce((a, b) => a + b, 0) / FILES.length) const secondsLeft = Math.max( 0, Math.round(((100 - progress[2]) / 100) * FILES[2].durationMs / 1000) ) function togglePause() { setStatus(s => (s === 'uploading' ? 'paused' : 'uploading')) } function handleStop() { if (intervalRef.current) clearInterval(intervalRef.current) progressRef.current = [0, 0, 0] setProgress([0, 0, 0]) setStatus('idle') } function handleRefresh() { progressRef.current = [0, 0, 0] setProgress([0, 0, 0]) setStatus('uploading') } function startUpload() { progressRef.current = [0, 0, 0] setProgress([0, 0, 0]) setStatus('uploading') } const isDone = status === 'complete' const isPaused = status === 'paused' const cardBg = isDark ? '#262623' : '#f1f1f0' const titleColor = isDark ? '#f1f1ec' : '#1a1a18' const mutedColor = isDark ? '#9a9a94' : '#6c6c6c' const btnBg = isDark ? '#34342f' : '#ededea' const btnColor = isDark ? '#b8b8b0' : '#6c6c6c' const dividerColor = isDark ? '#34342f' : '#e4e4dc' const trackColor = isDark ? '#34342f' : '#e4e4dc' const subtitle = isDone ? 'Upload complete' : isPaused ? `${overallPct}% · Paused` : `${overallPct}% · ${secondsLeft}s left` return (
{status === 'idle' ? ( Upload Files ) : ( {}

{isDone ? 'Upload complete' : `Uploading ${FILES.length} files`}

{!expanded && ( {isDone ? 'Upload complete' : ( <> {overallPct}% {' · '} {isPaused ? 'Paused' : `${secondsLeft}s left`} )} )}
{}
{} {!isDone && ( {} {isPaused ? ( ) : ( )} {} )} {} setExpanded(e => !e)} bg={btnBg} color={btnColor} > {expanded ? ( ) : ( )} {} {!isDone && ( {} )}
{} {expanded && (
{FILES.map((f, i) => { const pct = Math.round(progress[i]) const secs = Math.max( 0, Math.round(((100 - progress[i]) / 100) * f.durationMs / 1000) ) const fsub = isDone ? 'Complete' : isPaused ? `${pct}% · Paused` : `${pct}% · ${secs}s left` return (
{f.name} {fsub}
) })}
)} {} {!expanded && (
)}
)}
) } function IconBtn({ onClick, bg, color, children, }: { onClick: () => void bg: string color: string children: React.ReactNode }) { return ( {children} ) } ``` --- ## Live Session Pill Category: Cards & Modals Slug: `voice-chat-pill` URL: https://aicanvas.me/components/voice-chat-pill A compact live-session presence pill with an animated speaking indicator and overlapping avatars. Clicks open a soft-UI modal showing all participants with a Join Now button. Install (free account): ```bash npx shadcn@latest add @aicanvas/voice-chat-pill ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Presents a voice-chat pill that expands into participant controls. * Opening reveals selectable people and speaking indicators before collapsing again. */ import { useState, useRef, useEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { CaretDown, X, Check } from '@phosphor-icons/react' const MORPH = { type: 'spring' as const, stiffness: 320, damping: 30, mass: 1 } // customize: replace the participant names and avatars below const PEOPLE = [ { id: 0, name: 'David', avatar: 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=240&h=240&fit=crop&q=80' }, { id: 1, name: 'Kira', avatar: 'https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=240&h=240&fit=crop&q=80' }, { id: 2, name: 'Marina', avatar: 'https://images.unsplash.com/photo-1554780336-390462301acf?w=240&h=240&fit=crop&q=80' }, { id: 3, name: 'Razvan', avatar: 'https://images.unsplash.com/photo-1603415526960-f7e0328c63b1?w=240&h=240&fit=crop&q=80' }, { id: 4, name: 'Ana', avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=240&h=240&fit=crop&q=80' }, { id: 5, name: 'Daniel', avatar: 'https://images.unsplash.com/photo-1654110455429-cf322b40a906?w=240&h=240&fit=crop&q=80' }, { id: 6, name: 'Afshin', avatar: 'https://images.unsplash.com/photo-1639747280804-dd2d6b3d88ac?w=240&h=240&fit=crop&q=80' }, { id: 7, name: 'Lina', avatar: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=240&h=240&fit=crop&q=80' }, ] as const type ButtonRect = { x: number; y: number; w: number; h: number } | null function SpeakingBars({ size = 14 }: { size?: number }) { return ( ) } export default function VoiceChatPill() { const [open, setOpen] = useState(false) const [origin, setOrigin] = useState(null) const [speakerId, setSpeakerId] = useState(0) const pillRef = useRef(null) useEffect(() => { const interval = setInterval(() => { setSpeakerId((id) => (id + 1) % PEOPLE.length) }, 2400) return () => clearInterval(interval) }, []) function handleOpen() { if (pillRef.current) { const r = pillRef.current.getBoundingClientRect() setOrigin({ x: r.left, y: r.top, w: r.width, h: r.height }) } setOpen(true) } function close() { setOpen(false) } const collapsedSpeaker = PEOPLE.find((p) => p.id === speakerId) ?? PEOPLE[0] const visiblePeople = PEOPLE.slice(0, 4) const hiddenCount = PEOPLE.length - visiblePeople.length return (
{} {} {}
{visiblePeople.map((p, i) => ( {p.name} ))}
{} +{hiddenCount}
{} {open && ( )} {} {open && origin && ( )}
) } function ModalCard({ origin, onClose, speakerId, }: { origin: { x: number; y: number; w: number; h: number } onClose: () => void speakerId: number }) { const [joining, setJoining] = useState(false) const targetW = typeof window !== 'undefined' ? Math.min(440, window.innerWidth - 32) : 440 const initialOffsetX = origin.x + origin.w / 2 - window.innerWidth / 2 const initialOffsetY = origin.y + origin.h / 2 - window.innerHeight / 2 const initialScaleX = origin.w / targetW const shadowBase = '0px 16px 56px rgba(0,0,0,0.14)' const boxShadow = shadowBase return (
e.stopPropagation()} className="w-full max-w-[440px] rounded-[28px] bg-[#f1f1f0] px-6 pb-6 pt-6" > {} Live Session {} {PEOPLE.map((p) => (
{p.name} {p.id === speakerId && ( )}
{p.name}
))}
{} { if (joining) return setJoining(true) setTimeout(() => { setJoining(false); onClose() }, 700) }} disabled={joining} animate={{ scale: joining ? 0.96 : 1, backgroundColor: joining ? '#3a3a38' : '#1a1a18' }} whileHover={joining ? {} : { scale: 1.02, backgroundColor: '#2d2d2b' }} whileTap={joining ? {} : { scale: 0.98 }} transition={{ type: 'spring', stiffness: 500, damping: 40 }} className="mt-6 w-full rounded-full py-3.5 font-sans text-[15px] font-bold text-[#f2f1ec]" style={{ backgroundColor: '#1a1a18' }} > {joining ? ( ) : ( Join Now )}
) } ``` --- ## New Project Modal Category: Cards & Modals Slug: `new-project-modal` URL: https://aicanvas.me/components/new-project-modal A pill button that morphs into a soft-UI project creation form: title, description, 7-swatch color label picker, Private toggle, and a checkmark-confirm Create button. Install (free account): ```bash npx shadcn@latest add @aicanvas/new-project-modal ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Presents a new-project form that expands from its trigger button. * Opening moves focus into the modal and submission validates the project title. */ import { useState, useRef, useEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Plus, X, Check } from '@phosphor-icons/react' const MORPH = { type: 'spring' as const, stiffness: 320, damping: 30, mass: 1 } // customize: replace the project color choices below const COLORS = [ { value: '#E05C50', label: 'Rose' }, { value: '#E09A3A', label: 'Amber' }, { value: '#48B068', label: 'Sage' }, { value: '#30AACC', label: 'Teal' }, { value: '#5878D8', label: 'Slate' }, { value: '#8F54D8', label: 'Lavender' }, ] as const type ButtonRect = { x: number; y: number; w: number; h: number } | null export default function NewProjectModal() { const [open, setOpen] = useState(false) const [origin, setOrigin] = useState(null) const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [isPrivate, setIsPrivate] = useState(false) const [titleError, setTitleError] = useState(false) const [color, setColor] = useState(null) const buttonRef = useRef(null) const titleRef = useRef(null) function close() { setOpen(false) setTitle('') setDescription('') setIsPrivate(false) setTitleError(false) setColor(null) } useEffect(() => { if (open) { const t = setTimeout(() => titleRef.current?.focus(), 320) return () => clearTimeout(t) } }, [open]) useEffect(() => { if (!open) return function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') close() } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [open]) function handleOpen() { if (buttonRef.current) { const r = buttonRef.current.getBoundingClientRect() setOrigin({ x: r.left, y: r.top, w: r.width, h: r.height }) } setOpen(true) } return (
New Project {open && ( )} {open && origin && ( { setTitle(v); if (v) setTitleError(false) }} description={description} setDescription={setDescription} isPrivate={isPrivate} setIsPrivate={setIsPrivate} titleError={titleError} titleRef={titleRef} onValidate={() => { if (!title.trim()) { setTitleError(true); return false } return true }} color={color} setColor={setColor} /> )}
) } function ModalCard({ origin, onClose, title, setTitle, description, setDescription, isPrivate, setIsPrivate, titleError, titleRef, onValidate, color, setColor, }: { origin: { x: number; y: number; w: number; h: number } onClose: () => void title: string setTitle: (v: string) => void description: string setDescription: (v: string) => void isPrivate: boolean setIsPrivate: (fn: (p: boolean) => boolean) => void titleError: boolean titleRef: React.RefObject onValidate: () => boolean color: string | null setColor: (c: string | null) => void }) { const vw = typeof window !== 'undefined' ? window.innerWidth : 1024 const vh = typeof window !== 'undefined' ? window.innerHeight : 768 const targetW = Math.min(480, vw - 32) const initialOffsetX = origin.x + origin.w / 2 - vw / 2 const initialOffsetY = origin.y + origin.h / 2 - vh / 2 const initialScaleX = origin.w / targetW return (
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="New Project" className="w-full max-w-[480px] rounded-[28px] bg-[#f1f1f0] px-6 pb-6 pt-6" > {} New Project {}
setTitle(e.target.value)} placeholder="Title" className={`h-[52px] w-full rounded-full border-2 bg-[#f8f8f8] px-5 font-sans text-[15px] font-medium text-[#1a1a18] placeholder-[#a0a09a] outline-none transition-[border-color] focus:border-[#1a1a18] ${ titleError ? 'border-red-400' : 'border-transparent' }`} style={{ caretColor: '#1a1a18' }} /> {titleError && ( Title is required )} setDescription(e.target.value)} placeholder="Description" className="h-[52px] w-full rounded-full border-2 border-transparent bg-[#f8f8f8] px-5 font-sans text-[15px] font-medium text-[#1a1a18] placeholder-[#a0a09a] outline-none transition-[border-color] focus:border-[#1a1a18]" style={{ caretColor: '#1a1a18' }} />
{} setColor(null)} ringColor="#6c6c6c" style={{ background: 'linear-gradient(135deg, #e0dfd8 0%, #e0dfd8 45%, #c8c7c0 45%, #c8c7c0 55%, #e0dfd8 55%, #e0dfd8 100%)', }} /> {COLORS.map((c) => ( setColor(c.value)} ringColor={c.value} style={{ backgroundColor: c.value }} /> ))} {}
) } function Swatch({ label, selected, onClick, ringColor, style, }: { label: string selected: boolean onClick: () => void ringColor: string style: React.CSSProperties }) { const [hovered, setHovered] = useState(false) return (
{hovered && ( {label} )} setHovered(true)} onHoverEnd={() => setHovered(false)} whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.88 }} animate={{ scale: selected ? 1.1 : 1, boxShadow: selected ? `0 0 0 2px #f1f1f0, 0 0 0 3.5px ${ringColor}` : '0 0 0 0px transparent', }} transition={{ type: 'spring', stiffness: 400, damping: 24 }} className="size-[22px] shrink-0 rounded-full" style={style} />
) } function CreateButton({ onValidate, onConfirm }: { onValidate: () => boolean; onConfirm: () => void }) { const [confirming, setConfirming] = useState(false) function handleClick() { if (!onValidate()) return setConfirming(true) setTimeout(() => { setConfirming(false); onConfirm() }, 600) } return ( {confirming ? ( ) : ( Create )} ) } ``` --- ## Radial Cards Category: Cards & Modals Slug: `radial-cards` URL: https://aicanvas.me/components/radial-cards Seven health-metric cards bloom into a slowly rotating flower. Tap any petal to pull it forward and read your stats up close. Install (free account): ```bash npx shadcn@latest add @aicanvas/radial-cards ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Arranges activity cards around a continuously rotating radial track. * Pointer engagement lifts and centers the nearest card for inspection. */ import { useEffect, useRef, useState, type ComponentType, type RefObject } from 'react' import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react' import { animate, motion, useAnimationFrame, useMotionValue, useMotionValueEvent, useSpring, useTransform, type MotionValue, } from 'framer-motion' import { Footprints, Fire, Moon, Drop, Heart, Lightning, Path } from '@phosphor-icons/react' // tune: raise to slow the orbit const SECONDS_PER_TURN = 45 const REDUCED_SECONDS_PER_TURN = SECONDS_PER_TURN * 4 // tune: raise to bring the engaged card farther forward const ENGAGED_LIFT_Z = 120 const REDUCED_LIFT_Z = 30 // tune: raise to enlarge the engaged card const ENGAGED_SCALE = 1.18 const REDUCED_ENGAGED_SCALE = 1.06 const LIFT_SPRING = { stiffness: 140, damping: 28, mass: 1 } as const const SPRING = { stiffness: 200, damping: 24, mass: 1 } as const const SPEED_SPRING = { stiffness: 90, damping: 20, mass: 1 } as const // tune: change both dimensions to resize the cards const CARD_W = 240 const CARD_H = 138 const PETAL_SKEW_Y = 0 const OUTWARD_OFFSET_PX = '0px' // tune: adjust to redistribute cards around the orbit const SLOT_ANGLES: readonly number[] = [ -35.55, 22.14, 71.01, 122.25, 161.58, -150.4, -108.41, ] const STAGE_SIZE = 'clamp(300px, 50vw, 560px)' type PhosphorIconProps = { size?: number | string weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' color?: string } type Metric = { title: string label: string value: string delta: string surface: string gradientEnd: string dark: string sparkline: readonly number[] Icon: ComponentType } // customize: replace the activity metrics below const METRICS: Metric[] = [ { title: 'Steps', label: 'TODAY', value: '20.5K', delta: '+5K', surface: '#EAF6AE', gradientEnd: '#ACC13C', dark: '#5C662A', sparkline: [28, 18, 10, 6], Icon: Footprints, }, { title: 'Calories', label: 'BURNED', value: '1,820', delta: '+120', surface: '#F9C8A7', gradientEnd: '#D4783A', dark: '#853C0B', sparkline: [30, 8, 20, 6], Icon: Fire, }, { title: 'Sleep', label: 'LAST NIGHT', value: '7h 42m', delta: '+18m', surface: '#C1C2FA', gradientEnd: '#7B7DF0', dark: '#363885', sparkline: [22, 30, 14, 18], Icon: Moon, }, { title: 'Water', label: 'TODAY', value: '2.1 L', delta: '+0.4 L', surface: '#96D9F7', gradientEnd: '#4BB8F0', dark: '#085B80', sparkline: [26, 14, 20, 4], Icon: Drop, }, { title: 'Heart', label: 'RESTING', value: '72 BPM', delta: '−4', surface: '#FBB1BE', gradientEnd: '#F07090', dark: '#862334', sparkline: [8, 22, 16, 28], Icon: Heart, }, { title: 'Active', label: 'THIS WEEK', value: '48 min', delta: '+12 min', surface: '#9BE6DD', gradientEnd: '#48C7B8', dark: '#0B655B', sparkline: [32, 20, 10, 4], Icon: Lightning, }, { title: 'Distance', label: 'THIS WEEK', value: '8.4 km', delta: '+1.2 km', surface: '#FAD79C', gradientEnd: '#E8B040', dark: '#875706', sparkline: [24, 12, 20, 6], Icon: Path, }, ] function SparkLine({ points, color, id }: { points: readonly number[]; color: string; id: string }) { const W = 54, H = 36 const n = points.length const xs = points.map((_, i) => (i / (n - 1)) * W) const pts = points.map((y, i) => ({ x: xs[i], y })) const segs: string[] = [`M ${pts[0].x},${pts[0].y}`] for (let i = 0; i < n - 1; i++) { const p0 = pts[Math.max(0, i - 1)] const p1 = pts[i] const p2 = pts[i + 1] const p3 = pts[Math.min(n - 1, i + 2)] const cp1x = p1.x + (p2.x - p0.x) / 6 const cp1y = p1.y + (p2.y - p0.y) / 6 const cp2x = p2.x - (p3.x - p1.x) / 6 const cp2y = p2.y - (p3.y - p1.y) / 6 segs.push(`C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`) } const linePath = segs.join(' ') const areaPath = `${linePath} L ${W},${H} L 0,${H} Z` const gradId = `spark-${id}` return ( ) } function useTheme(ref: RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const read = () => { const scope = element.closest('[data-card-theme]') as HTMLElement | null if (scope) { setTheme(scope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } read() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const o = new MutationObserver(read) o.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(o) current = current.parentElement } return () => observers.forEach((o) => o.disconnect()) }, [ref]) return { theme } } function useReducedMotion(): boolean { const [reduced, setReduced] = useState(false) useEffect(() => { if (typeof window === 'undefined') return const mq = window.matchMedia('(prefers-reduced-motion: reduce)') const update = () => setReduced(mq.matches) update() mq.addEventListener('change', update) return () => mq.removeEventListener('change', update) }, []) return reduced } type CardProps = { index: number metric: Metric rotation: MotionValue engagedIndex: number | null topIndex: number | null reduced: boolean onToggle: (index: number) => void } function Card({ index, metric, rotation, engagedIndex, topIndex, reduced, onToggle, }: CardProps) { const engaged = engagedIndex === index const isTop = topIndex === index const slotTargetDeg = SLOT_ANGLES[index] const outwardFactorTarget = useMotionValue(1) const outwardFactor = useSpring(outwardFactorTarget, SPRING) const centeringFactorTarget = useMotionValue(0) const centeringFactor = useSpring(centeringFactorTarget, LIFT_SPRING) const skewFactorTarget = useMotionValue(1) const skewFactor = useSpring(skewFactorTarget, SPRING) const liftTarget = useMotionValue(0) const lift = useSpring(liftTarget, LIFT_SPRING) const scaleTarget = useMotionValue(1) const scale = useSpring(scaleTarget, LIFT_SPRING) const opacityTarget = useMotionValue(1) const cardOpacity = useSpring(opacityTarget, SPRING) const initialRot = SLOT_ANGLES[index] + rotation.get() const cardRotation = useMotionValue(initialRot) const followModeRef = useRef(true) const followBaseRef = useRef(SLOT_ANGLES[index]) useMotionValueEvent(rotation, 'change', (latest) => { if (!followModeRef.current) return cardRotation.set(followBaseRef.current + latest) }) useEffect(() => { const shortestEquivalent = (current: number, target: number): number => { let delta = target - current while (delta > 180) delta -= 360 while (delta <= -180) delta += 360 return current + delta } if (engaged) { followBaseRef.current = 0 followModeRef.current = false const current = cardRotation.get() const shortestTarget = shortestEquivalent(current, 0) const controls = animate(cardRotation, shortestTarget, { type: 'spring', ...LIFT_SPRING, }) return () => controls.stop() } else { followBaseRef.current = slotTargetDeg cardRotation.set(slotTargetDeg + rotation.get()) followModeRef.current = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [engaged, slotTargetDeg, index]) useEffect(() => { if (engaged) { outwardFactorTarget.set(0) centeringFactorTarget.set(1) skewFactorTarget.set(0) liftTarget.set(reduced ? REDUCED_LIFT_Z : ENGAGED_LIFT_Z) scaleTarget.set(reduced ? REDUCED_ENGAGED_SCALE : ENGAGED_SCALE) opacityTarget.set(1) } else { outwardFactorTarget.set(1) centeringFactorTarget.set(0) skewFactorTarget.set(1) liftTarget.set(0) scaleTarget.set(1) opacityTarget.set(reduced && engagedIndex != null ? 0.7 : 1) } }, [ engaged, engagedIndex, reduced, slotTargetDeg, outwardFactorTarget, centeringFactorTarget, skewFactorTarget, liftTarget, scaleTarget, opacityTarget, ]) const pivotTransform = useTransform( cardRotation, (r) => `rotate(${r as number}deg)`, ) const cardBodyTransform = useTransform( [centeringFactor, skewFactor, outwardFactor], ([c, sk, ow]) => { const cf = c as number const skf = sk as number const owf = ow as number const txPct = -50 * cf return ( `translate(${txPct}%, calc(-50% - (50% + (${OUTWARD_OFFSET_PX}) * ${owf}) * ${1 - cf})) ` + `skewY(${PETAL_SKEW_Y * skf}deg)` ) }, ) const liftTransform = useTransform( [lift, scale], ([z, s]) => `translateZ(${z as number}px) scale(${s as number})`, ) const dynamicZIndex = useTransform(lift, (z) => { if (z > 4) return 50 if (engaged) return 40 if (isTop) return 30 return 1 }) useEffect(() => { lift.set(lift.get()) }, [engaged, isTop, lift]) const restShadow = '4px 6px 14px rgba(0,0,0,0.18), 12px 18px 40px rgba(0,0,0,0.22)' const liftedShadow = '8px 14px 26px rgba(0,0,0,0.30), 28px 36px 70px rgba(0,0,0,0.34)' function handlePointerDown(e: ReactPointerEvent) { e.stopPropagation() onToggle(index) } const interStack = '"Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif' const frontShadow = '-8px 16px 32px rgba(0,0,0,0.12), -3px 6px 14px rgba(0,0,0,0.18)' return ( {}
{}
{metric.title}
{}
{}
{metric.delta}
{}
{metric.label} {metric.value}
) } export default function RadialCards() { const rootRef = useRef(null) const { theme } = useTheme(rootRef) const isDark = theme === 'dark' const reduced = useReducedMotion() const rotation = useMotionValue(0) const speedTargetRef = useRef(1) const speedTarget = useMotionValue(1) const speedSmooth = useSpring(speedTarget, SPEED_SPRING) const [engagedIndex, setEngagedIndex] = useState(null) const [topIndex, setTopIndex] = useState(null) useEffect(() => { speedTargetRef.current = 1 speedTarget.set(speedTargetRef.current) }, [engagedIndex, speedTarget]) const prevTs = useRef(null) useAnimationFrame((t) => { const last = prevTs.current prevTs.current = t if (last == null) return const dt = (t - last) / 1000 const seconds = reduced ? REDUCED_SECONDS_PER_TURN : SECONDS_PER_TURN const degPerSec = 360 / seconds const next = rotation.get() + degPerSec * speedSmooth.get() * dt rotation.set(((next % 360) + 360) % 360) }) function handleToggle(i: number) { setTopIndex(i) setEngagedIndex((prev) => (prev === i ? null : i)) } function handleStageRelease() { setEngagedIndex(null) } return (
{}
{}
{METRICS.map((metric, i) => ( ))}
) } ``` --- ## Ripple Type Category: Typography Slug: `ripple-type` URL: https://aicanvas.me/components/ripple-type SVG text animation warped by a turbulence filter. Toggle the fan and letters ripple. Install (free account): ```bash npx shadcn@latest add @aicanvas/ripple-type ``` ```tsx 'use client' // npm install framer-motion /** * Renders a word around a rotating fan of repeated glyph layers. * Pointer engagement increases the ripple amplitude and rotation rate. */ import React, { useEffect, useId, useRef, useState } from 'react' import { useAnimationFrame } from 'framer-motion' // customize: replace the display word below const WORD = 'RIPPLE' const LEVEL_COUNT = 1 // tune: raise to build ripple intensity faster const LEVEL_RAMP = 1.0 // tune: raise to release ripple intensity faster const LEVEL_DECAY = 2.5 // tune: raise to increase the maximum ripple scale const MAX_SCALE = 36 const OSCILLATION_HZ = 0.09 const REST_FREQ = 0.001 const HOVER_FREQ = 0.009 const RIPPLE_PHASE_RATE = 0.9 // tune: raise the divisor to slow the engaged fan const FAN_FULL_RATE = (Math.PI * 2) / 0.6 const FAN_REDUCED_RATE = (Math.PI * 2) / 2.0 function useTheme(ref: React.RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const read = () => { const scope = element.closest('[data-card-theme]') as HTMLElement | null if (scope) { setTheme(scope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } read() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const o = new MutationObserver(read) o.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(o) current = current.parentElement } return () => observers.forEach((o) => o.disconnect()) }, [ref]) return { theme } } export default function RippleType() { const rootRef = useRef(null) const { theme } = useTheme(rootRef) const isDark = theme === 'dark' const bg = isDark ? '#0A0A0A' : '#EFEEE6' const fg = isDark ? '#EFEEE6' : '#0A0A0A' const fanStroke = isDark ? '#EFEEE6' : '#0A0A0A' const vignette = isDark ? 'radial-gradient(70% 55% at 50% 50%, rgba(239,238,230,0.06) 0%, rgba(10,10,10,0) 70%)' : 'radial-gradient(70% 55% at 50% 50%, rgba(10,10,10,0.05) 0%, rgba(239,238,230,0) 70%)' const uid = useId().replace(/:/g, '-') const filterId = `ripple-${uid}` const turbRef = useRef(null) const dispRef = useRef(null) const bladesRef = useRef(null) const [reducedMotion, setReducedMotion] = useState(false) useEffect(() => { if (typeof window === 'undefined') return const mq = window.matchMedia('(prefers-reduced-motion: reduce)') const update = () => setReducedMotion(mq.matches) update() mq.addEventListener('change', update) return () => mq.removeEventListener('change', update) }, []) const [fanOn, setFanOn] = useState(false) const fanOnRef = useRef(false) useEffect(() => { fanOnRef.current = fanOn }, [fanOn]) const levelRef = useRef(0) const fanAngleRef = useRef(0) const ripplePhaseRef = useRef(0) const lastTimeRef = useRef(null) const textSvgRef = useRef(null) useAnimationFrame((t) => { const turb = turbRef.current const disp = dispRef.current const blades = bladesRef.current if (!turb || !disp) return const last = lastTimeRef.current const dt = last == null ? 0 : Math.max(0, Math.min(0.05, (t - last) / 1000)) lastTimeRef.current = t if (fanOnRef.current) { levelRef.current = Math.min(LEVEL_COUNT, levelRef.current + LEVEL_RAMP * dt) } else { levelRef.current = Math.max(0, levelRef.current - LEVEL_DECAY * dt) } const intensity = levelRef.current / LEVEL_COUNT const secs = t / 1000 const breath = Math.sin(secs * Math.PI * 2 * OSCILLATION_HZ) const peakScale = reducedMotion ? 14 : MAX_SCALE const scale = intensity * peakScale const freq = Math.max(0.002, REST_FREQ + (HOVER_FREQ - REST_FREQ) * intensity + 0.004 * breath * intensity ) ripplePhaseRef.current += intensity * RIPPLE_PHASE_RATE * dt const p = ripplePhaseRef.current const liveFreqX = Math.max(0.002, freq + 0.007 * Math.sin(p) * intensity) const liveFreqY = Math.max(0.002, 0.028 + 0.012 * Math.sin(p * 1.4 + 1) * intensity) turb.setAttribute('baseFrequency', `${liveFreqX} ${liveFreqY}`) disp.setAttribute('scale', String(scale)) const textSvgEl = textSvgRef.current if (textSvgEl) { const skew = reducedMotion ? 0 : intensity * 13 textSvgEl.style.transform = `skewX(${skew}deg)` } if (blades) { const rate = reducedMotion ? FAN_REDUCED_RATE : FAN_FULL_RATE fanAngleRef.current += rate * intensity * dt if (fanAngleRef.current > Math.PI * 2) fanAngleRef.current -= Math.PI * 2 blades.style.transform = `rotate(${(fanAngleRef.current * 180) / Math.PI}deg)` } }) const toggleFan = () => setFanOn((v) => !v) const onKey = (e: React.KeyboardEvent) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault() toggleFan() } } const fan = (
{ if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleFan() } }} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, cursor: 'pointer', outline: 'none', flexShrink: 0, userSelect: 'none', touchAction: 'manipulation', }} > {} {} {[0, 120, 240].map((angle) => ( ))} {} {fanOn ? 'ON' : 'OFF'}
) const textSvg = ( {WORD} ) return (
{}
{}
{fan} {textSvg}
) } ``` --- ## Stack Tower Category: Typography Slug: `stack-tower` URL: https://aicanvas.me/components/stack-tower A column of 12 stacked words that reads as a rotating 3D cylinder. 2D transforms skew, scale and shift each row so the rotation travels down the stack. Hover lights one row in warm orange without breaking the rhythm. Install (free account): ```bash npx shadcn@latest add @aicanvas/stack-tower ``` ```tsx 'use client' // npm install framer-motion /** * Renders repeated words as a vertically rotating typographic tower. * Hovering a row increases its scale while the stack continues cycling. */ import React, { useEffect, useMemo, useRef, useState } from 'react' import { motion, motionValue, useAnimationFrame, useTransform, } from 'framer-motion' import type { MotionValue } from 'framer-motion' // customize: replace the alternating tower words below const WORDS = ['STACK', 'TOWER'] as const // tune: raise to add more stacked rows const ROW_COUNT = 12 // tune: raise to slow the tower cycle const SECONDS_PER_CYCLE = 5 // tune: raise to increase horizontal row travel const AMPLITUDE_PX = 22 // tune: raise to enlarge the hovered row further const HOVER_SCALE_BOOST = 0.1 const HOVER_EASE_RATE = 10 function useTheme(ref: React.RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const read = () => { const scope = element.closest('[data-card-theme]') as HTMLElement | null if (scope) { setTheme(scope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } read() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const o = new MutationObserver(read) o.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(o) current = current.parentElement } return () => observers.forEach((o) => o.disconnect()) }, [ref]) return { theme } } type RowProps = { text: string rowIndex: number phase: MotionValue hover: MotionValue fg: string dim: string accent: string fontSize: string onEnter: (i: number) => void onLeave: (i: number) => void } function TowerRow({ text, rowIndex, phase, hover, fg, dim, accent, fontSize, onEnter, onLeave, }: RowProps) { const rowOffset = rowIndex * 0.35 const transform = useTransform([phase, hover], ([p, h]) => { const local = (p as number) * Math.PI * 2 + rowOffset const scaleX = 0.55 + 0.45 * Math.cos(local) const shiftX = Math.sin(local) * AMPLITUDE_PX const skewX = Math.sin(local) * 6 const boost = 1 + (h as number) * HOVER_SCALE_BOOST return `translateX(${shiftX}px) skewX(${skewX}deg) scale(${ Math.max(0.08, scaleX) * boost }, ${boost})` }) const color = useTransform([phase, hover], ([p, h]) => { const local = (p as number) * Math.PI * 2 + rowOffset const tt = (Math.cos(local) + 1) / 2 const base = mix(dim, fg, tt) const hv = h as number if (hv < 0.002) return base return mix(base, accent, hv) }) return (
onEnter(rowIndex)} onPointerLeave={() => onLeave(rowIndex)} onPointerDown={() => onEnter(rowIndex)} onPointerUp={() => onLeave(rowIndex)} onPointerCancel={() => onLeave(rowIndex)} style={{ width: '100%', display: 'flex', justifyContent: 'center', cursor: 'pointer', touchAction: 'none', }} > {text}
) } function mix(a: string, b: string, t: number): string { const pa = parseInt(a.slice(1), 16) const pb = parseInt(b.slice(1), 16) const ar = (pa >> 16) & 0xff const ag = (pa >> 8) & 0xff const ab = pa & 0xff const br = (pb >> 16) & 0xff const bg = (pb >> 8) & 0xff const bb = pb & 0xff const r = Math.round(ar + (br - ar) * t) const g = Math.round(ag + (bg - ag) * t) const bl = Math.round(ab + (bb - ab) * t) return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}` } export default function StackTower() { const rootRef = useRef(null) const { theme } = useTheme(rootRef) const isDark = theme === 'dark' const bg = isDark ? '#0A0A0A' : '#EFEEE6' const fg = isDark ? '#EFEEE6' : '#0A0A0A' const dim = isDark ? '#3A3936' : '#C7C3B8' const accent = '#F16D14' const fadeTop = isDark ? 'linear-gradient(to bottom, #0A0A0A 0%, rgba(10,10,10,0) 100%)' : 'linear-gradient(to bottom, #EFEEE6 0%, rgba(239,238,230,0) 100%)' const fadeBot = isDark ? 'linear-gradient(to top, #0A0A0A 0%, rgba(10,10,10,0) 100%)' : 'linear-gradient(to top, #EFEEE6 0%, rgba(239,238,230,0) 100%)' const [reducedMotion, setReducedMotion] = useState(false) useEffect(() => { if (typeof window === 'undefined') return const mq = window.matchMedia('(prefers-reduced-motion: reduce)') const update = () => setReducedMotion(mq.matches) update() mq.addEventListener('change', update) return () => mq.removeEventListener('change', update) }, []) const hoveredIndexRef = useRef(null) const phases = useMemo[]>( () => Array.from({ length: ROW_COUNT }, () => motionValue(0)), [], ) const hovers = useMemo[]>( () => Array.from({ length: ROW_COUNT }, () => motionValue(0)), [], ) const hoverEased = useRef(Array(ROW_COUNT).fill(0)) const prevTs = useRef(null) useAnimationFrame((t) => { if (reducedMotion) { phases.forEach((p, i) => p.set(0.2 + i * 0.03)) return } const last = prevTs.current prevTs.current = t if (last == null) return const dtSec = (t - last) / 1000 const phaseDt = dtSec / SECONDS_PER_CYCLE const alpha = 1 - Math.exp(-HOVER_EASE_RATE * dtSec) const hov = hoveredIndexRef.current for (let i = 0; i < ROW_COUNT; i++) { phases[i].set(phases[i].get() + phaseDt) const target = i === hov ? 1 : 0 const cur = hoverEased.current[i] const next = cur + (target - cur) * alpha hoverEased.current[i] = next hovers[i].set(next) } }) const handleEnter = (i: number) => { hoveredIndexRef.current = i } const handleLeave = (i: number) => { if (hoveredIndexRef.current === i) hoveredIndexRef.current = null } const rows = Array.from({ length: ROW_COUNT }, (_, i) => WORDS[i % WORDS.length]) const fontSize = 'clamp(1.75rem, 9vw, 4.5rem)' return (
{rows.map((word, i) => ( ))} {}
) } ``` --- ## Slice Type Category: Typography Slug: `slice-type` URL: https://aicanvas.me/components/slice-type A typographic magic trick. At rest you read one ambiguous word; hover and it splits into two: LIGHT lifting up, NIGHT sinking down, the shared letters resolving into separate glyphs. Install (free account): ```bash npx shadcn@latest add @aicanvas/slice-type ``` ```tsx 'use client' // npm install framer-motion /** * Renders sliced typography that transitions between contrasting color modes. * Hovering offsets the letter slices and reveals the alternate word treatment. */ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { animate, motion, useMotionValue, useSpring, useTransform, } from 'framer-motion' import type { MotionValue } from 'framer-motion' const DARK_BG = '#0A0A0A' const LIGHT_BG = '#EFEEE6' const DARK_FG = '#EFEEE6' const LIGHT_FG = '#0A0A0A' function measureLeftInk(char: string, font: string): number { try { const W = 200, H = 200 const canvas = document.createElement('canvas') canvas.width = W canvas.height = H const ctx = canvas.getContext('2d') if (!ctx) return 0 ctx.font = font ctx.textBaseline = 'alphabetic' ctx.fillStyle = '#000' ctx.fillText(char, 50, 150) const { data } = ctx.getImageData(0, 0, W, H) for (let x = 0; x < W; x++) { for (let y = 0; y < H; y++) { if (data[(y * W + x) * 4 + 3] > 32) return x - 50 } } return 0 } catch { return 0 } } function mix(a: string, b: string, t: number): string { const pa = parseInt(a.slice(1), 16) const pb = parseInt(b.slice(1), 16) const ar = (pa >> 16) & 0xff; const ag = (pa >> 8) & 0xff; const ab = pa & 0xff const br = (pb >> 16) & 0xff; const bg = (pb >> 8) & 0xff; const bb = pb & 0xff const r = Math.round(ar + (br - ar) * t) const g = Math.round(ag + (bg - ag) * t) const bl = Math.round(ab + (bb - ab) * t) return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}` } // customize: replace the paired words below const WORD_TOP = 'LIGHT' const WORD_BOTTOM = 'NIGHT' // tune: raise to separate the slices farther const OPEN_OFFSET = 0.65 // tune: raise to delay the introductory reveal const INTRO_DELAY_MS = 700 // tune: raise to hold the introductory reveal longer const INTRO_HOLD_MS = 1100 const INTRO_PEAK = 0.7 const INTRO_DURATION_S = 0.9 export default function SliceType() { const rootRef = useRef(null) const [reducedMotion, setReducedMotion] = useState(false) useEffect(() => { if (typeof window === 'undefined') return const mq = window.matchMedia('(prefers-reduced-motion: reduce)') const update = () => setReducedMotion(mq.matches) update() mq.addEventListener('change', update) return () => mq.removeEventListener('change', update) }, []) const engage = useMotionValue(0) const engageSmooth = useSpring(engage, { stiffness: 140, damping: 18, mass: 0.9 }) const bgColor = useTransform(engageSmooth, (e) => mix(DARK_BG, LIGHT_BG, e)) const fgColor = useTransform(engageSmooth, (e) => mix(DARK_FG, LIGHT_FG, e)) const topClip = useTransform(engageSmooth, (e) => `inset(0 0 ${50 * (1 - e)}% 0)`, ) const topY = useTransform(engageSmooth, (e) => `${-OPEN_OFFSET * 100 * e}%`) const botClip = useTransform(engageSmooth, (e) => `inset(${50 * (1 - e)}% 0 0 0)`, ) const botY = useTransform(engageSmooth, (e) => `${OPEN_OFFSET * 100 * e}%`) const containerRef = useRef(null) const lRef = useRef(null) const ightRef = useRef(null) const naturalLeftMV: MotionValue = useMotionValue(0) const nudgeMV: MotionValue = useMotionValue(0) useLayoutEffect(() => { const container = containerRef.current const lEl = lRef.current const ightEl = ightRef.current if (!container || !lEl || !ightEl) return const measure = () => { const cRect = container.getBoundingClientRect() const lRect = lEl.getBoundingClientRect() const iRect = ightEl.getBoundingClientRect() naturalLeftMV.set(Math.max(0, iRect.left - cRect.left - lRect.width)) const computed = window.getComputedStyle(lEl) const font = `900 ${computed.fontSize} ${computed.fontFamily}` const lInk = measureLeftInk('L', font) const nInk = measureLeftInk('N', font) nudgeMV.set(nInk - lInk) } measure() const ro = new ResizeObserver(measure) ro.observe(container) return () => ro.disconnect() }, [naturalLeftMV, nudgeMV]) const lX = useTransform( [engageSmooth, naturalLeftMV, nudgeMV], ([e, natural, nudge]) => { const eN = e as number return `${(nudge as number) * (1 - eN) + (natural as number) * eN}px` }, ) const didIntro = useRef(false) useEffect(() => { if (didIntro.current) return if (reducedMotion) { didIntro.current = true return } didIntro.current = true let cancelled = false let closeTimer: ReturnType | null = null const startTimer = setTimeout(async () => { if (cancelled) return const opener = animate(engage, INTRO_PEAK, { duration: INTRO_DURATION_S, ease: [0.22, 1, 0.36, 1], }) try { await opener } catch { } if (cancelled) return closeTimer = setTimeout(() => { if (cancelled) return animate(engage, 0, { duration: INTRO_DURATION_S, ease: [0.32, 0, 0.36, 1], }) }, INTRO_HOLD_MS) }, INTRO_DELAY_MS) const cancel = () => { cancelled = true clearTimeout(startTimer) if (closeTimer) clearTimeout(closeTimer) } cancelTeaserRef.current = cancel return cancel }, [engage, reducedMotion]) const touchOpenRef = useRef(false) const cancelTeaserRef = useRef<(() => void) | null>(null) const cancelTeaser = () => { cancelTeaserRef.current?.() cancelTeaserRef.current = null } const handlePointerEnter = (e: React.PointerEvent) => { if (e.pointerType !== 'mouse') return cancelTeaser() engage.set(1) } const handlePointerLeave = (e: React.PointerEvent) => { if (e.pointerType !== 'mouse') return engage.set(0) } const handlePointerDown = (e: React.PointerEvent) => { if (e.pointerType === 'mouse') return cancelTeaser() touchOpenRef.current = !touchOpenRef.current engage.set(touchOpenRef.current ? 1 : 0) } const sharedTextStyle: React.CSSProperties = { fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif', fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 11rem)', lineHeight: 0.92, letterSpacing: '-0.04em', color: 'inherit', whiteSpace: 'nowrap', userSelect: 'none', } const TAIL = WORD_TOP.slice(1) return (
{} {WORD_BOTTOM} {} {} {WORD_TOP.charAt(0)} {} {TAIL} {} {WORD_BOTTOM}
) } ``` --- ## Halo Type Category: Typography Slug: `halo-type` URL: https://aicanvas.me/components/halo-type Rotating 3D ring text animation: the front arc reads upright, the back arc upside-down. Install (free account): ```bash npx shadcn@latest add @aicanvas/halo-type ``` ```tsx 'use client' // npm install framer-motion /** * Renders a rotating ring of individually positioned text glyphs. * Hover and touch slow the rotation, while reduced motion freezes the ring. */ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import { motion, useAnimationFrame, useMotionValue, useSpring, useTransform, } from 'framer-motion' // customize: replace the ring phrase below const PHRASE = 'COPY ✦ PASTE ✦ SHIP ✦ REPEAT ✦ ' // tune: raise to tilt the ring farther at rest const TILT_REST = 24 // tune: raise to tilt the ring farther during interaction const TILT_HOVER = 24 // tune: raise to slow the rotation const SECONDS_PER_TURN = 14 // tune: raise to preserve more speed during interaction const SPEED_HOVER = 0.3 // tune: raise to widen the ring const RADIUS_FRACTION = 0.35 // tune: raise to enlarge the separators const STAR_SCALE = 0.65 function useTheme(ref: React.RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const read = () => { const scope = element.closest('[data-card-theme]') as HTMLElement | null if (scope) { setTheme(scope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } read() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const o = new MutationObserver(read) o.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(o) current = current.parentElement } return () => observers.forEach((o) => o.disconnect()) }, [ref]) return { theme } } export default function HaloType() { const rootRef = useRef(null) const wrapRef = useRef(null) const { theme } = useTheme(rootRef) const isDark = theme === 'dark' const [size, setSize] = useState(480) useLayoutEffect(() => { const el = wrapRef.current if (!el) return const measure = () => { const rect = el.getBoundingClientRect() setSize(Math.max(260, Math.min(rect.width, rect.height))) } measure() const ro = new ResizeObserver(measure) ro.observe(el) return () => ro.disconnect() }, []) const [reducedMotion, setReducedMotion] = useState(false) useEffect(() => { if (typeof window === 'undefined') return const mq = window.matchMedia('(prefers-reduced-motion: reduce)') const update = () => setReducedMotion(mq.matches) update() mq.addEventListener('change', update) return () => mq.removeEventListener('change', update) }, []) const engagement = useMotionValue(0) const engagementSmooth = useSpring(engagement, { stiffness: 120, damping: 22, mass: 0.6, }) const tilt = useTransform(engagementSmooth, [0, 1], [TILT_REST, TILT_HOVER]) const speedMul = useTransform(engagementSmooth, [0, 1], [1, SPEED_HOVER]) const rotateY = useMotionValue(0) const prevTs = useRef(null) const frontRefs = useRef>([]) const backRefs = useRef>([]) const radius = size * RADIUS_FRACTION const fontSize = Math.max(20, Math.min(48, radius * 0.22)) const circumference = 2 * Math.PI * radius const [phraseWidths, setPhraseWidths] = useState([]) const measureRef = useRef(null) useLayoutEffect(() => { if (typeof document === 'undefined') return const el = measureRef.current if (!el) return const measure = () => { const spans = el.querySelectorAll('[data-m-char]') if (!spans.length) return const widths = Array.from(spans).map((s) => s.getBoundingClientRect().width) setPhraseWidths(widths) } measure() const ro = new ResizeObserver(measure) ro.observe(el) const fonts = (document as Document & { fonts?: FontFaceSet }).fonts if (fonts?.ready) { fonts.ready.then(measure) } return () => ro.disconnect() }, [fontSize]) const phraseWidth = phraseWidths.reduce((a, b) => a + b, 0) const repeats = phraseWidth > 0 ? Math.max(1, Math.round(circumference / phraseWidth)) : Math.max(1, Math.round(circumference / (PHRASE.length * fontSize * 0.55))) const characters = PHRASE.repeat(repeats).split('') const totalChars = characters.length const totalTiledWidth = phraseWidth * repeats const charAngles: number[] = [] if (totalTiledWidth > 0) { let cum = 0 characters.forEach((_, i) => { const w = phraseWidths[i % PHRASE.length] ?? 0 charAngles.push(((cum + w / 2) / totalTiledWidth) * 360) cum += w }) } else { for (let i = 0; i < totalChars; i++) { charAngles.push((i / totalChars) * 360) } } useAnimationFrame((t) => { if (reducedMotion) { rotateY.set(30) } else { const last = prevTs.current prevTs.current = t if (last != null) { const dt = (t - last) / 1000 const degPerSec = 360 / SECONDS_PER_TURN const next = rotateY.get() + degPerSec * speedMul.get() * dt rotateY.set(next % 360) } } const ry = rotateY.get() for (let i = 0; i < charAngles.length; i++) { const eff = ((ry + charAngles[i]) % 360 + 360) % 360 const norm = eff > 180 ? eff - 360 : eff const c = Math.cos((norm * Math.PI) / 180) const frontOp = c > 0 ? c : 0 const backOp = c < 0 ? -c : 0 const fe = frontRefs.current[i] const be = backRefs.current[i] if (fe) fe.style.opacity = String(frontOp) if (be) be.style.opacity = String(backOp) } }) const bg = isDark ? '#0A0A0A' : '#F5F1E8' const fg = isDark ? '#F5F1E8' : '#0A0A0A' const fgBack = isDark ? '#7A756C' : '#6A655C' const vignette = isDark ? `radial-gradient(60% ${size * 0.6}px at 50% 52%, rgba(245,241,232,0.10) 0%, rgba(245,241,232,0.04) 35%, rgba(10,10,10,0) 70%)` : `radial-gradient(60% ${size * 0.6}px at 50% 52%, rgba(10,10,10,0.08) 0%, rgba(10,10,10,0.03) 35%, rgba(245,241,232,0) 70%)` const handleEngage = () => engagement.set(1) const handleRelease = () => engagement.set(0) const ringTransform = useTransform( [tilt, rotateY], ([x, y]) => `rotateX(${x}deg) rotateY(${y}deg)`, ) const edgeMask = 'linear-gradient(to right, transparent 0%, black 18%, black 82%, transparent 100%)' return (
{}
{} {PHRASE.split('').map((ch, i) => ( {ch} ))} {}
{characters.map((ch, i) => { const angle = charAngles[i] ?? (i / totalChars) * 360 const display = ch === ' ' ? ' ' : ch const base = `translate(-50%, -50%) rotateY(${angle}deg) translateZ(${radius}px)` const glyphSize = ch === '✦' ? fontSize * STAR_SCALE : fontSize const glyphStyle: React.CSSProperties = { position: 'absolute', top: '50%', left: '50%', transformOrigin: '50% 50%', fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif', fontWeight: 800, fontSize: glyphSize, lineHeight: 1, letterSpacing: '-0.01em', userSelect: 'none', whiteSpace: 'pre', } return ( {} { frontRefs.current[i] = el }} style={{ ...glyphStyle, transform: base, color: fg, }} > {display} {} { backRefs.current[i] = el }} style={{ ...glyphStyle, transform: `${base} rotateY(180deg) rotateZ(180deg)`, color: fgBack, }} > {display} ) })}
) } ``` --- ## Orbit Category: Typography Slug: `orbit` URL: https://aicanvas.me/components/orbit Kinetic text animation arranged in a circle. Hover slows the spin; letters push outward. Install (free account): ```bash npx shadcn@latest add @aicanvas/orbit ``` ```tsx 'use client' // npm install next // font: Anton /** * Displays text arranged around a responsive elliptical orbit. * Pointer movement shifts the ring perspective and its central marker. */ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import { Anton } from 'next/font/google' const anton = Anton({ subsets: ['latin'], weight: '400' }) function useTheme(ref: React.RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const readTheme = () => { const cardScope = element.closest('[data-card-theme]') as HTMLElement | null if (cardScope) { setTheme(cardScope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } readTheme() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const observer = new MutationObserver(readTheme) observer.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(observer) current = current.parentElement } return () => observers.forEach((o) => o.disconnect()) }, [ref]) return { theme } } // customize: replace the orbit phrase below const BASE_TEXT = 'KEEP MOVING • KEEP MOVING • ' const FULL_TEXT = BASE_TEXT + BASE_TEXT const LETTERS = FULL_TEXT.split('') const TOTAL = LETTERS.length const INFLUENCE_RADIUS = 400 const MAX_SCALE = 1.5 const MAX_PUSH = 22 const EASE_ACTIVE = 0.15 const EASE_EXIT = 0.05 const SPEED_NORMAL = 0.3 const SPEED_SLOW = 0.075 const SPEED_EASE = 0.03 const TEXT_RADIUS_RATIO = 0.88 export default function Orbit() { const containerRef = useRef(null) const wheelRef = useRef(null) const { theme } = useTheme(containerRef) const isDark = theme === 'dark' const letterRefs = useRef<(HTMLSpanElement | null)[]>([]) const starRef = useRef(null) const mouseRef = useRef<{ x: number; y: number } | null>(null) const animIdRef = useRef(0) const aliveRef = useRef(true) const isHoveredRef = useRef(false) const rotationRef = useRef(0) const speedRef = useRef(SPEED_NORMAL) const stateRef = useRef(LETTERS.map(() => ({ scale: 1, pushX: 0, pushY: 0 }))) const [size, setSize] = useState(400) useLayoutEffect(() => { const el = wheelRef.current if (!el) return setSize(el.offsetWidth) const ro = new ResizeObserver(() => { setSize(el.offsetWidth) }) ro.observe(el) return () => ro.disconnect() }, []) useEffect(() => { aliveRef.current = true function animate() { if (!aliveRef.current) return const wheel = wheelRef.current if (wheel) { const targetSpeed = isHoveredRef.current ? SPEED_SLOW : SPEED_NORMAL speedRef.current += (targetSpeed - speedRef.current) * SPEED_EASE rotationRef.current += speedRef.current wheel.style.transform = `rotate(${rotationRef.current}deg)` } const star = starRef.current if (star) { star.style.transform = `translate(-50%, -50%) rotate(${-rotationRef.current * 1.5}deg)` } const mx = mouseRef.current?.x ?? -99999 const my = mouseRef.current?.y ?? -99999 const isExiting = !mouseRef.current const wheelEl = wheelRef.current const wheelRect = wheelEl?.getBoundingClientRect() const wheelCx = wheelRect ? wheelRect.left + wheelRect.width / 2 : 0 const wheelCy = wheelRect ? wheelRect.top + wheelRect.height / 2 : 0 LETTERS.forEach((_, i) => { const el = letterRefs.current[i] if (!el) return const rect = el.getBoundingClientRect() const lx = rect.left + rect.width / 2 const ly = rect.top + rect.height / 2 const dx = lx - mx const dy = ly - my const dist = Math.sqrt(dx * dx + dy * dy) let influence = 0 if (dist < INFLUENCE_RADIUS) { influence = 1 - dist / INFLUENCE_RADIUS influence = influence * influence * (3 - 2 * influence) } const targetScale = 1 + (MAX_SCALE - 1) * influence const rdx = lx - wheelCx const rdy = ly - wheelCy const rlen = Math.sqrt(rdx * rdx + rdy * rdy) || 1 const targetPushX = (rdx / rlen) * MAX_PUSH * influence const targetPushY = (rdy / rlen) * MAX_PUSH * influence const state = stateRef.current[i] const easing = isExiting ? EASE_EXIT : EASE_ACTIVE state.scale += (targetScale - state.scale) * easing state.pushX += (targetPushX - state.pushX) * easing state.pushY += (targetPushY - state.pushY) * easing el.style.setProperty('--scale', state.scale.toFixed(3)) el.style.setProperty('--push-x', `${state.pushX.toFixed(2)}px`) el.style.setProperty('--push-y', `${state.pushY.toFixed(2)}px`) }) animIdRef.current = requestAnimationFrame(animate) } animate() return () => { aliveRef.current = false cancelAnimationFrame(animIdRef.current) } }, []) const bgColor = isDark ? '#1A1A19' : '#E8E8DF' const textColor = isDark ? '#E8E8DF' : '#1A1A19' const radius = size / 2 const textRadius = radius * TEXT_RADIUS_RATIO const fontSize = Math.max(8, size * 0.076) return (
{ mouseRef.current = { x: e.clientX, y: e.clientY } isHoveredRef.current = true }} onMouseLeave={() => { mouseRef.current = null isHoveredRef.current = false }} onTouchStart={(e) => { const touch = e.touches[0] if (touch) { mouseRef.current = { x: touch.clientX, y: touch.clientY } isHoveredRef.current = true } }} onTouchMove={(e) => { const touch = e.touches[0] if (touch) { mouseRef.current = { x: touch.clientX, y: touch.clientY } isHoveredRef.current = true } }} onTouchEnd={() => { setTimeout(() => { mouseRef.current = null isHoveredRef.current = false }, 600) }} >
{LETTERS.map((letter, i) => { const angle = (i / TOTAL) * 2 * Math.PI - Math.PI / 2 const x = radius + textRadius * Math.cos(angle) const y = radius + textRadius * Math.sin(angle) const rotDeg = (angle * 180) / Math.PI + 90 return ( { letterRefs.current[i] = el }} style={{ display: 'inline-block', fontSize, lineHeight: 1, color: textColor, transform: 'translate(var(--push-x, 0px), var(--push-y, 0px)) scale(var(--scale, 1))', willChange: 'transform', userSelect: 'none', }} > {letter} ) })}
{[0, 1, 2, 3].map((i) => (
))}
) } ``` --- ## Wild Morph Category: Typography Slug: `wild-morph` URL: https://aicanvas.me/components/wild-morph Italic SVG word that warps under the cursor, a spring-physics text animation. Install (free account): ```bash npx shadcn@latest add @aicanvas/wild-morph ``` ```tsx 'use client' // npm install framer-motion // font: Anton /** * Renders a word whose corners warp through a matrix transform. * Pointer position selects an upper or lower deformation before easing back at rest. */ import { useEffect, useRef, useState } from 'react' import React from 'react' import { animate, useMotionValue, useMotionValueEvent, } from 'framer-motion' import type { AnimationPlaybackControls, MotionValue } from 'framer-motion' const DARK_BG = '#1a1a19' const LIGHT_BG = '#efeee6' function useTheme(ref: React.RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const readTheme = () => { const cardScope = element.closest('[data-card-theme]') as HTMLElement | null if (cardScope) { setTheme(cardScope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } readTheme() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const observer = new MutationObserver(readTheme) observer.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(observer) current = current.parentElement } return () => observers.forEach((o) => o.disconnect()) }, [ref]) return { theme } } const ENTER_SPRING = { type: 'spring', stiffness: 280, damping: 14, mass: 1.8 } as const const RETURN_SPRING = { type: 'spring', stiffness: 280, damping: 16, mass: 1.8 } as const type Pt = readonly [number, number] function solve8( A: number[][], b: number[], ): number[] { const n = 8 const M: number[][] = A.map((row, i) => [...row, b[i]]) for (let col = 0; col < n; col++) { let pivot = col let maxAbs = Math.abs(M[col][col]) for (let r = col + 1; r < n; r++) { const v = Math.abs(M[r][col]) if (v > maxAbs) { maxAbs = v pivot = r } } if (maxAbs < 1e-12) { return [1, 0, 0, 1, 0, 0, 0, 0] } if (pivot !== col) { const tmp = M[col] M[col] = M[pivot] M[pivot] = tmp } const p = M[col][col] for (let c = col; c <= n; c++) M[col][c] /= p for (let r = 0; r < n; r++) { if (r === col) continue const factor = M[r][col] if (factor === 0) continue for (let c = col; c <= n; c++) { M[r][c] -= factor * M[col][c] } } } const x: number[] = new Array(n) for (let i = 0; i < n; i++) x[i] = M[i][n] return x } function cornerPinMatrix3d( tl: Pt, tr: Pt, br: Pt, bl: Pt, w: number, h: number, ): string { const src: readonly Pt[] = [ [0, 0], [w, 0], [w, h], [0, h], ] as const const dst: readonly Pt[] = [tl, tr, br, bl] as const const A: number[][] = [] const rhs: number[] = [] for (let i = 0; i < 4; i++) { const [u, v] = src[i] const [x, y] = dst[i] A.push([u, 0, v, 0, 1, 0, -u * x, -v * x]) rhs.push(x) A.push([0, u, 0, v, 0, 1, -u * y, -v * y]) rhs.push(y) } const [a, b, c, d, e, f, g, hh] = solve8(A, rhs) return `matrix3d(${a},${b},0,${g},${c},${d},0,${hh},0,0,1,0,${e},${f},0,1)` } // tune: increase the magnitudes below to strengthen the corner warp const X_OUTER_LEFT = -1.0 const X_OUTER_RIGHT = 1.05 const Y_LEFT_MAG = 1.55 const Y_RIGHT_MAG = 1.48 type Mode = 'none' | 'top' | 'bottom' type Offsets8 = readonly [ number, number, number, number, number, number, number, number, ] function targetsFor(mode: Mode, w: number, h: number): Offsets8 { if (mode === 'top') { return [ X_OUTER_LEFT * w, -Y_LEFT_MAG * h, X_OUTER_RIGHT * w, -Y_RIGHT_MAG * h, 0, 0, 0, 0, ] as const } if (mode === 'bottom') { return [ 0, 0, 0, 0, X_OUTER_LEFT * w, Y_LEFT_MAG * h, X_OUTER_RIGHT * w, Y_RIGHT_MAG * h, ] as const } return [0, 0, 0, 0, 0, 0, 0, 0] as const } export default function WildMorph({ text = 'wild' }: { text?: string }) { const containerRef = useRef(null) const { theme } = useTheme(containerRef) const isDark = theme === 'dark' const bgColor = isDark ? DARK_BG : LIGHT_BG const inkColor = isDark ? LIGHT_BG : DARK_BG const tlX: MotionValue = useMotionValue(0) const tlY: MotionValue = useMotionValue(0) const trX: MotionValue = useMotionValue(0) const trY: MotionValue = useMotionValue(0) const blX: MotionValue = useMotionValue(0) const blY: MotionValue = useMotionValue(0) const brX: MotionValue = useMotionValue(0) const brY: MotionValue = useMotionValue(0) const warpRef = useRef(null) const panelRef = useRef(null) const svgRef = useRef(null) const textRef = useRef(null) const sizeRef = useRef<{ w: number; h: number }>({ w: 1, h: 1 }) const controlsRef = useRef([]) const modeRef = useRef('none') const [natural, setNatural] = useState({ width: 1, height: 1, ascent: 1 }) const [fontSize, setFontSize] = useState(144) useEffect(() => { const update = () => { const vw = window.innerWidth const clamped = Math.min(Math.max(vw * 0.12, 64), 144) setFontSize(clamped) } update() window.addEventListener('resize', update) return () => window.removeEventListener('resize', update) }, []) useEffect(() => { const el = textRef.current if (!el) return const bbox = el.getBBox() if (bbox.width === 0) return setNatural({ width: bbox.width, height: bbox.height, ascent: -bbox.y, }) }, [fontSize, text]) const applyTransform = () => { const el = warpRef.current if (!el) return const { w, h } = sizeRef.current const tl: Pt = [0 + tlX.get(), 0 + tlY.get()] const tr: Pt = [w + trX.get(), 0 + trY.get()] const br: Pt = [w + brX.get(), h + brY.get()] const bl: Pt = [0 + blX.get(), h + blY.get()] el.style.transform = cornerPinMatrix3d(tl, tr, br, bl, w, h) } useEffect(() => { if (natural.width > 1 && natural.height > 1) { sizeRef.current = { w: natural.width, h: natural.height } applyTransform() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [natural]) useEffect(() => { const el = warpRef.current if (!el) return const update = () => { const w = el.offsetWidth const h = el.offsetHeight if (w > 0 && h > 0) { sizeRef.current = { w, h } applyTransform() } } const ro = new ResizeObserver(update) ro.observe(el) update() return () => ro.disconnect() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useMotionValueEvent(tlX, 'change', applyTransform) useMotionValueEvent(tlY, 'change', applyTransform) useMotionValueEvent(trX, 'change', applyTransform) useMotionValueEvent(trY, 'change', applyTransform) useMotionValueEvent(blX, 'change', applyTransform) useMotionValueEvent(blY, 'change', applyTransform) useMotionValueEvent(brX, 'change', applyTransform) useMotionValueEvent(brY, 'change', applyTransform) const stopAll = () => { for (const c of controlsRef.current) c.stop() controlsRef.current = [] } const applyMode = (next: Mode) => { if (next === modeRef.current) return modeRef.current = next if (warpRef.current) { warpRef.current.style.transformOrigin = '0 0' } stopAll() const { w, h } = sizeRef.current const targets = targetsFor(next, w, h) const opts = next === 'none' ? RETURN_SPRING : ENTER_SPRING controlsRef.current = [ animate(tlX, targets[0], opts), animate(tlY, targets[1], opts), animate(trX, targets[2], opts), animate(trY, targets[3], opts), animate(blX, targets[4], opts), animate(blY, targets[5], opts), animate(brX, targets[6], opts), animate(brY, targets[7], opts), ] } useEffect(() => { return () => stopAll() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const modeFromClientY = (clientY: number): Mode => { const el = panelRef.current if (!el) return 'none' const rect = el.getBoundingClientRect() if (clientY < rect.top || clientY > rect.bottom) return 'none' return clientY < rect.top + rect.height / 2 ? 'top' : 'bottom' } const onPointerDown = (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId) applyMode(modeFromClientY(e.clientY)) } const onPointerMove = (e: React.PointerEvent) => { applyMode(modeFromClientY(e.clientY)) } const onPointerUp = () => applyMode('none') const onPointerLeave = () => applyMode('none') const onPointerCancel = () => applyMode('none') return (
{ (containerRef as React.MutableRefObject).current = el; (panelRef as React.MutableRefObject).current = el; }} className="relative flex min-h-screen w-full select-none items-center justify-center" style={{ backgroundColor: bgColor, perspective: '1400px', perspectiveOrigin: '50% 50%', cursor: 'crosshair', touchAction: 'none', }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerLeave={onPointerLeave} onPointerCancel={onPointerCancel} >
{text}
) } ``` --- ## Playful Category: Typography Slug: `playful` URL: https://aicanvas.me/components/playful Kinetic typography where letters jump, drop, and rotate on cursor proximity. Install (free account): ```bash npx shadcn@latest add @aicanvas/playful ``` ```tsx 'use client' // npm install framer-motion next // font: Science Gothic /** * Renders variable-font lettering that responds to pointer proximity. * Nearby glyphs change width, weight, and position as the pointer moves. */ import React, { useEffect, useRef, useState } from 'react' import { motion } from 'framer-motion' import { Science_Gothic } from 'next/font/google' const scienceGothic = Science_Gothic({ subsets: ['latin'] }) function useTheme(ref: React.RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const readTheme = () => { const cardScope = element.closest('[data-card-theme]') as HTMLElement | null if (cardScope) { setTheme(cardScope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } readTheme() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const observer = new MutationObserver(readTheme) observer.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(observer) current = current.parentElement } return () => observers.forEach((o) => o.disconnect()) }, [ref]) return { theme } } interface LetterSpanProps { letter: string textColor: string fontFamily: string textShadow: string forwardedRef: React.Ref } const LetterSpanComponent = ({ letter, textColor, fontFamily, textShadow, forwardedRef }: LetterSpanProps) => { const spanRef = useRef(null) useEffect(() => { if (forwardedRef) { if (typeof forwardedRef === 'function') { forwardedRef(spanRef.current) } else { forwardedRef.current = spanRef.current } } }, [forwardedRef]) return ( {letter} ) } const LetterSpan = motion(LetterSpanComponent) // customize: replace the two text rows below const TEXT_ROW_1 = 'STAY' const TEXT_ROW_2 = 'WEIRD' const ALL_LETTERS = TEXT_ROW_1 + TEXT_ROW_2 // tune: raise to widen pointer influence const INFLUENCE_RADIUS = 600 // tune: raise to increase maximum glyph rotation const MAX_ROTATION = 75 // tune: raise to increase vertical glyph travel const MAX_TRANSLATE_Y = 100 // tune: raise to enlarge nearby glyphs further const MAX_SCALE = 1.4 const MIN_SCALE = 1 // tune: raise to quicken the active response const EASE_ACTIVE = 0.15 // tune: raise to quicken the return to rest const EASE_EXIT = 0.05 export default function Playful() { const containerRef = useRef(null) const { theme } = useTheme(containerRef) const isDark = theme === 'dark' const lettersRef = useRef<(HTMLSpanElement | null)[]>([]) const mouseRef = useRef<{ x: number; y: number } | null>(null) const animIdRef = useRef(0) const aliveRef = useRef(true) const stateRef = useRef< Array<{ rotate: number translateY: number scale: number }> >([]) useEffect(() => { aliveRef.current = true const container = containerRef.current if (!container) return ALL_LETTERS.split('').forEach((_, i) => { if (!stateRef.current[i]) { stateRef.current[i] = { rotate: 0, translateY: 0, scale: MIN_SCALE, } } }) function animate() { if (!aliveRef.current) return const mx = mouseRef.current?.x ?? -99999 const my = mouseRef.current?.y ?? -99999 const isExiting = !mouseRef.current ALL_LETTERS.split('').forEach((_, i) => { const letterEl = lettersRef.current[i] if (!letterEl) return const rect = letterEl.getBoundingClientRect() const letterCenterX = rect.left + rect.width / 2 const letterCenterY = rect.top + rect.height / 2 const dx = letterCenterX - mx const dy = letterCenterY - my const dist = Math.sqrt(dx * dx + dy * dy) let influence = 0 if (dist < INFLUENCE_RADIUS) { influence = 1 - dist / INFLUENCE_RADIUS influence = influence * influence * (3 - 2 * influence) } let targetRotate = 0 if (influence > 0) { const angle = Math.atan2(dy, dx) const rotateDirection = Math.sin(angle) targetRotate = MAX_ROTATION * influence * rotateDirection } const direction = i % 2 === 0 ? -1 : 1 const targetTranslateY = MAX_TRANSLATE_Y * influence * direction const targetScale = MIN_SCALE + (MAX_SCALE - MIN_SCALE) * influence const state = stateRef.current[i] const easing = isExiting ? EASE_EXIT : EASE_ACTIVE state.rotate += (targetRotate - state.rotate) * easing state.translateY += (targetTranslateY - state.translateY) * easing state.scale += (targetScale - state.scale) * easing letterEl.style.setProperty('--rotate', `${state.rotate.toFixed(2)}deg`) letterEl.style.setProperty('--translate-y', `${state.translateY.toFixed(2)}px`) letterEl.style.setProperty('--scale', state.scale.toFixed(3)) }) animIdRef.current = requestAnimationFrame(animate) } animate() return () => { aliveRef.current = false if (animIdRef.current) cancelAnimationFrame(animIdRef.current) } }, []) function handleMouseMove(e: React.MouseEvent) { mouseRef.current = { x: e.clientX, y: e.clientY } } function handleMouseLeave() { mouseRef.current = null } function handleTouchStart(e: React.TouchEvent) { const touch = e.touches[0] if (touch) mouseRef.current = { x: touch.clientX, y: touch.clientY } } function handleTouchMove(e: React.TouchEvent) { const touch = e.touches[0] if (touch) mouseRef.current = { x: touch.clientX, y: touch.clientY } } function handleTouchEnd() { setTimeout(() => { mouseRef.current = null }, 600) } const bgColor = isDark ? '#1A1A19' : '#869631' const textColor = isDark ? 'text-[#869631]' : 'text-[#1A1A19]' const textShadow = isDark ? '2px 2px 0 rgba(0, 0, 0, 0.85)' : '2px 2px 0 rgba(0, 0, 0, 0.25)' return (
{TEXT_ROW_1.split('').map((letter, i) => ( { if (el) lettersRef.current[i] = el }} textColor={textColor} textShadow={textShadow} fontFamily={scienceGothic.style.fontFamily} /> ))}
{TEXT_ROW_2.split('').map((letter, i) => ( { if (el) lettersRef.current[i + TEXT_ROW_1.length] = el }} textColor={textColor} textShadow={textShadow} fontFamily={scienceGothic.style.fontFamily} /> ))}
) } ``` --- ## Good Vibes Category: Typography Slug: `good-vibes` URL: https://aicanvas.me/components/good-vibes Interactive typography where letters scale 2x and become bold on hover. Install (free account): ```bash npx shadcn@latest add @aicanvas/good-vibes ``` ```tsx 'use client' // npm install framer-motion next // font: Science Gothic /** * Renders variable-font text whose glyphs react to pointer and touch proximity. * Nearby letters gain weight, scale, and spacing with eased falloff. */ import React, { useEffect, useRef, useState } from 'react' import { motion } from 'framer-motion' import { Science_Gothic } from 'next/font/google' const scienceGothic = Science_Gothic({ subsets: ['latin'] }) function useTheme(ref: React.RefObject) { const [theme, setTheme] = useState<'light' | 'dark'>('dark') useEffect(() => { const element = ref.current if (!element) return const readTheme = () => { const cardScope = element.closest('[data-card-theme]') as HTMLElement | null if (cardScope) { setTheme(cardScope.dataset.cardTheme === 'dark' ? 'dark' : 'light') return } setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light') } readTheme() const observers: MutationObserver[] = [] let current: HTMLElement | null = element while (current) { const observer = new MutationObserver(readTheme) observer.observe(current, { attributes: true, attributeFilter: ['class', 'data-card-theme'] }) observers.push(observer) current = current.parentElement } return () => observers.forEach((o) => o.disconnect()) }, [ref]) return { theme } } interface LetterSpanProps { letter: string textColor: string fontFamily: string forwardedRef: React.Ref } const LetterSpanComponent = ({ letter, textColor, fontFamily, forwardedRef }: LetterSpanProps) => { const spanRef = useRef(null) useEffect(() => { if (forwardedRef) { if (typeof forwardedRef === 'function') { forwardedRef(spanRef.current) } else { forwardedRef.current = spanRef.current } } }, [forwardedRef]) return ( {letter} ) } const LetterSpan = motion(LetterSpanComponent) // customize: change the text and pointer response values below const TEXT = 'GOOD VIBES' const INFLUENCE_RADIUS = 300 const MAX_WEIGHT = 700 const MIN_WEIGHT = 100 const MAX_SCALE = 2 const MIN_SCALE = 1 const MAX_LETTER_SPACING = 0.3 const MIN_LETTER_SPACING = 0 const EASE_DURATION = 0.15 export default function GoodVibes() { const containerRef = useRef(null) const { theme } = useTheme(containerRef) const isDark = theme === 'dark' const lettersRef = useRef<(HTMLSpanElement | null)[]>([]) const mouseRef = useRef<{ x: number; y: number } | null>(null) const animIdRef = useRef(0) const aliveRef = useRef(true) const stateRef = useRef< Array<{ weight: number scale: number letterSpacing: number }> >([]) useEffect(() => { aliveRef.current = true const container = containerRef.current if (!container) return TEXT.split('').forEach((_, i) => { if (!stateRef.current[i]) { stateRef.current[i] = { weight: MIN_WEIGHT, scale: MIN_SCALE, letterSpacing: MIN_LETTER_SPACING, } } }) function animate() { if (!aliveRef.current) return const mx = mouseRef.current?.x ?? -99999 const my = mouseRef.current?.y ?? -99999 const isExiting = !mouseRef.current TEXT.split('').forEach((_, i) => { const letterEl = lettersRef.current[i] if (!letterEl) return const rect = letterEl.getBoundingClientRect() const letterCenterX = rect.left + rect.width / 2 const letterCenterY = rect.top + rect.height / 2 const dx = letterCenterX - mx const dy = letterCenterY - my const dist = Math.sqrt(dx * dx + dy * dy) let influence = 0 if (dist < INFLUENCE_RADIUS) { influence = 1 - dist / INFLUENCE_RADIUS influence = influence * influence * (3 - 2 * influence) } const targetWeight = MIN_WEIGHT + (MAX_WEIGHT - MIN_WEIGHT) * influence const targetScale = MIN_SCALE + (MAX_SCALE - MIN_SCALE) * influence const targetLetterSpacing = MIN_LETTER_SPACING + (MAX_LETTER_SPACING - MIN_LETTER_SPACING) * influence const state = stateRef.current[i] const easing = isExiting ? 0.05 : EASE_DURATION state.weight += (targetWeight - state.weight) * easing state.scale += (targetScale - state.scale) * easing state.letterSpacing += (targetLetterSpacing - state.letterSpacing) * easing letterEl.style.setProperty('--font-weight', Math.round(state.weight).toString()) letterEl.style.setProperty('--scale', state.scale.toFixed(3)) letterEl.style.setProperty('--letter-spacing', `${state.letterSpacing.toFixed(3)}em`) }) animIdRef.current = requestAnimationFrame(animate) } animate() return () => { aliveRef.current = false if (animIdRef.current) cancelAnimationFrame(animIdRef.current) } }, []) function handleMouseMove(e: React.MouseEvent) { mouseRef.current = { x: e.clientX, y: e.clientY } } function handleMouseLeave() { mouseRef.current = null } function handleTouchStart(e: React.TouchEvent) { const touch = e.touches[0] if (touch) mouseRef.current = { x: touch.clientX, y: touch.clientY } } function handleTouchMove(e: React.TouchEvent) { const touch = e.touches[0] if (touch) mouseRef.current = { x: touch.clientX, y: touch.clientY } } function handleTouchEnd() { setTimeout(() => { mouseRef.current = null }, 600) } const bgColor = isDark ? '#1a1a1a' : '#f5f5f5' const textColor = isDark ? 'text-[#ed7550]' : 'text-[#ed7550]' return (
{TEXT.split('').map((letter, i) => ( { if (el) lettersRef.current[i] = el }} textColor={textColor} fontFamily={scienceGothic.style.fontFamily} /> ))}
) } ``` --- ## Responsive Letters Category: Typography Slug: `responsive-letters` URL: https://aicanvas.me/components/responsive-letters Interactive text where each letter responds to cursor proximity, animating variable font properties (weight, stretch, italic, letter-spacing, skew) to create a deformation-under-pressure effect. Install (free account): ```bash npx shadcn@latest add @aicanvas/responsive-letters ``` ```tsx 'use client' // npm install framer-motion next // font: Science Gothic /** * Renders variable-font letters that react independently to pointer distance. * Nearby glyphs adjust width, weight, slant, and scale with eased falloff. */ import { useEffect, useRef, useState } from 'react' import { motion } from 'framer-motion' import { Science_Gothic } from 'next/font/google' const scienceGothic = Science_Gothic({ subsets: ['latin'], axes: ['wdth'] }) interface LetterSpanProps { letter: string textColor: string fontFamily: string forwardedRef: React.Ref } const LetterSpanComponent = ({ letter, textColor, fontFamily, forwardedRef }: LetterSpanProps) => { const [fontStyle, setFontStyle] = useState<'italic' | 'normal'>('italic') const spanRef = useRef(null) useEffect(() => { const element = spanRef.current if (!element) return const updateStyle = () => { const italicVar = getComputedStyle(element).getPropertyValue('--italic') const italicValue = parseFloat(italicVar) || 1 setFontStyle(italicValue > 0.5 ? 'italic' : 'normal') } updateStyle() const observer = new MutationObserver(updateStyle) observer.observe(element, { attributes: true, attributeFilter: ['style'] }) const interval = setInterval(updateStyle, 16) return () => { observer.disconnect() clearInterval(interval) } }, []) useEffect(() => { if (forwardedRef) { if (typeof forwardedRef === 'function') { forwardedRef(spanRef.current) } else { forwardedRef.current = spanRef.current } } }, [forwardedRef]) return ( {letter} ) } const LetterSpan = motion(LetterSpanComponent) // customize: replace the display text below const TEXT = 'WHAT ?!' // tune: raise to widen pointer influence const INFLUENCE_RADIUS = 300 // tune: adjust these bounds to change the variable-font response const MAX_WEIGHT = 900 const MIN_WEIGHT = 100 const MAX_STRETCH = 200 const MIN_STRETCH = 100 const MAX_LETTER_SPACING = 0.4 const MIN_LETTER_SPACING = 0 const MAX_SKEW = 18 const MIN_SKEW = 0 // tune: raise to slow glyph transitions const EASE_DURATION = 0.3 export default function ResponsiveLetters() { const [isDark, setIsDark] = useState(true) const containerRef = useRef(null) const lettersRef = useRef<(HTMLSpanElement | null)[]>([]) const mouseRef = useRef<{ x: number; y: number } | null>(null) const animIdRef = useRef(0) const aliveRef = useRef(true) useEffect(() => { function detectTheme(): boolean { let element: HTMLElement | null = containerRef.current while (element) { const cardTheme = element.getAttribute('data-card-theme') if (cardTheme) { return cardTheme === 'dark' } element = element.parentElement } return document.documentElement.classList.contains('dark') } setIsDark(detectTheme()) const observer = new MutationObserver(() => { setIsDark(detectTheme()) }) let element: HTMLElement | null = containerRef.current while (element) { observer.observe(element, { attributes: true, attributeFilter: ['data-card-theme', 'class'] }) element = element.parentElement } observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) return () => observer.disconnect() }, []) const exitStateRef = useRef>([]) useEffect(() => { aliveRef.current = true const container = containerRef.current if (!container) return TEXT.split('').forEach((_, i) => { if (!exitStateRef.current[i]) { exitStateRef.current[i] = { weight: MIN_WEIGHT, stretch: MIN_STRETCH, letterSpacing: MIN_LETTER_SPACING, skew: 0, italic: 1, } } }) const exitSpring = { type: 'spring', damping: 6, stiffness: 35 } function animate() { if (!aliveRef.current) return const mx = mouseRef.current?.x ?? -99999 const my = mouseRef.current?.y ?? -99999 const isExiting = !mouseRef.current TEXT.split('').forEach((_, i) => { const letterEl = lettersRef.current[i] if (!letterEl) return const rect = letterEl.getBoundingClientRect() const letterCenterX = rect.left + rect.width / 2 const letterCenterY = rect.top + rect.height / 2 const dx = letterCenterX - mx const dy = letterCenterY - my const dist = Math.sqrt(dx * dx + dy * dy) let influence = 0 if (dist < INFLUENCE_RADIUS) { influence = 1 - dist / INFLUENCE_RADIUS influence = influence * influence * (3 - 2 * influence) } const targetWeight = MIN_WEIGHT + (MAX_WEIGHT - MIN_WEIGHT) * influence const targetStretch = MIN_STRETCH + (MAX_STRETCH - MIN_STRETCH) * influence const targetLetterSpacing = MIN_LETTER_SPACING + (MAX_LETTER_SPACING - MIN_LETTER_SPACING) * influence const italicValue = 1 - influence let targetSkew = 0 if (influence > 0) { const angle = Math.atan2(dy, dx) const skewDirection = Math.sin(angle) targetSkew = (MAX_SKEW - MIN_SKEW) * influence * skewDirection } const state = exitStateRef.current[i] const spring = isExiting ? exitSpring : { type: 'spring', damping: 10, stiffness: 160 } const easing = isExiting ? 0.05 : 0.15 state.weight += (targetWeight - state.weight) * easing state.stretch += (targetStretch - state.stretch) * easing state.letterSpacing += (targetLetterSpacing - state.letterSpacing) * easing state.skew += (targetSkew - state.skew) * easing state.italic += (italicValue - state.italic) * easing letterEl.style.setProperty('--font-weight', Math.round(state.weight).toString()) letterEl.style.setProperty('--font-width', state.stretch.toFixed(1)) letterEl.style.setProperty('--letter-spacing', `${state.letterSpacing.toFixed(3)}em`) letterEl.style.setProperty('--skew', `${state.skew.toFixed(1)}`) letterEl.style.setProperty('--italic', state.italic.toFixed(3)) }) animIdRef.current = requestAnimationFrame(animate) } animate() return () => { aliveRef.current = false if (animIdRef.current) cancelAnimationFrame(animIdRef.current) } }, []) function handleMouseMove(e: React.MouseEvent) { mouseRef.current = { x: e.clientX, y: e.clientY } } function handleMouseLeave() { mouseRef.current = null } function handleTouchStart(e: React.TouchEvent) { const touch = e.touches[0] if (touch) mouseRef.current = { x: touch.clientX, y: touch.clientY } } function handleTouchMove(e: React.TouchEvent) { const touch = e.touches[0] if (touch) mouseRef.current = { x: touch.clientX, y: touch.clientY } } function handleTouchEnd() { setTimeout(() => { mouseRef.current = null }, 600) } const bgColor = isDark ? '#0d001a' : '#40FFA7' const textColor = isDark ? 'text-[#40FFA7]' : 'text-[#0d001a]' return (
{}
{TEXT.split('').map((letter, i) => ( { if (el) lettersRef.current[i] = el }} textColor={textColor} fontFamily={scienceGothic.style.fontFamily} /> ))}
) } ``` --- ## Emoji Reaction Jar Category: Widgets Slug: `jar-of-emotions` URL: https://aicanvas.me/components/jar-of-emotions Glass jar widget. Click a reaction to spring the lid and watch the emoji bounce down. Install (free account): ```bash npx shadcn@latest add @aicanvas/jar-of-emotions ``` ```tsx 'use client' // npm install framer-motion matter-js /** * Displays a physics-driven jar that dispenses selected emotion emoji. * Clicking an emotion opens the lid and launches a matching body toward its button. */ import { useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react' import type { RefObject } from 'react' import { motion, AnimatePresence } from 'framer-motion' import type { Engine, World, Body, Runner } from 'matter-js' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect function useTheme(ref: RefObject): { theme: '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 } } // tune: raise to make dispensed emoji fall faster const GRAVITY_SCALE = 0.0018 // tune: change to resize the physics bodies const EMOJI_SIZE = 34 // tune: change to resize the visible emoji const EMOJI_FONT = 30 // tune: raise to allow more active emoji const MAX_BODIES = 30 // tune: raise to keep more emoji inside the jar const MAX_INTERIOR = 8 const WALL_THICKNESS = 40 const JAR_WALL_THICKNESS = 24 const JAR_VIEW_W = 200 const JAR_VIEW_H = 240 const MOUTH_LEFT = 40 const MOUTH_RIGHT = 160 const JAR_INNER_LEFT = 32 const JAR_INNER_RIGHT = 168 const JAR_INNER_TOP = 46 const JAR_INNER_BOTTOM = 226 const LID_X = 26 const LID_Y = 22 const LID_W = 148 const LID_H = 18 const LID_HINGE_X = LID_X const LID_HINGE_Y = LID_Y + LID_H / 2 type Emotion = { id: string emoji: string label: string darkColor: string lightColor: string } // customize: replace the emotion labels and emoji below const EMOTIONS: Emotion[] = [ { id: 'banger', emoji: '🔥', label: 'Banger', darkColor: '#FF6B35', lightColor: '#C94400' }, { id: 'perfect', emoji: '👌', label: 'Perfect', darkColor: '#6BD97A', lightColor: '#1E8A35' }, { id: 'purejoy', emoji: '😄', label: 'Pure joy', darkColor: '#F5C046', lightColor: '#C47A00' }, { id: 'crying', emoji: '😭', label: 'Crying', darkColor: '#7FB4F0', lightColor: '#2E6FC1' }, { id: 'cooking', emoji: '🧑‍🍳', label: 'Cooking', darkColor: '#FFB347', lightColor: '#B86A00' }, { id: 'goat', emoji: '🐐', label: 'GOAT', darkColor: '#C4A0F5', lightColor: '#7A45CF' }, ] type Dispensed = { id: number body: Body emoji: string emotionId: string location: 'inside' | 'outside' } type BodyWithMeta = Body & { plugin?: { emotion?: string } } let uid = 0 function rand(min: number, max: number) { return min + Math.random() * (max - min) } export default function JarOfEmotions() { const containerRef = useRef(null) const { theme } = useTheme(containerRef) const isDark = theme === 'dark' const overlayRef = useRef(null) const jarWrapRef = useRef(null) const buttonRowRef = useRef(null) const buttonRefs = useRef>({}) const matterRef = useRef(null) const engineRef = useRef(null) const runnerRef = useRef(null) const worldRef = useRef(null) const dispensedRef = useRef([]) const jarBoxRef = useRef<{ x: number y: number w: number h: number scale: number }>({ x: 0, y: 0, w: 0, h: 0, scale: 1 }) const [lidOpen, setLidOpen] = useState(false) const [dispensedTotal, setDispensedTotal] = useState(0) const [renderTick, setRenderTick] = useState(0) const lidCloseTimerRef = useRef(null) useEffect(() => { let alive = true let rafId = 0 let ro: ResizeObserver | null = null let outerWalls: Body[] = [] let jarWalls: Body[] = [] let buttonFloors: Body[] = [] function recomputeJarBox() { const container = containerRef.current const jarWrap = jarWrapRef.current if (!container || !jarWrap) return const cRect = container.getBoundingClientRect() const jRect = jarWrap.getBoundingClientRect() jarBoxRef.current = { x: jRect.left - cRect.left, y: jRect.top - cRect.top, w: jRect.width, h: jRect.height, scale: jRect.width / JAR_VIEW_W, } } function buildOuterWalls( Matter: typeof import('matter-js'), w: number, h: number, ): Body[] { const t = WALL_THICKNESS const opts = { isStatic: true, render: { visible: false } } return [ Matter.Bodies.rectangle(w / 2, h + t / 2, w + t * 2, t, opts), Matter.Bodies.rectangle(-t / 2, h / 2, t, h * 2, opts), Matter.Bodies.rectangle(w + t / 2, h / 2, t, h * 2, opts), Matter.Bodies.rectangle(w / 2, -t * 3, w + t * 2, t, opts), ] } function buildJarWalls(Matter: typeof import('matter-js')): Body[] { const { x, y, scale } = jarBoxRef.current const jy = y const t = JAR_WALL_THICKNESS const opts = { isStatic: true, render: { visible: false }, friction: 0.6 } const leftX = x + (JAR_INNER_LEFT - 2) * scale const rightX = x + (JAR_INNER_RIGHT + 2) * scale const topY = y + JAR_INNER_TOP * scale const botY = y + JAR_INNER_BOTTOM * scale const innerH = botY - topY return [ Matter.Bodies.rectangle( leftX - t / 2, topY + innerH / 2, t, innerH, opts, ), Matter.Bodies.rectangle( rightX + t / 2, topY + innerH / 2, t, innerH, opts, ), ...(() => { const pts = [ [30, 218], [40, 226], [65, 233], [100, 236], [135, 233], [160, 226], [170, 218], ] const segs: Body[] = [] for (let i = 0; i < pts.length - 1; i++) { const [x1s, y1s] = pts[i] const [x2s, y2s] = pts[i + 1] const cx = x + (x1s + x2s) / 2 * scale const cy = y + (y1s + y2s) / 2 * scale + t / 2 const len = Math.hypot((x2s - x1s) * scale, (y2s - y1s) * scale) const angle = Math.atan2(y2s - y1s, x2s - x1s) const seg = Matter.Bodies.rectangle(cx, cy, len + 2, t, opts) Matter.Body.setAngle(seg, angle) segs.push(seg) } return segs })(), ] } function buildButtonFloors(Matter: typeof import('matter-js')): Body[] { const container = containerRef.current const row = buttonRowRef.current if (!container || !row) return [] const cRect = container.getBoundingClientRect() const bodies: Body[] = [] for (const emotion of EMOTIONS) { const el = buttonRefs.current[emotion.id] if (!el) continue const r = el.getBoundingClientRect() const x = r.left - cRect.left + r.width / 2 const y = r.top - cRect.top + 4 const body = Matter.Bodies.rectangle(x, y, r.width, 8, { isStatic: true, render: { visible: false }, friction: 0.9, restitution: 0.15, }) as BodyWithMeta body.plugin = { emotion: emotion.id } bodies.push(body) } return bodies } function seedInterior(Matter: typeof import('matter-js')) { const world = worldRef.current if (!world) return const { x, y, scale } = jarBoxRef.current const leftX = x + (JAR_INNER_LEFT + 12) * scale const rightX = x + (JAR_INNER_RIGHT - 12) * scale const topY = y + JAR_INNER_TOP * scale const botY = y + (JAR_INNER_BOTTOM - 10) * scale const innerH = botY - topY for (let i = 0; i < EMOTIONS.length; i++) { const e = EMOTIONS[i] const px = rand(leftX, rightX) const py = botY - rand(0, innerH * 0.6) const body = Matter.Bodies.circle(px, py, EMOJI_SIZE / 2, { friction: 0.55, frictionAir: 0.06, restitution: 0.05, density: 0.0018, render: { visible: false }, }) as BodyWithMeta body.plugin = { emotion: e.id } Matter.Body.setVelocity(body, { x: 0, y: 0 }) Matter.Composite.add(world, body) dispensedRef.current.push({ id: ++uid, body, emoji: e.emoji, emotionId: e.id, location: 'inside', }) } } function rebuildStatics() { const Matter = matterRef.current const world = worldRef.current if (!Matter || !world) return const container = containerRef.current if (!container) return const cw = container.clientWidth const ch = container.clientHeight if (outerWalls.length) for (const w of outerWalls) Matter.Composite.remove(world, w) if (jarWalls.length) for (const w of jarWalls) Matter.Composite.remove(world, w) if (buttonFloors.length) for (const w of buttonFloors) Matter.Composite.remove(world, w) outerWalls = buildOuterWalls(Matter, cw, ch) Matter.Composite.add(world, outerWalls) recomputeJarBox() jarWalls = buildJarWalls(Matter) Matter.Composite.add(world, jarWalls) buttonFloors = buildButtonFloors(Matter) Matter.Composite.add(world, buttonFloors) for (const d of dispensedRef.current) { const p = d.body.position let nx = p.x let ny = p.y if (nx < 8) nx = 8 if (nx > cw - 8) nx = cw - 8 if (ny > ch - 8) ny = ch - 8 if (nx !== p.x || ny !== p.y) Matter.Body.setPosition(d.body, { x: nx, y: ny }) } } function tick() { if (!alive) return const Matter = matterRef.current if (Matter) { const list = dispensedRef.current const outsideCount = list.filter(d => d.location === 'outside').length if (outsideCount > MAX_BODIES) { const extra = outsideCount - MAX_BODIES let removed = 0 for (let i = 0; i < list.length && removed < extra; i++) { const d = list[i] if (d.location === 'outside') { Matter.Composite.remove(worldRef.current!, d.body) list.splice(i, 1) i-- removed++ } } } } setRenderTick((t) => (t + 1) & 0xffff) rafId = requestAnimationFrame(tick) } import('matter-js').then((Matter) => { if (!alive) return matterRef.current = Matter const engine = Matter.Engine.create({ gravity: { x: 0, y: 1, scale: GRAVITY_SCALE }, }) engine.timing.timeScale = 1 engineRef.current = engine worldRef.current = engine.world const runner = Matter.Runner.create() runnerRef.current = runner Matter.Runner.run(runner, engine) rebuildStatics() seedInterior(Matter) ro = new ResizeObserver(() => { rebuildStatics() }) if (containerRef.current) ro.observe(containerRef.current) rafId = requestAnimationFrame(tick) }) return () => { alive = false cancelAnimationFrame(rafId) if (ro) ro.disconnect() if (lidCloseTimerRef.current !== null) { clearTimeout(lidCloseTimerRef.current) lidCloseTimerRef.current = null } const Matter = matterRef.current if (Matter) { if (runnerRef.current) Matter.Runner.stop(runnerRef.current) if (worldRef.current) Matter.Composite.clear(worldRef.current, false, true) if (engineRef.current) Matter.Engine.clear(engineRef.current) } matterRef.current = null engineRef.current = null runnerRef.current = null worldRef.current = null dispensedRef.current = [] } }, []) useIsomorphicLayoutEffect(() => { return () => { if (lidCloseTimerRef.current !== null) { clearTimeout(lidCloseTimerRef.current) lidCloseTimerRef.current = null } } }, []) function dispense(emotion: Emotion) { const Matter = matterRef.current const world = worldRef.current const container = containerRef.current if (!Matter || !world || !container) return setLidOpen(true) if (lidCloseTimerRef.current !== null) clearTimeout(lidCloseTimerRef.current) lidCloseTimerRef.current = window.setTimeout(() => { setLidOpen(false) lidCloseTimerRef.current = null }, 900) const list = dispensedRef.current let candidate = list.find( (d) => d.location === 'inside' && d.emotionId === emotion.id, ) if (!candidate) { const interiorCount = list.filter(d => d.location === 'inside').length if (interiorCount < MAX_INTERIOR) { const { x, y, scale } = jarBoxRef.current const leftX = x + (JAR_INNER_LEFT + 14) * scale const rightX = x + (JAR_INNER_RIGHT - 14) * scale const botY = y + (JAR_INNER_BOTTOM - 14) * scale const px = rand(leftX, rightX) const py = botY - 4 const body = Matter.Bodies.circle(px, py, EMOJI_SIZE / 2, { friction: 0.55, frictionAir: 0.06, restitution: 0.05, density: 0.0018, render: { visible: false }, }) as BodyWithMeta body.plugin = { emotion: emotion.id } Matter.Composite.add(world, body) candidate = { id: ++uid, body, emoji: emotion.emoji, emotionId: emotion.id, location: 'inside', } list.push(candidate) } } if (!candidate) { const { x: jx, y: jy, scale: s } = jarBoxRef.current const mouthCx = jx + ((MOUTH_LEFT + MOUTH_RIGHT) / 2) * s const exitY = jy - 18 const body = Matter.Bodies.circle(mouthCx, exitY, EMOJI_SIZE / 2, { friction: 0.55, frictionAir: 0.06, restitution: 0.05, density: 0.0018, render: { visible: false }, }) as BodyWithMeta body.plugin = { emotion: emotion.id } Matter.Composite.add(world, body) candidate = { id: ++uid, body, emoji: emotion.emoji, emotionId: emotion.id, location: 'outside', } list.push(candidate) } const btn = buttonRefs.current[emotion.id] if (!btn) return const cRect = container.getBoundingClientRect() const bRect = btn.getBoundingClientRect() const targetX = bRect.left - cRect.left + bRect.width / 2 const targetY = bRect.top - cRect.top + 4 const { x: jx, y: jy, scale: s } = jarBoxRef.current const mouthCx = jx + ((MOUTH_LEFT + MOUTH_RIGHT) / 2) * s const exitY = jy - 18 Matter.Body.setPosition(candidate.body, { x: mouthCx + rand(-6, 6), y: exitY, }) Matter.Body.setAngularVelocity(candidate.body, rand(-0.12, 0.12)) const dx = targetX - mouthCx const upKick = -14 - Math.min(2, Math.abs(dx) / 200) const hVel = dx / 60 Matter.Body.setVelocity(candidate.body, { x: hVel + rand(-0.3, 0.3), y: upKick, }) candidate.location = 'outside' setDispensedTotal(t => t + 1) const remainingInside = list.some( (d) => d.location === 'inside' && d.emotionId === emotion.id, ) if (!remainingInside) { window.setTimeout(() => { const w = worldRef.current const M = matterRef.current if (!w || !M) return const innerCount = dispensedRef.current.filter(d => d.location === 'inside').length if (innerCount >= MAX_INTERIOR) return const { x, y, scale } = jarBoxRef.current const lx = x + (JAR_INNER_LEFT + 14) * scale const rx = x + (JAR_INNER_RIGHT - 14) * scale const spawnY = y + (JAR_INNER_TOP + EMOJI_SIZE / 2 + 4) * scale const body = M.Bodies.circle(rand(lx, rx), spawnY, EMOJI_SIZE / 2, { friction: 0.55, frictionAir: 0.06, restitution: 0.05, density: 0.0018, render: { visible: false }, }) as BodyWithMeta body.plugin = { emotion: emotion.id } M.Composite.add(w, body) dispensedRef.current.push({ id: ++uid, body, emoji: emotion.emoji, emotionId: emotion.id, location: 'inside' }) }, 600) } } const styles = useMemo( () => ({ jarFill: isDark ? 'rgba(220, 235, 245, 0.10)' : 'rgba(120, 140, 160, 0.18)', jarStroke: isDark ? 'rgba(220, 235, 245, 0.55)' : 'rgba(70, 90, 110, 0.55)', jarHighlight: isDark ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.7)', jarShadow: isDark ? 'rgba(0,0,0,0.35)' : 'rgba(60,80,100,0.22)', lidFill: isDark ? '#B4A27A' : '#8B7349', lidStroke: isDark ? '#7A6A47' : '#5C4A28', promptColor: isDark ? 'rgba(250, 246, 238, 0.92)' : 'rgba(40, 36, 32, 0.88)', }), [isDark], ) void renderTick return (
{}
{} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}
{} {dispensedTotal >= 30 && (

Full. Please Stop.

)}
{} {}

Emoji Reaction Jar

Reactions on tap

{}
{EMOTIONS.map((e) => ( { buttonRefs.current[e.id] = el }} type="button" onClick={() => dispense(e)} whileHover={{ y: -2, scale: 1.04 }} whileTap={{ scale: 0.94 }} transition={{ type: 'spring', stiffness: 400, damping: 22 }} className="flex min-w-[58px] flex-col items-center justify-center gap-2 rounded-2xl border px-2 py-2 font-sans transition-colors sm:min-w-[68px] sm:px-3" style={{ minHeight: 48, background: isDark ? 'rgba(255, 250, 235, 0.04)' : 'rgba(255, 255, 255, 0.7)', borderColor: isDark ? 'rgba(255, 250, 235, 0.14)' : 'rgba(40, 36, 32, 0.12)', boxShadow: isDark ? '0 2px 6px rgba(0,0,0,0.18)' : '0 2px 8px rgba(40, 36, 32, 0.08)', }} aria-label={e.label} > {e.emoji} {e.label} ))}
{}
{dispensedRef.current.map((d) => { const p = d.body.position const angle = d.body.angle if (d.location !== 'outside') return null return ( {d.emoji} ) })}
{}
{dispensedRef.current.map((d) => { if (d.location !== 'inside') return null const p = d.body.position const angle = d.body.angle return (
{d.emoji}
) })}
) } ``` --- ## Peel to Scan Category: Cards & Modals Slug: `peel-corner-reveal` URL: https://aicanvas.me/components/peel-corner-reveal A portrait card whose corner peels on tap, revealing a scannable Wi-Fi QR code. Install (free account): ```bash npx shadcn@latest add @aicanvas/peel-corner-reveal ``` ```tsx 'use client' // npm install framer-motion qrcode.react /** * Presents a Wi-Fi card with a peelable corner containing a QR code. * Hover teases the fold, while click and keyboard input lock the reveal open. */ import { useEffect, useRef, useState } from 'react' import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from 'react' import { motion, useMotionTemplate, useMotionValue, useMotionValueEvent, useSpring, useTransform, } from 'framer-motion' import { QRCodeSVG } from 'qrcode.react' function useTheme(ref: RefObject): '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 } const PEEL_FILL = '#1A9D51' const PEEL_FILL_DEEP = '#127A3D' const PEEL_INK = '#FFFFFF' const VB_W = 500 const VB_H = 620 const CARD_W = 320 const CARD_H = 440 const CARD_X = 90 const CARD_Y = 70 // tune: raise to round the fixed card corners further const CARD_RADIUS = 12 const TL = { x: CARD_X, y: CARD_Y } const TR = { x: CARD_X + CARD_W, y: CARD_Y } const BR = { x: CARD_X + CARD_W, y: CARD_Y + CARD_H } const BL = { x: CARD_X, y: CARD_Y + CARD_H } const REST_W_PCT = 0.22 const REST_H_PCT = 0.18 const OPEN_W_PCT = 0.78 const OPEN_H_PCT = 0.9 const BOB_AMPLITUDE = 2.4 export default function PeelCornerReveal() { const containerRef = useRef(null) const theme = useTheme(containerRef) const [isOpen, setIsOpen] = useState(false) const [isHovered, setIsHovered] = useState(false) const qrGroupRef = useRef(null) const PAGE_BG = theme === 'dark' ? '#2E2E2C' : '#D0CCC4' const CARD_FILL = theme === 'dark' ? '#FFFFFF' : '#121212' const CARD_INK = theme === 'dark' ? '#0A0A0A' : '#F5F5F0' const FOLD_STROKE = theme === 'dark' ? 'rgba(0,0,0,0.28)' : 'rgba(255,255,255,0.22)' const DIVIDER_STROKE = theme === 'dark' ? CARD_INK : '#FFFFFF' const DROP_SHADOW = theme === 'dark' ? '4px 4px 24px rgba(0,0,0,0.55)' : '4px 4px 24px rgba(20,15,10,0.28)' const target = useMotionValue(0) useEffect(() => { target.set(isOpen ? 1 : isHovered ? 0.18 : 0) }, [isOpen, isHovered, target]) const progress = useSpring(target, { stiffness: 170, damping: 22, mass: 0.9 }) const w = useTransform( progress, [0, 1], [REST_W_PCT * CARD_W, OPEN_W_PCT * CARD_W], ) const h = useTransform( progress, [0, 1], [REST_H_PCT * CARD_H, OPEN_H_PCT * CARD_H], ) const Ax = useTransform(w, (v) => BR.x - v) const Ay = useMotionValue(BR.y) const Bx = useMotionValue(BR.x) const By = useTransform(h, (v) => BR.y - v) const Cx = useTransform([Ax, By], ([ax, by]) => { const dx = BR.x - ax const dy = by - BR.y const len2 = dx * dx + dy * dy if (len2 === 0) return BR.x const t = (dx * dx) / len2 const footX = ax + t * dx return 2 * footX - BR.x }) const Cy = useTransform([Ax, By], ([ax, by]) => { const dx = BR.x - ax const dy = by - BR.y const len2 = dx * dx + dy * dy if (len2 === 0) return BR.y const t = (dx * dx) / len2 const footY = BR.y + t * dy return 2 * footY - BR.y }) const angle = useTransform([Ax, By], ([ax, by]) => { const dy = by - BR.y const dx = BR.x - ax return (Math.atan2(dy, dx) * 180) / Math.PI }) const cardPoints = useMotionTemplate`${TL.x},${TL.y} ${TR.x},${TR.y} ${Bx},${By} ${Ax},${Ay} ${BL.x},${BL.y}` const 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` const peelPoints = useMotionTemplate`${Ax},${Ay} ${Bx},${By} ${Cx},${Cy}` const foldX1 = Ax const foldY1 = Ay const foldX2 = Bx const foldY2 = By const foldMidX = useTransform([Ax, Bx], ([ax, bx]) => (ax + bx) / 2) const foldMidY = useTransform([Ay, By], ([ay, by]) => (ay + by) / 2) const bobRaw = useMotionValue(0) useEffect(() => { let raf = 0 let alive = true const start = performance.now() function tick(now: number) { if (!alive) return const t = (now - start) / 1000 bobRaw.set(Math.sin(t * 1.2) * BOB_AMPLITUDE) raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => { alive = false cancelAnimationFrame(raf) } }, [bobRaw]) const bobGate = useTransform(progress, [0, 0.4], [1, 0]) const bobY = useTransform( [bobRaw, bobGate], ([b, g]) => b * g, ) function handleToggle() { setIsOpen((v) => !v) } function handleKey(e: ReactKeyboardEvent) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() setIsOpen((v) => !v) } } const revealOpacity = useTransform(progress, [0.35, 0.75], [0, 1]) // tune: change to resize the QR code const QR_SIZE = 110 // tune: raise to move the QR anchor toward the right fold endpoint const QR_ALONG_FRAC = 0.3 // tune: raise to move the QR anchor farther from the fold const QR_PERP_FRAC = 0.7 const QR_OFFSET_X = 54 const QR_OFFSET_Y = 18 const qrAnchorX = useTransform( [Ax, Bx, Cx], ([ax, bx, cx]) => ax + QR_ALONG_FRAC * (bx - ax) + (QR_PERP_FRAC / 2) * (cx - BR.x) + QR_OFFSET_X, ) const qrAnchorY = useTransform( [Ay, By, Cy], ([ay, by, cy]) => ay + QR_ALONG_FRAC * (by - ay) + (QR_PERP_FRAC / 2) * (cy - BR.y) + QR_OFFSET_Y, ) const qrAngle = useTransform(angle, (a) => a + 31) const qrTransform = useMotionTemplate`translate(${qrAnchorX} ${qrAnchorY}) rotate(${qrAngle})` useMotionValueEvent(qrTransform, 'change', (latest) => { qrGroupRef.current?.setAttribute('transform', latest) }) useEffect(() => { const el = qrGroupRef.current if (!el) return el.setAttribute('transform', qrTransform.get()) }, [qrTransform]) return (
setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} whileHover={{ scale: 1.015 }} transition={{ type: 'spring', stiffness: 260, damping: 22 }} className="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})` }} > {} {} {} {} {} {} {} Free Wi-Fi {} {} TAP TO SCAN {} {} {}
) } ``` --- ## Sticker Wall Category: Widgets Slug: `sticker-wall` URL: https://aicanvas.me/components/sticker-wall A draggable sticker card wall. Drop notes, toss emoji, pile them up with real 2D physics. Install (free account): ```bash npx shadcn@latest add @aicanvas/sticker-wall ``` ```tsx 'use client' // npm install framer-motion matter-js /** * Presents a physics wall where text and emoji stickers can be added and dragged. * New stickers fall into the scene and collide with existing bodies and boundaries. */ import { useEffect, useLayoutEffect, useRef, useState, FormEvent } from 'react' import { motion } from 'framer-motion' import type { Engine, Runner, World, Body, MouseConstraint as MC, Mouse as MatterMouse } from 'matter-js' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect // tune: raise to make stickers fall faster const GRAVITY_SCALE = 0.0012 // tune: raise to increase collision bounce const RESTITUTION = 0.05 const FRICTION = 0.6 const FRICTION_AIR = 0.02 const DENSITY = 0.0015 // tune: raise to allow more stickers in the scene const STICKER_CAP = 60 // tune: raise to slow sticker removal fades const FADE_MS = 250 const WALL_THICKNESS = 60 const TEXT_FONT_PX = 15 // tune: raise to allow wider text stickers const TEXT_MAX_WIDTH = 180 const TEXT_PAD_X = 14 const TEXT_PAD_Y = 10 const TEXT_LINE_H = 20 // tune: change to resize emoji sticker bodies const EMOJI_SIZE = 72 const EMOJI_FONT_PX = 42 const CARD_RADIUS = 32 const BORDER_WIDTH = 2 const PALETTE_DARK = ['#FDE68A', '#BBF7D0', '#FBCFE8', '#C7D2FE', '#BAE6FD', '#FED7AA'] const PALETTE_LIGHT = ['#F59E0B', '#34D399', '#F472B6', '#A78BFA', '#38BDF8', '#FB923C'] const STICKER_TEXT_COLOR_DARK = '#111827' const STICKER_TEXT_COLOR_LIGHT = '#FFFFFF' const BG_DARK = '#0F0F12' const BG_LIGHT = '#F5F1E8' // customize: replace the initial text and emoji stickers below const SEED_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', ] const SEED_EMOJIS = ['👏', '💡', '🙌', '👀', '💬', '✅', '🔥', '💯', '🎉', '❤️', '🤔', '⭐'] type StickerKind = 'text' | 'emoji' interface Sticker { body: Body kind: StickerKind content: string w: number h: number color: string lines: string[] createdAt: number fadeStart?: number } type BodyWithPlugin = Body & { plugin: { sticker?: Sticker } } function roundedRect( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number, ) { 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() } function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { const words = text.trim().split(/\s+/) const lines: string[] = [] let current = '' for (const word of words) { const candidate = current ? `${current} ${word}` : word const width = ctx.measureText(candidate).width if (width <= maxWidth) { current = candidate } else if (!current) { lines.push(word) current = '' } else { lines.push(current) current = word } } if (current) lines.push(current) return lines.length > 0 ? lines : [''] } function measureTextCard( ctx: CanvasRenderingContext2D, text: string, ): { lines: string[]; w: number; h: number } { ctx.save() ctx.font = `600 ${TEXT_FONT_PX}px ui-sans-serif, system-ui, -apple-system, Segoe UI, Manrope, sans-serif` const lines = wrapText(ctx, text, TEXT_MAX_WIDTH) let maxW = 0 for (const line of lines) { const lw = ctx.measureText(line).width if (lw > maxW) maxW = lw } ctx.restore() const w = Math.max(70, Math.round(maxW + TEXT_PAD_X * 2)) const h = Math.max(40, Math.round(lines.length * TEXT_LINE_H + TEXT_PAD_Y * 2)) return { lines, w, h } } function randBetween(min: number, max: number): number { return min + Math.random() * (max - min) } function pickPalette(isDark: boolean): string[] { return isDark ? PALETTE_DARK : PALETTE_LIGHT } export default function StickerWall() { const containerRef = useRef(null) const canvasRef = useRef(null) const inputRef = useRef(null) const engineRef = useRef(null) const worldRef = useRef(null) const stickersRef = useRef([]) const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 }) const measureCtxRef = useRef(null) const paletteRef = useRef(PALETTE_DARK) const matterRef = useRef(null) const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : true, ) const isDarkRef = useRef(isDark) useIsomorphicLayoutEffect(() => { isDarkRef.current = isDark paletteRef.current = pickPalette(isDark) }, [isDark]) useEffect(() => { const palette = pickPalette(isDark) stickersRef.current.forEach((sticker, i) => { sticker.color = palette[i % palette.length] }) }, [isDark]) useIsomorphicLayoutEffect(() => { const el = containerRef.current if (!el) return const check = () => { const card = el.closest('[data-card-theme]') const dark = card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark') setIsDark(dark) isDarkRef.current = dark } check() const observer = new MutationObserver(check) 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() }, []) useEffect(() => { const canvas = canvasRef.current const container = containerRef.current if (!canvas || !container) return const ctx = canvas.getContext('2d') if (!ctx) return measureCtxRef.current = ctx let alive = true let rafId = 0 let engine: Engine | null = null let runner: Runner | null = null let world: World | null = null let walls: Body[] = [] let mouse: MatterMouse | null = null let mouseConstraint: MC | null = null let ro: ResizeObserver | null = null let dpr = Math.min(window.devicePixelRatio || 1, 2) function buildWalls(Matter: typeof import('matter-js'), w: number, h: number): Body[] { const t = WALL_THICKNESS const opts = { isStatic: true, render: { visible: false } } return [ Matter.Bodies.rectangle(w / 2, -t / 2, w + t * 2, t, opts), Matter.Bodies.rectangle(w / 2, h + t / 2, w + t * 2, t, opts), Matter.Bodies.rectangle(-t / 2, h / 2, t, h + t * 2, opts), Matter.Bodies.rectangle(w + t / 2, h / 2, t, h + t * 2, opts), ] } function makeTextSticker( Matter: typeof import('matter-js'), text: string, x: number, y: number, color: string, spawnMotion: boolean, ): Sticker { const { lines, w, h } = measureTextCard(ctx!, text) const body = Matter.Bodies.rectangle(x, y, w, h, { restitution: RESTITUTION, friction: FRICTION, frictionAir: FRICTION_AIR, density: DENSITY, angle: randBetween(-0.25, 0.25), render: { visible: false }, }) if (spawnMotion) { Matter.Body.setAngularVelocity(body, randBetween(-0.03, 0.03)) Matter.Body.setVelocity(body, { x: randBetween(-0.3, 0.3), y: 0 }) } const sticker: Sticker = { body, kind: 'text', content: text, w, h, color, lines, createdAt: performance.now(), } ;(body as BodyWithPlugin).plugin = { sticker } return sticker } function makeEmojiSticker( Matter: typeof import('matter-js'), emoji: string, x: number, y: number, color: string, ): Sticker { const body = Matter.Bodies.rectangle(x, y, EMOJI_SIZE, EMOJI_SIZE, { restitution: RESTITUTION, friction: FRICTION, frictionAir: FRICTION_AIR, density: DENSITY, angle: randBetween(-0.25, 0.25), render: { visible: false }, }) const sticker: Sticker = { body, kind: 'emoji', content: emoji, w: EMOJI_SIZE, h: EMOJI_SIZE, color, lines: [], createdAt: performance.now(), } ;(body as BodyWithPlugin).plugin = { sticker } return sticker } function seed(Matter: typeof import('matter-js'), w: number, h: number) { const palette = paletteRef.current for (let i = 0; i < SEED_QUOTES.length; i++) { const quote = SEED_QUOTES[i] const color = palette[i % palette.length] const x = randBetween(100, Math.max(120, w - 100)) const y = randBetween(80, Math.max(120, h - 120)) const sticker = makeTextSticker(Matter, quote, x, y, color, false) Matter.Body.setAngularVelocity(sticker.body, randBetween(-0.05, 0.05)) Matter.Body.setVelocity(sticker.body, { x: randBetween(-0.5, 0.5), y: randBetween(-0.5, 0.5) }) Matter.Composite.add(world!, sticker.body) stickersRef.current.push(sticker) } for (let i = 0; i < SEED_EMOJIS.length; i++) { const emoji = SEED_EMOJIS[i] const color = palette[(i + 3) % palette.length] const x = randBetween(80, Math.max(100, w - 80)) const y = randBetween(80, Math.max(120, h - 120)) const sticker = makeEmojiSticker(Matter, emoji, x, y, color) Matter.Body.setAngularVelocity(sticker.body, randBetween(-0.05, 0.05)) Matter.Body.setVelocity(sticker.body, { x: randBetween(-0.5, 0.5), y: randBetween(-0.5, 0.5) }) Matter.Composite.add(world!, sticker.body) stickersRef.current.push(sticker) } } function resize() { const Matter = matterRef.current if (!Matter || !world) return const w = container!.clientWidth || 480 const h = container!.clientHeight || 480 dpr = Math.min(window.devicePixelRatio || 1, 2) canvas!.width = Math.round(w * dpr) canvas!.height = Math.round(h * dpr) canvas!.style.width = `${w}px` canvas!.style.height = `${h}px` ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) if (walls.length > 0) { for (const wall of walls) Matter.Composite.remove(world, wall) } walls = buildWalls(Matter, w, h) Matter.Composite.add(world, walls) for (const s of stickersRef.current) { const p = s.body.position let nx = p.x let ny = p.y if (nx < 20) nx = 20 if (nx > w - 20) nx = w - 20 if (ny > h - 20) ny = h - 20 if (nx !== p.x || ny !== p.y) Matter.Body.setPosition(s.body, { x: nx, y: ny }) } if (mouse) mouse.pixelRatio = dpr sizeRef.current = { w, h } } function drawFrame(now: number) { if (!alive) return const dark = isDarkRef.current const bg = dark ? BG_DARK : BG_LIGHT const { w: W, h: H } = sizeRef.current ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) ctx!.fillStyle = bg ctx!.fillRect(0, 0, W, H) const stickers = stickersRef.current const Matter = matterRef.current if (Matter && world) { for (let i = stickers.length - 1; i >= 0; i--) { const s = stickers[i] if (s.fadeStart !== undefined) { const dt = now - s.fadeStart if (dt >= FADE_MS) { Matter.Composite.remove(world, s.body) stickers.splice(i, 1) } } } } for (const s of stickers) { const { body, w, h, color, kind, lines, content } = s let alpha = 1 if (s.fadeStart !== undefined) { const dt = now - s.fadeStart alpha = Math.max(0, 1 - dt / FADE_MS) } ctx!.save() ctx!.globalAlpha = alpha ctx!.translate(body.position.x, body.position.y) ctx!.rotate(body.angle) ctx!.fillStyle = color roundedRect(ctx!, -w / 2, -h / 2, w, h, CARD_RADIUS) ctx!.fill() ctx!.strokeStyle = 'rgba(255,255,255,0.7)' ctx!.lineWidth = BORDER_WIDTH const inset = BORDER_WIDTH roundedRect( ctx!, -w / 2 + inset, -h / 2 + inset, w - inset * 2, h - inset * 2, Math.max(1, CARD_RADIUS - inset), ) ctx!.stroke() if (kind === 'text') { ctx!.fillStyle = isDarkRef.current ? STICKER_TEXT_COLOR_DARK : STICKER_TEXT_COLOR_LIGHT ctx!.font = `600 ${TEXT_FONT_PX}px ui-sans-serif, system-ui, -apple-system, Segoe UI, Manrope, sans-serif` ctx!.textAlign = 'center' ctx!.textBaseline = 'middle' const totalH = lines.length * TEXT_LINE_H const startY = -totalH / 2 + TEXT_LINE_H / 2 for (let li = 0; li < lines.length; li++) { ctx!.fillText(lines[li], 0, startY + li * TEXT_LINE_H) } } else { ctx!.font = `${EMOJI_FONT_PX}px ui-sans-serif, system-ui, -apple-system, Segoe UI, "Apple Color Emoji", "Segoe UI Emoji", sans-serif` ctx!.textAlign = 'center' ctx!.textBaseline = 'middle' ctx!.fillText(content, 0, 2) } ctx!.restore() } rafId = requestAnimationFrame(drawFrame) } import('matter-js').then((Matter) => { if (!alive) return matterRef.current = Matter engine = Matter.Engine.create({ gravity: { x: 0, y: 1, scale: GRAVITY_SCALE } }) engine.timing.timeScale = 0.6 world = engine.world engineRef.current = engine worldRef.current = world runner = Matter.Runner.create() Matter.Runner.run(runner, engine) resize() mouse = Matter.Mouse.create(canvas!) mouse.pixelRatio = dpr mouseConstraint = Matter.MouseConstraint.create(engine, { mouse, constraint: { stiffness: 0.2, damping: 0.1, render: { visible: false }, }, }) Matter.Composite.add(world, mouseConstraint) seed(Matter, sizeRef.current.w, sizeRef.current.h) ro = new ResizeObserver(resize) ro.observe(container!) rafId = requestAnimationFrame(drawFrame) }) return () => { alive = false cancelAnimationFrame(rafId) if (ro) ro.disconnect() const Matter = matterRef.current if (Matter) { if (runner) Matter.Runner.stop(runner) if (world) Matter.Composite.clear(world, false, true) if (engine) Matter.Engine.clear(engine) } matterRef.current = null engineRef.current = null worldRef.current = null stickersRef.current = [] measureCtxRef.current = null } }, []) function onSubmit(e: FormEvent) { e.preventDefault() const input = inputRef.current if (!input) return const value = input.value.trim() if (!value) return const Matter = matterRef.current const world = worldRef.current const ctx = measureCtxRef.current if (!Matter || !world || !ctx) return const { w: W } = sizeRef.current if (W === 0) return const palette = paletteRef.current const color = palette[Math.floor(Math.random() * palette.length)] const x = randBetween(80, Math.max(100, W - 80)) const y = -30 const { lines, w, h } = measureTextCard(ctx, value) const body = Matter.Bodies.rectangle(x, y, w, h, { restitution: RESTITUTION, friction: FRICTION, frictionAir: FRICTION_AIR, density: DENSITY, angle: randBetween(-0.25, 0.25), render: { visible: false }, }) Matter.Body.setAngularVelocity(body, randBetween(-0.03, 0.03)) Matter.Body.setVelocity(body, { x: randBetween(-0.3, 0.3), y: 0 }) const sticker: Sticker = { body, kind: 'text', content: value, w, h, color, lines, createdAt: performance.now(), } ;(body as BodyWithPlugin).plugin = { sticker } Matter.Composite.add(world, body) stickersRef.current.push(sticker) if (stickersRef.current.length > STICKER_CAP) { for (const s of stickersRef.current) { if (s.fadeStart === undefined) { s.fadeStart = performance.now() break } } } input.value = '' } const bg = isDark ? BG_DARK : BG_LIGHT const inputBg = isDark ? 'rgba(0,0,0,0.9)' : 'rgba(255,255,255,0.9)' const accentBg = '#8A9CF4' const inputText = isDark ? 'rgba(255,255,255,0.95)' : 'rgba(17,24,39,0.95)' const accentText = isDark ? '#111827' : '#FFFFFF' const stickerBorder = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(17,24,39,0.12)' const stickerShadow = isDark ? '0 6px 14px rgba(0,0,0,0.25), 0 2px 0 rgba(0,0,0,0.08)' : '0 6px 14px rgba(17,24,39,0.12), 0 2px 0 rgba(17,24,39,0.04)' const keyShadow = isDark ? '0 4px 0 rgba(0,0,0,0.45), 0 8px 16px rgba(0,0,0,0.3)' : '0 4px 0 rgba(17,24,39,0.35), 0 8px 16px rgba(17,24,39,0.18)' const placeholderColor = isDark ? 'rgba(255,255,255,0.55)' : 'rgba(17,24,39,0.5)' const titleColor = isDark ? 'rgba(255,255,255,0.95)' : 'rgba(17,24,39,0.95)' const titleShadow = isDark ? '0 2px 20px rgba(0,0,0,0.4)' : '0 2px 20px rgba(17,24,39,0.08)' const subtitleColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(17,24,39,0.7)' const pillHoverBg = isDark ? 'rgba(0,0,0,0.95)' : 'rgba(255,255,255,0.95)' const pillFocusBg = isDark ? 'rgba(0,0,0,1)' : 'rgba(255,255,255,1)' const pillHoverBorder = isDark ? 'rgba(255,255,255,0.85)' : 'rgba(17,24,39,0.6)' const pillFocusBorder = isDark ? '#FFFFFF' : '#111827' const pillFocusShadow = isDark ? '0 10px 24px rgba(0,0,0,0.3), 0 0 0 4px rgba(255,255,255,0.12)' : '0 10px 24px rgba(17,24,39,0.15), 0 0 0 4px rgba(17,24,39,0.08)' const sendHoverShadow = isDark ? '0 5px 0 rgba(0,0,0,0.45), 0 10px 20px rgba(0,0,0,0.32)' : '0 5px 0 rgba(17,24,39,0.35), 0 10px 20px rgba(17,24,39,0.2)' const sendActiveShadow = isDark ? '0 1px 0 rgba(0,0,0,0.45), 0 2px 4px rgba(0,0,0,0.25)' : '0 1px 0 rgba(17,24,39,0.35), 0 2px 4px rgba(17,24,39,0.15)' return (

Feedback Wall

Drop a note, toss an emoji, drag anything around. Real physics, no rules. Just leave your mark on the wall.

) } ``` --- ## Danger Stripes Category: Widgets Slug: `danger-stripes` URL: https://aicanvas.me/components/danger-stripes Three crossing caution-tape stripes on orange. Hover to shake, click to intensify. Install (free account): ```bash npx shadcn@latest add @aicanvas/danger-stripes ``` ```tsx 'use client' // npm install framer-motion /** * Displays layered warning stripes with repeating text. * Hovering jitters the stripes, while clicking briefly increases the motion. */ import { useCallback, useEffect, useRef } from 'react' import { motion } from 'framer-motion' const STRIPE_TEXT = 'WORK IN PROGRESS \u00A0\u2715\u00A0 DO NOT CLICK OR HOVER \u00A0\u2715\u00A0 DANGER' const STRIPES = [ { rotate: -20, top: '40%', bg: '#0a0a0a', fg: '#ffffff' }, { rotate: 12, top: '47%', bg: '#ffffff', fg: '#0a0a0a' }, { rotate: -8, top: '54%', bg: '#2a2a2a', fg: '#ffffff' }, ] const ACCENT_COLOR = '#FF6B1A' function RepeatingText({ color }: { color: string }) { return (
{Array.from({ length: 10 }, (_, i) => ( {STRIPE_TEXT.split('\u2715').map((chunk, j, arr) => ( {chunk} {j < arr.length - 1 && ( {'\u2715'} )} ))} {'\u00A0'} {'\u2715'} {'\u00A0 '} ))}
) } function random(min: number, max: number) { return Math.random() * (max - min) + min } export default function DangerStripes() { const rafRef = useRef(0) const hoveringRef = useRef(false) const clickedRef = useRef(false) const intensityRef = useRef(0) const stripesRef = useRef<(HTMLDivElement | null)[]>([]) const animateStripes = useCallback(() => { if (hoveringRef.current || clickedRef.current) { const target = clickedRef.current ? 3 : 1 intensityRef.current += (target - intensityRef.current) * 0.15 } else { intensityRef.current *= 0.92 } if (intensityRef.current < 0.005 && !hoveringRef.current && !clickedRef.current) { intensityRef.current = 0 stripesRef.current.forEach((el, idx) => { if (el) el.style.transform = `rotate(${STRIPES[idx].rotate}deg)` }) rafRef.current = 0 return } const intensity = intensityRef.current stripesRef.current.forEach((el, idx) => { if (!el) return const base = STRIPES[idx].rotate const tx = random(-18, 18) * intensity const ty = random(-10, 10) * intensity const skewX = random(-6, 6) * intensity const sc = 1 + random(-0.04, 0.04) * intensity el.style.transform = `rotate(${base}deg) translate(${tx}px, ${ty}px) skewX(${skewX}deg) scale(${sc})` }) rafRef.current = requestAnimationFrame(animateStripes) }, []) useEffect(() => { return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } }, []) const startLoop = useCallback(() => { if (rafRef.current) return rafRef.current = requestAnimationFrame(animateStripes) }, [animateStripes]) const triggerHover = useCallback(() => { hoveringRef.current = true startLoop() }, [startLoop]) const triggerClick = useCallback(() => { clickedRef.current = true startLoop() setTimeout(() => { clickedRef.current = false }, 500) }, [startLoop]) const triggerLeave = useCallback(() => { hoveringRef.current = false }, []) return (
{STRIPES.map((stripe, i) => ( { stripesRef.current[i] = el }} className="absolute left-[-40%] flex h-[60px] w-[180%] items-center overflow-hidden sm:h-[72px]" style={{ top: stripe.top, background: stripe.bg, boxShadow: '0 4px 24px rgba(0,0,0,0.4), 0 1px 8px rgba(0,0,0,0.3)', willChange: 'transform', cursor: 'pointer', }} initial={{ x: i % 2 === 0 ? -300 : 300, opacity: 0, rotate: stripe.rotate }} animate={{ x: 0, opacity: 1, rotate: stripe.rotate }} transition={{ duration: 0.6, delay: i * 0.12, ease: 'easeOut', }} onMouseEnter={triggerHover} onMouseLeave={triggerLeave} onClick={triggerClick} onTouchStart={triggerHover} onTouchEnd={() => { triggerClick() setTimeout(triggerLeave, 600) }} > ))}
) } ``` --- ## AI Job Cards Category: Cards & Modals Slug: `ai-job-cards` URL: https://aicanvas.me/components/ai-job-cards Three AI job card stacks with swipe-to-cycle. Brand logos, bookmark toggle, springs. Install (free account): ```bash npx shadcn@latest add @aicanvas/ai-job-cards ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Displays AI job card stacks that cycle by button press or vertical drag. */ import { useRef, useState, useLayoutEffect, useEffect, useCallback } from 'react' import { motion } from 'framer-motion' import { BookmarkSimple } from '@phosphor-icons/react' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect type LogoKey = 'claude' | 'openai' | 'gemini' | 'vercel' | 'mistral' | 'perplexity' interface CardData { rate: string title: string role: string } interface StackData { logo: LogoKey company: string borderColor: string borderColorLight?: string cards: [CardData, CardData, CardData] } const STACKS: StackData[] = [ { logo: 'claude' as const, company: 'Anthropic', borderColor: '#d97757', cards: [ { rate: '$120/hr', title: 'Prompt Engineer', role: 'AI Research' }, { rate: '$145/hr', title: 'AI Safety Researcher', role: 'Safety & Alignment' }, { rate: '$155/hr', title: 'Interpretability Lead', role: 'Research' }, ], }, { logo: 'perplexity' as const, company: 'Perplexity', borderColor: '#FFFFFF', borderColorLight: '#1C1C1C', cards: [ { rate: '$160/hr', title: 'Generative AI Lead', role: 'AI Platform' }, { rate: '$135/hr', title: 'ML Engineer', role: 'Infrastructure' }, { rate: '$140/hr', title: 'Search AI Researcher', role: 'Research' }, ], }, { logo: 'gemini' as const, company: 'Google', borderColor: '#4893FC', cards: [ { rate: '$130–160/hr', title: 'LLM Platform Engineer', role: 'Engineering' }, { rate: '$150/hr', title: 'AI Research Scientist', role: 'Research' }, { rate: '$165/hr', title: 'Multimodal AI Lead', role: 'DeepMind' }, ], }, ] const CARD_H = 234 // tune: raise to make each card taller const PEEK = 20 // tune: raise to reveal more of the stacked cards const SLOTS = [ { y: 0, scale: 1, z: 3 }, { y: PEEK, scale: 0.96, z: 2 }, { y: PEEK * 2, scale: 0.92, z: 1 }, ] function ClaudeLogo() { return ( ) } function OpenAILogo({ isDark }: { isDark: boolean }) { const fill = isDark ? '#FFFFFF' : '#000000' return ( ) } function GeminiLogo() { return ( ) } function VercelLogo({ isDark }: { isDark: boolean }) { const fill = isDark ? '#FFFFFF' : '#000000' return ( ) } function MistralLogo() { return ( {} ) } function PerplexityLogo({ isDark }: { isDark: boolean }) { const fill = isDark ? '#FFFFFF' : '#1C1C1C' return ( ) } function BrandLogo({ logo, isDark }: { logo: LogoKey; isDark: boolean }) { switch (logo) { case 'claude': return case 'openai': return case 'gemini': return case 'vercel': return case 'mistral': return case 'perplexity': return default: return null } } function useIsDark(ref: React.RefObject) { const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false) useIsomorphicLayoutEffect(() => { const el = ref.current if (!el) return const check = () => { const card = el.closest('[data-card-theme]') setIsDark(card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark')) } check() const obs = new MutationObserver(check) obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) const cardWrapper = el.closest('[data-card-theme]') if (cardWrapper) obs.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] }) return () => obs.disconnect() }, [ref]) return isDark } interface SingleCardProps { card: CardData stack: StackData isDark: boolean isFront: boolean frontIndex: number onCycle: () => void } function SingleCard({ card, stack, isDark, isFront, frontIndex, onCycle }: SingleCardProps) { const [bookmarked, setBookmarked] = useState(false) const cardBg = isDark ? '#1e1e1c' : '#F7F7EF' const borderColor = isDark ? stack.borderColor : (stack.borderColorLight ?? stack.borderColor) const rateColor = isDark ? 'rgba(255,255,255,0.55)' : 'rgba(30,30,28,0.55)' const titleColor = isDark ? '#F5F5F0' : '#141412' const arrowColor = isFront ? (isDark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)') : (isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.18)') const dotFill = isDark ? '#E5E5E0' : '#2a2a28' const dotEmpty = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.15)' const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)' const companyColor = isDark ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)' const roleColor = isDark ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' const btnBg = isDark ? '#e8e8e0' : '#1a1a18' const btnText = isDark ? '#1a1a18' : '#FFFFFF' const shadowVal = isDark ? '0 2px 12px rgba(0,0,0,0.35)' : '0 2px 8px rgba(0,0,0,0.07)' const shadowHover = isDark ? '0 12px 32px rgba(0,0,0,0.55)' : '0 12px 24px rgba(0,0,0,0.13)' return (
{ if (isFront) (e.currentTarget as HTMLDivElement).style.boxShadow = shadowHover }} onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = shadowVal }} style={{ background: cardBg, borderRadius: 32, border: `1px solid ${borderColor}1A`, padding: '18px 18px 16px', display: 'flex', flexDirection: 'column', gap: 0, boxShadow: shadowVal, transition: 'box-shadow 0.25s ease', height: CARD_H, boxSizing: 'border-box', overflow: 'hidden', }} > {}
{card.rate} { e.stopPropagation(); setBookmarked(b => !b) }} whileTap={{ scale: 1.3 }} transition={{ type: 'spring', stiffness: 400, damping: 18 }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }} aria-label={bookmarked ? 'Remove bookmark' : 'Add bookmark'} >
{}

{card.title}

{ e.stopPropagation(); if (isFront) onCycle() }} whileTap={isFront ? { scale: 0.9 } : {}} transition={{ type: 'spring', stiffness: 400, damping: 20 }} style={{ background: 'none', border: 'none', cursor: isFront ? 'pointer' : 'default', padding: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5, flexShrink: 0, marginRight: 6, }} aria-label={isFront ? 'Next card' : undefined} > {[0, 1, 2].map(i => { const isFrontDot = i === frontIndex return (
) })}
{}
{}
{stack.company} {card.role}
e.stopPropagation()} style={{ background: btnBg, color: btnText, border: 'none', borderRadius: 999, padding: '7px 16px', fontSize: 12, fontWeight: 600, cursor: 'pointer', letterSpacing: '0.01em', flexShrink: 0, }} > View
) } interface CardStackProps { stack: StackData isDark: boolean } function CardStack({ stack, isDark }: CardStackProps) { const [order, setOrder] = useState<[number, number, number]>([0, 1, 2]) const [exitingId, setExitingId] = useState(null) const exitDir = useRef<'up' | 'down'>('up') const [returningIds, setReturningIds] = useState>(new Set()) const dismissing = useRef(false) const orderRef = useRef(order) useEffect(() => { orderRef.current = order }, [order]) const cycle = useCallback((dir: 'up' | 'down' = 'up') => { if (dismissing.current) return dismissing.current = true exitDir.current = dir const frontId = orderRef.current[0] setExitingId(frontId) setTimeout(() => { setReturningIds(new Set([frontId])) setOrder(prev => [prev[1], prev[2], prev[0]]) setExitingId(null) requestAnimationFrame(() => requestAnimationFrame(() => { setReturningIds(new Set()) dismissing.current = false })) }, 380) }, []) const containerHeight = CARD_H + PEEK * 2 + 4 return (
{([0, 1, 2] as const).map((cardIndex) => { const slotIndex = order.indexOf(cardIndex) const slot = SLOTS[slotIndex] const isExiting = exitingId === cardIndex const isReturning = returningIds.has(cardIndex) const isFront = slotIndex === 0 const animTarget = isExiting ? { y: exitDir.current === 'down' ? 160 : -160, scale: 0.88, opacity: 0 } : { y: slot.y, scale: slot.scale, opacity: 1 } const animTransition = isExiting ? { duration: 0.38, ease: [0.4, 0, 0.2, 1] as [number, number, number, number] } : isReturning ? { duration: 0 } : { type: 'spring' as const, stiffness: 280, damping: 26 } return ( { if (Math.abs(info.offset.y) > 60 || Math.abs(info.velocity.y) > 400) { cycle(info.offset.y > 0 ? 'down' : 'up') } }} whileHover={isFront ? { y: slot.y - 4, transition: { type: 'spring', stiffness: 300, damping: 24 } } : {}} style={{ position: 'absolute', left: 0, right: 0, top: 0, zIndex: isExiting ? 10 : slot.z, transformOrigin: 'center top', cursor: isFront ? 'grab' : 'default', }} > ) })}
) } export default function AiJobCards() { const containerRef = useRef(null) const isDark = useIsDark(containerRef) const [narrow, setNarrow] = useState(false) useEffect(() => { const el = containerRef.current if (!el) return const obs = new ResizeObserver(entries => { for (const entry of entries) { setNarrow(entry.contentRect.width < 480) } }) obs.observe(el) return () => obs.disconnect() }, []) const outerBg = isDark ? '#0d0d0c' : '#CADBDD' return (
{STACKS.map((stack, i) => (
))}
) } ``` --- ## Label Cards Category: Cards & Modals Slug: `label-cards` URL: https://aicanvas.me/components/label-cards Colour-blocked label cards scattered at random angles. Tap one to spring it to centre. Install (free account): ```bash npx shadcn@latest add @aicanvas/label-cards ``` ```tsx 'use client' // npm install framer-motion /** * Presents a responsive stack of labeled portfolio cards. * Hover and focus separate the overlapping cards while preserving their spread. */ import { useRef, useState, useLayoutEffect, useEffect } from 'react' import { motion } from 'framer-motion' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect // tune: change both dimensions to resize the card stack const BASE_CARD_W = 300 const BASE_CARD_H = 169 // customize: replace the card labels, colors, and resting positions below const CARDS = [ { id: 0, title: 'Mobile Apps', sub1: 'Native iOS & Android', sub2: 'Interaction Design', since: '2023', bg: '#5CAD60', fg: '#162217', idle: { x: -82, y: -70, rotate: -11 }, }, { id: 1, title: 'Web Design', sub1: 'React & Next.js', sub2: 'Frontend Systems', since: '2021', bg: '#EDD540', fg: '#241F05', idle: { x: -48, y: 66, rotate: 9 }, }, { id: 2, title: 'Branding', sub1: 'Visual Identity', sub2: 'Brand Strategy', since: '2020', bg: '#1C2E8A', fg: '#E8EDF8', idle: { x: 12, y: -14, rotate: -4 }, }, { id: 3, title: 'Editorial', sub1: 'Magazine & Print', sub2: 'Typography Systems', since: '2022', bg: '#D43C3C', fg: '#FAE8E8', idle: { x: 68, y: 62, rotate: 14 }, }, { id: 4, title: 'Motion', sub1: 'Animation & Micro-UX', sub2: 'Interaction Patterns', since: '2023', bg: '#E08030', fg: '#2A1A06', idle: { x: 84, y: -86, rotate: 18 }, }, ] const BAR_PATTERN = [1,2,1,1,3,1,2,1,1,3,1,1,2,1,2,1,3,1,1,2,1,3,1,2,1,1,2,1] const SPRING_SCATTER = { type: 'spring' as const, stiffness: 240, damping: 24 } const SPRING_FOCUS = { type: 'spring' as const, stiffness: 340, damping: 28 } function Barcode({ color, scale }: { color: string; scale: number }) { return (
{BAR_PATTERN.map((w, i) => (
))}
) } function LogoMark({ color, scale }: { color: string; scale: number }) { const size = 28 * scale return (
AI
CANVAS
) } export default function LabelCards() { const containerRef = useRef(null) const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false) const [selected, setSelected] = useState(null) const [containerW, setContainerW] = useState(480) useIsomorphicLayoutEffect(() => { const el = containerRef.current if (!el) return const checkTheme = () => { const card = el.closest('[data-card-theme]') setIsDark( card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark'), ) } checkTheme() const themeObs = new MutationObserver(checkTheme) themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) const cardWrapper = el.closest('[data-card-theme]') if (cardWrapper) themeObs.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] }) const sizeObs = new ResizeObserver(entries => { setContainerW(entries[0].contentRect.width) }) sizeObs.observe(el) return () => { themeObs.disconnect(); sizeObs.disconnect() } }, []) const cardW = Math.min(BASE_CARD_W, Math.floor(containerW / 1.56)) const cardH = Math.round(cardW * 9 / 16) const scale = cardW / BASE_CARD_W return (
{}
{CARDS.map(card => { const isSelected = selected === card.id return ( setSelected(prev => (prev === card.id ? null : card.id))} >
{}
{card.title}
Since {card.since}
{}
{}

{card.sub1}

{card.sub2}

{}
) })} {} tap a card to focus
) } ``` --- ## Slide Deck Category: Cards & Modals Slug: `slide-deck` URL: https://aicanvas.me/components/slide-deck Swipeable card deck with four editorial slides. Navigate forward and back by drag or tap. Install (free account): ```bash npx shadcn@latest add @aicanvas/slide-deck ``` ```tsx 'use client' // npm install framer-motion /** * Presents a responsive deck of numbered presentation slides. * Dragging or using navigation controls moves the focused slide through the stack. */ import { useRef, useState, useCallback, useLayoutEffect, useEffect } from 'react' import { motion } from 'framer-motion' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect // customize: replace the slide content and colors below const SLIDES = [ { id: 0, num: '01', label: 'Opportunity', title: 'Define the\nProblem Space', accent: '#E55A2B', bg: '#111111', textPrimary: '#FFFFFF', textMuted: 'rgba(255,255,255,0.35)', shape: 'circle', }, { id: 1, num: '02', label: 'Strategy', title: 'Discover\nDirection', accent: '#E55A2B', bg: '#F0EDEA', textPrimary: '#111111', textMuted: 'rgba(0,0,0,0.35)', shape: 'square', }, { id: 2, num: '03', label: 'Execution', title: 'Design &\nDeliver', accent: '#111111', bg: '#E55A2B', textPrimary: '#FFFFFF', textMuted: 'rgba(255,255,255,0.5)', shape: 'line', }, { id: 3, num: '04', label: 'Metrics', title: 'Measure\nImpact', accent: '#E55A2B', bg: '#2A2A2A', textPrimary: '#F0EDEA', textMuted: 'rgba(240,237,234,0.4)', shape: 'triangle', }, ] // tune: change both dimensions to resize the slide cards const CARD_W = 260 const CARD_H = 300 // tune: adjust to change the stacked slide positions const STACK = [ { x: 0, y: 0, scale: 1.000, opacity: 1 }, { x: 0, y: 11, scale: 0.962, opacity: 1 }, { x: 0, y: 20, scale: 0.926, opacity: 1 }, ] const OFFSCREEN = { x: 0, y: 30, scale: 0.88, opacity: 0 } export default function SlideDeck() { const containerRef = useRef(null) const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false) useIsomorphicLayoutEffect(() => { const el = containerRef.current if (!el) return const check = () => { const card = el.closest('[data-card-theme]') setIsDark(card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark')) } check() const obs = new MutationObserver(check) obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) const cardWrapper = el.closest('[data-card-theme]') if (cardWrapper) obs.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] }) return () => obs.disconnect() }, []) const [current, setCurrent] = useState(0) const [direction, setDirection] = useState<1 | -1>(1) const [exitInfo, setExitInfo] = useState<{ slideId: number; xTarget: number } | null>(null) const [enterFromRight, setEnterFromRight] = useState(null) const goTo = useCallback((newIdx: number, dir: 1 | -1) => { if (dir > 0) { setExitInfo({ slideId: SLIDES[current].id, xTarget: -380 }) setEnterFromRight(null) } else { setExitInfo(null) setEnterFromRight(SLIDES[newIdx].id) } setDirection(dir) setCurrent(newIdx) }, [current]) const navigate = useCallback((dir: 1 | -1) => { goTo((current + dir + SLIDES.length) % SLIDES.length, dir) }, [current, goTo]) return (
{}
{SLIDES.map(slide => { const offset = (slide.id - current + SLIDES.length) % SLIDES.length const isExiting = exitInfo?.slideId === slide.id && offset === SLIDES.length - 1 const isEnteringFromRight = enterFromRight === slide.id && offset === 0 const animTarget = isExiting ? { x: exitInfo!.xTarget, y: 0, scale: 0.88, opacity: 0 } : offset <= 2 ? STACK[offset] : OFFSCREEN const zIndex = isEnteringFromRight ? 20 : isExiting ? 15 : offset === 0 ? 10 : offset === 1 ? 6 : offset === 2 ? 2 : 0 return ( { setExitInfo(prev => prev?.slideId === slide.id ? null : prev) setEnterFromRight(prev => prev === slide.id ? null : prev) }} style={{ position: 'absolute', inset: 0, borderRadius: 20, background: slide.bg, overflow: 'hidden', zIndex, cursor: offset === 0 ? 'grab' : 'default', boxShadow: offset === 0 ? (isDark ? '0 20px 60px rgba(0,0,0,0.6)' : '0 12px 40px rgba(0,0,0,0.18)') : 'none', pointerEvents: offset === 0 ? 'auto' : 'none', }} drag={offset === 0 ? 'x' : false} dragConstraints={offset === 0 ? { left: 0, right: 0 } : undefined} dragElastic={offset === 0 ? 0.5 : undefined} onDragEnd={offset === 0 ? (_, info) => { if (info.offset.x < -60 || info.velocity.x < -400) navigate(1) else if (info.offset.x > 60 || info.velocity.x > 400) navigate(-1) } : undefined} > {} {}
{}
{slide.label} {slide.num} / 04
{}
{slide.num}
{slide.title}
) })}
{}
{SLIDES.map((s, i) => ( { if (i !== current) goTo(i, i > current ? 1 : -1) }} style={{ height: 6, borderRadius: 3, background: i === current ? '#E55A2B' : (isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.15)'), border: 'none', cursor: 'pointer', padding: 0, }} animate={{ width: i === current ? 24 : 6 }} transition={{ type: 'spring', stiffness: 400, damping: 30 }} aria-label={`Slide ${i + 1}`} /> ))}
) } function ShapeDecor({ type, accent, primary, }: { type: string accent: string primary: string }) { if (type === 'circle') { return (
) } if (type === 'square') { return (
) } if (type === 'line') { return ( <>
) } if (type === 'triangle') { return ( ) } return null } ``` --- ## Task Cards Category: Cards & Modals Slug: `task-cards` URL: https://aicanvas.me/components/task-cards A swipeable project task card stack where each card has a unique accent colour, status badge, and due date. Drag left or right to cycle through tasks. The dismissed card springs back to the bottom of the deck. Install (free account): ```bash npx shadcn@latest add @aicanvas/task-cards ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Presents project tasks as a responsive overlapping card deck. * Dragging or using arrow controls moves the selected task through the stack. */ import { useRef, useState, useCallback, useEffect, useLayoutEffect } from 'react' import { motion, useMotionValue, useTransform } from 'framer-motion' import { PaintBrush, Megaphone, Code, ChartBar, CaretLeft, CaretRight, ArrowUpRight } from '@phosphor-icons/react' import type { Icon as PhosphorIcon } from '@phosphor-icons/react' // tune: change both dimensions to resize the task cards const CARD_W = 220 const CARD_H = 280 const DECK_W = CARD_W + 210 // customize: replace the task content, colors, and icons below const TASKS: Array<{ id: number title: string category: string description: string progress: number accent: string accentLight: string bg: string bgLight: string darkOnAccent?: boolean darkLabel?: string lightLabel?: string icon: PhosphorIcon }> = [ { id: 0, title: 'Brand Overhaul', category: 'Design', description: 'Complete visual identity refresh: logo, type scale, and colour system across all brand touchpoints.', progress: 45, accent: '#429EBD', accentLight: '#2980A0', bg: '#0C1E27', bgLight: '#EAF4F8', icon: PaintBrush, }, { id: 1, title: 'Product Launch', category: 'Marketing', description: 'Coordinate go-to-market strategy, press kit, social assets, and launch-day campaign timeline.', progress: 72, accent: '#053F5C', accentLight: '#032F45', bg: '#010810', bgLight: '#B8CEDB', darkLabel: '#2A9DC0', lightLabel: '#0A6A8E', icon: Megaphone, }, { id: 2, title: 'API Migration', category: 'Engineering', description: 'Migrate three legacy endpoints to v3 schema with full backward-compatibility and rollback plan.', progress: 28, accent: '#F7AD19', accentLight: '#D4900E', bg: '#1E1608', bgLight: '#FEF8E6', darkOnAccent: true, icon: Code, }, { id: 3, title: 'Q2 Metrics', category: 'Analytics', description: 'Build consolidated dashboard: retention, revenue, and activation funnels with weekly drill-down.', progress: 15, accent: '#F27F0C', accentLight: '#C96208', bg: '#1C1006', bgLight: '#FEF1E4', darkOnAccent: true, icon: ChartBar, }, ] // tune: adjust to change the deck spread const SLOTS = [ { x: 0, y: 0, rotate: 0, scale: 1, z: 4, opacity: 1 }, { x: 108, y: 0, rotate: 0, scale: 0.88, z: 3, opacity: 0.7 }, { x: -108, y: 0, rotate: 0, scale: 0.88, z: 2, opacity: 0.7 }, { x: 0, y: 0, rotate: 0, scale: 0.78, z: 1, opacity: 0 }, ] const SPRING = { type: 'spring' as const, stiffness: 280, damping: 26 } const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect function useIsDark(ref: React.RefObject) { const [isDark, setIsDark] = useState(() => { if (typeof window === 'undefined') return false return document.documentElement.classList.contains('dark') }) useIsomorphicLayoutEffect(() => { const el = ref.current if (!el) return const check = () => { const card = el.closest('[data-card-theme]') setIsDark(card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark')) } check() const obs = new MutationObserver(check) obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) const cardWrapper = el.closest('[data-card-theme]') if (cardWrapper) obs.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] }) return () => obs.disconnect() }, [ref]) return isDark } function AnimatedProgress({ progress, isActive, darkText }: { progress: number; isActive: boolean; darkText?: boolean }) { const [count, setCount] = useState(0) useEffect(() => { if (!isActive) { setCount(0) return } const duration = 1400 const delay = 300 let rafId: number let startTime: number | null = null const tick = (now: number) => { if (startTime === null) startTime = now const t = Math.min((now - startTime) / duration, 1) const eased = 1 - Math.pow(1 - t, 3) setCount(Math.round(eased * progress)) if (t < 1) rafId = requestAnimationFrame(tick) } const timeout = setTimeout(() => { rafId = requestAnimationFrame(tick) }, delay) return () => { clearTimeout(timeout); cancelAnimationFrame(rafId) } }, [progress, isActive]) const labelColor = darkText ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.6)' const pctColor = darkText ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.95)' const trackBg = darkText ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.2)' const fillBg = darkText ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.85)' return (
Progress {count}%
) } export default function TaskCards() { const containerRef = useRef(null) const isDark = useIsDark(containerRef) const dragX = useMotionValue(0) const cardRotateY = useTransform(dragX, [-200, 0, 200], [14, 0, -14]) const [order, setOrder] = useState([0, 1, 2, 3]) const orderRef = useRef(order) useEffect(() => { orderRef.current = order }, [order]) const dismissing = useRef(false) const dragDelta = useRef(0) const [exiting, setExiting] = useState<{ id: number; dir: 'left' | 'right' } | null>(null) const [returning, setReturning] = useState>(new Set()) const dismiss = useCallback((dir: 'left' | 'right') => { if (dismissing.current) return dismissing.current = true const frontId = orderRef.current[0] setExiting({ id: frontId, dir }) setTimeout(() => { setReturning(prev => new Set([...prev, frontId])) setOrder(prev => [...prev.slice(1), prev[0]]) setExiting(null) requestAnimationFrame(() => requestAnimationFrame(() => { setReturning(prev => { const s = new Set(prev); s.delete(frontId); return s }) dismissing.current = false })) }, 420) }, []) return (
{}
{TASKS.map(task => { const slotIndex = order.indexOf(task.id) const slot = SLOTS[slotIndex] const isFront = slotIndex === 0 const isExiting = exiting?.id === task.id const isReturning = returning.has(task.id) const cardBg = isDark ? task.accent : task.accentLight const topBg = isDark ? task.bg : task.bgLight const catColor = isDark ? (task.darkLabel ?? task.accent) : (task.lightLabel ?? task.accentLight) const titleColor = isDark ? 'rgba(255,255,255,0.92)' : '#21211F' const descColor = isDark ? 'rgba(255,255,255,0.48)' : '#52524E' const Icon = task.icon return ( { dragDelta.current = 0; dragX.set(0) }} onDrag={(_, info) => { dragDelta.current = info.offset.x; dragX.set(info.offset.x) }} onDragEnd={(_, info) => { dragX.set(0) if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 400) { dismiss(info.offset.x < 0 ? 'left' : 'right') } }} > {}
{} {}
{task.category}
{}
{}

{task.title}

{}
{}

{task.description}

{}
) })}
{}
{([ { dir: 'left' as const, icon: , label: 'Previous' }, { dir: 'right' as const, icon: , label: 'Next' }, ] as const).map(({ dir, icon, label }) => ( dismiss(dir)} aria-label={label} style={{ width: 36, height: 36, borderRadius: '50%', border: `1.5px solid ${isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.08)'}`, background: 'rgba(0,0,0,0)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.28)', }} whileHover={{ scale: 1.1, background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)', borderColor: isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.18)', color: isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.55)', }} whileTap={{ scale: 0.9 }} transition={{ type: 'spring', stiffness: 400, damping: 25 }} > {icon} ))}
) } ``` --- ## Meet the Crew Category: Cards & Modals Slug: `meet-the-crew` URL: https://aicanvas.me/components/meet-the-crew A swipeable portrait card stack perfect for introducing a team, showcasing crew members, or picking an avatar. Four stacked cards fan out with soft rotations: drag to browse, tap to choose. Drop in your own photos and names for an About Us section, a character selector, or an onboarding profile picker. Install (free account): ```bash npx shadcn@latest add @aicanvas/meet-the-crew ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Presents an avatar stack that advances on horizontal swipes and confirms a tapped selection. */ import { useState, useLayoutEffect, useEffect, useRef, useCallback } from 'react' import { motion } from 'framer-motion' import { Check } from '@phosphor-icons/react' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect const CARD_W = 174 const CARD_H = 218 const RADIUS = 20 interface Photo { id: number name: string role: string url: string } const PHOTOS: Photo[] = [ { id: 0, name: 'Capt. Vroom', role: 'Asteroid Hugger', url: 'https://images.unsplash.com/photo-1698327615546-7f30183aa4e3?w=400&h=500&fit=crop&auto=format&q=80', }, { id: 1, name: 'Zork', role: 'Zero-G Chef', url: 'https://images.unsplash.com/photo-1541873676-a18131494184?w=400&h=500&fit=crop&auto=format&q=80', }, { id: 2, name: 'Dronk', role: 'Space Surfer', url: 'https://images.unsplash.com/photo-1691379635079-9f438036ea58?w=400&h=500&fit=crop&auto=format&q=80', }, { id: 3, name: 'Gloop', role: 'Nebula Napper', url: 'https://images.unsplash.com/photo-1536697246787-1f7ae568d89a?w=400&h=500&fit=crop&auto=format&q=80', }, ] const SLOTS = [ { x: 0, y: 0, rotate: 1.5, scale: 1, z: 4 }, { x: 52, y: -8, rotate: 8, scale: 0.92, z: 3 }, { x: -46, y: -4, rotate: -9, scale: 0.90, z: 2 }, { x: 4, y: 26, rotate: 3.5, scale: 0.86, z: 1 }, ] const SPRING = { type: 'spring' as const, stiffness: 280, damping: 26 } export default function AvatarPicker() { const containerRef = useRef(null) const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false) const [order, setOrder] = useState([0, 1, 2, 3]) const [selectedId, setSelectedId] = useState(null) const [exiting, setExiting] = useState<{ id: number; dir: 'left' | 'right' } | null>(null) const [returning, setReturning] = useState>(new Set()) const orderRef = useRef(order) const dismissing = useRef(false) const dragDelta = useRef(0) useIsomorphicLayoutEffect(() => { orderRef.current = order }, [order]) useIsomorphicLayoutEffect(() => { const check = () => setIsDark(document.documentElement.classList.contains('dark')) check() const obs = new MutationObserver(check) obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) return () => obs.disconnect() }, []) const dismiss = useCallback((dir: 'left' | 'right') => { if (dismissing.current) return dismissing.current = true const frontId = orderRef.current[0] setExiting({ id: frontId, dir }) setTimeout(() => { setReturning(prev => new Set([...prev, frontId])) setOrder(prev => [...prev.slice(1), prev[0]]) setExiting(null) requestAnimationFrame(() => requestAnimationFrame(() => { setReturning(prev => { const s = new Set(prev) s.delete(frontId) return s }) dismissing.current = false })) }, 420) }, []) const handleSelect = useCallback(() => { const frontId = orderRef.current[0] setSelectedId(prev => prev === frontId ? null : frontId) }, []) const labelColor = isDark ? 'rgba(255,255,255,0.35)' : '#1a1a18' return (

Meet the Crew

{PHOTOS.map(photo => { const slotIndex = order.indexOf(photo.id) const slot = SLOTS[slotIndex] const isExiting = exiting?.id === photo.id const isReturning = returning.has(photo.id) const isFront = slotIndex === 0 && !isExiting const isSelected = selectedId === photo.id return ( { dragDelta.current = 0 }} onDrag={(_, info) => { dragDelta.current = info.offset.x }} onDragEnd={(_, info) => { if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 400) { dismiss(info.offset.x < 0 ? 'left' : 'right') } }} onClick={() => { if (isFront && Math.abs(dragDelta.current) < 8) handleSelect() }} > {photo.name}

{photo.name}

{photo.role}

{isSelected && ( )}
{isSelected && (
)} ) })}
{PHOTOS.map(photo => { const isCurrent = order[0] === photo.id return ( ) })}
{selectedId !== null ? ( setSelectedId(null)} style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#2DD4BF', borderRadius: 20, padding: '7px 18px', border: 'none', cursor: 'pointer', }} > {PHOTOS.find(p => p.id === selectedId)?.name} Selected ) : ( swipe to browse )}
) } ``` --- ## Andromeda Button Category: Buttons & Toggles Slug: `andromeda-button` URL: https://aicanvas.me/components/andromeda-button A sci-fi / blueprint-aesthetic button with five variants (default, outline, ghost, destructive, link), three sizes (small, medium, large), optional leading icon, and full hover / focus / active / disabled state coverage. Transparent hairline surfaces sit on a near-black canvas, with an electric-blue accent that brightens and glows on interaction. Install (free account): ```bash npx shadcn@latest add @aicanvas/andromeda-button ``` ```tsx 'use client' // npm install @carbon/icons-react class-variance-authority clsx next tailwind-merge // font: JetBrains Mono /** * Demonstrates a tokenized button family with hover, focus, pressed, and disabled states. */ import { forwardRef } from 'react' import type { ComponentType, ReactNode, ButtonHTMLAttributes } from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { clsx, type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' import { JetBrains_Mono } from 'next/font/google' import { Notification, Settings } from '@carbon/icons-react' function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } function andromedaVars(): React.CSSProperties { return { '--andromeda-text-primary': 'rgba(255, 255, 255, 0.96)', '--andromeda-text-secondary': 'rgba(255, 255, 255, 0.62)', '--andromeda-surface-raised': 'rgba(255, 255, 255, 0.025)', '--andromeda-surface-hover': 'rgba(255, 255, 255, 0.06)', '--andromeda-surface-active': 'rgba(255, 255, 255, 0.09)', '--andromeda-border-base': 'rgba(255, 255, 255, 0.08)', '--andromeda-border-bright': 'rgba(255, 255, 255, 0.32)', '--andromeda-accent-base': '#2DD4BF', '--andromeda-accent-bright': '#5EEAD4', '--andromeda-accent-dim': 'rgba(45, 212, 191, 0.55)', '--andromeda-accent-glow': 'rgba(45, 212, 191, 0.18)', '--andromeda-accent-glow-soft':'rgba(45, 212, 191, 0.08)', '--andromeda-fault': '#EF4444', '--andromeda-fault-dim': 'rgba(239, 68, 68, 0.50)', '--andromeda-fault-glow': 'rgba(239, 68, 68, 0.10)', '--andromeda-fault-ring': 'rgba(239, 68, 68, 0.25)', '--andromeda-font-mono': "'JetBrains Mono', 'IBM Plex Mono', Menlo, monospace", '--andromeda-text-xs': '10px', '--andromeda-text-sm': '12px', '--andromeda-text-md': '14px', '--andromeda-weight-medium': '500', '--andromeda-leading-tight': '1.1', '--andromeda-tracking-wider': '0.14em', '--andromeda-2': '8px', '--andromeda-3': '12px', '--andromeda-4': '16px', '--andromeda-5': '20px', '--andromeda-radius-none': '0', } as React.CSSProperties } const buttonVariants = cva( [ 'relative inline-flex items-center justify-center select-none whitespace-nowrap', 'gap-[var(--andromeda-2)] border border-solid rounded-[var(--andromeda-radius-none)]', '[font-family:var(--andromeda-font-mono)] font-[number:var(--andromeda-weight-medium)]', 'uppercase [letter-spacing:var(--andromeda-tracking-wider)] [line-height:var(--andromeda-leading-tight)]', 'cursor-pointer transition-all duration-150 ease-out active:scale-[0.97]', '[backdrop-filter:blur(2px)] [-webkit-backdrop-filter:blur(2px)]', 'focus-visible:outline-none', 'focus-visible:shadow-[0_0_0_1px_var(--andromeda-accent-dim),0_0_12px_var(--andromeda-accent-glow)]', 'disabled:cursor-not-allowed disabled:opacity-[0.35] disabled:pointer-events-none', ], { variants: { variant: { default: [ 'text-[color:var(--andromeda-accent-base)] bg-[color:var(--andromeda-accent-glow-soft)] border-[color:var(--andromeda-accent-dim)]', 'hover:text-[color:var(--andromeda-accent-bright)] hover:bg-[color:var(--andromeda-accent-glow)] hover:border-[color:var(--andromeda-accent-bright)]', 'hover:shadow-[0_0_16px_var(--andromeda-accent-glow)]', ], outline: [ 'text-[color:var(--andromeda-text-primary)] bg-[color:var(--andromeda-surface-raised)] border-[color:var(--andromeda-border-base)]', 'hover:bg-[color:var(--andromeda-surface-hover)] hover:border-[color:var(--andromeda-border-bright)]', 'active:bg-[color:var(--andromeda-surface-active)]', ], ghost: [ 'text-[color:var(--andromeda-text-secondary)] bg-transparent border-transparent', 'hover:text-[color:var(--andromeda-text-primary)] hover:bg-[color:var(--andromeda-surface-raised)]', 'active:bg-[color:var(--andromeda-surface-hover)]', ], destructive: [ 'text-[color:var(--andromeda-fault)] bg-[color:var(--andromeda-fault-glow)] border-[color:var(--andromeda-fault-dim)]', 'hover:bg-[color:var(--andromeda-fault-dim)] hover:text-[color:var(--andromeda-text-primary)] hover:border-[color:var(--andromeda-fault)]', 'hover:shadow-[0_0_16px_var(--andromeda-fault-ring)]', 'focus-visible:shadow-[0_0_0_1px_var(--andromeda-fault-dim),0_0_12px_var(--andromeda-fault-ring)]', ], link: [ 'text-[color:var(--andromeda-accent-base)] bg-transparent border-transparent', 'underline-offset-4 hover:underline hover:text-[color:var(--andromeda-accent-bright)]', 'focus-visible:shadow-none focus-visible:underline', ], }, size: { sm: 'px-[var(--andromeda-3)] py-[5px] text-[length:var(--andromeda-text-xs)]', md: 'px-[var(--andromeda-4)] py-[var(--andromeda-2)] text-[length:var(--andromeda-text-sm)]', lg: 'px-[var(--andromeda-5)] py-[11px] text-[length:var(--andromeda-text-md)]', }, }, defaultVariants: { variant: 'default', size: 'md' }, }, ) type ButtonProps = ButtonHTMLAttributes & VariantProps & { icon?: ComponentType<{ size?: number | string }> } const Button = forwardRef(function Button( { className, variant, size, icon: Icon, children, style, type = 'button', ...props }, ref, ) { const iconSize = size === 'sm' ? 16 : size === 'lg' ? 20 : 18 return ( ) }) const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-jetbrains-mono', display: 'swap', }) function Row({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
) } export default function AndromedaButton() { return (
) } ``` --- ## Glass AI Composer Category: Inputs & Controls Slug: `glass-ai-compose` URL: https://aicanvas.me/components/glass-ai-compose Glassmorphism AI chat input with image upload, web search toggle, and model switcher. Install (free account): ```bash npx shadcn@latest add @aicanvas/glass-ai-compose ``` ```tsx 'use client' // npm install @phosphor-icons/react framer-motion /** * Presents a glass-styled AI composer with model and web-search controls. * Text input expands with content and supports removable image attachments. */ import { useState, useRef, useEffect, useCallback } from 'react' import { motion, AnimatePresence, useReducedMotion } from 'framer-motion' import { PaperPlaneRight, ImageSquare, GlobeSimple, X, } from '@phosphor-icons/react' const BACKGROUND = 'https://ik.imagekit.io/aitoolkit/bg%20images/Ethereal%20Orange%20Flower%204%20(1).png?updatedAt=1775226802133' const MODELS = [ { label: 'Claude', color: '#FF7B54' }, { label: 'ChatGPT', color: '#10A37F' }, { label: 'Perplexity', color: '#3A86FF' }, { label: 'Gemini', color: '#FFBE0B' }, ] as const type Model = (typeof MODELS)[number] const MAX_TEXTAREA_HEIGHT = 160 const glassBlur = { backdropFilter: 'blur(24px) saturate(1.8)', WebkitBackdropFilter: 'blur(24px) saturate(1.8)', } as const const glassPanel = { background: 'rgba(255, 255, 255, 0.08)', border: '1px solid rgba(255, 255, 255, 0.1)', boxShadow: '0 8px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.08)', } as const const ACTIVE_GLOW = '0 8px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 0 1.5px rgba(255, 255, 255, 0.25), 0 0 20px rgba(255, 255, 255, 0.06)' function ModelSwitcher({ activeModel, onSelect, }: { activeModel: Model onSelect: (model: Model) => void }) { return (
{MODELS.map((model) => { const isActive = model.label === activeModel.label return ( ) })}
) } function ImageThumbnail({ src, onRemove, }: { src: string onRemove: () => void }) { return ( Upload preview ) } export default function GlassAiCompose() { const [isActive, setIsActive] = useState(false) const [message, setMessage] = useState('') const [activeModel, setActiveModel] = useState(MODELS[0]) const [images, setImages] = useState([]) const [webSearch, setWebSearch] = useState(false) const [showWebLabel, setShowWebLabel] = useState(false) const containerRef = useRef(null) const textareaRef = useRef(null) const fileInputRef = useRef(null) const prefersReduced = useReducedMotion() const reducedMotion = prefersReduced ?? false const canSend = message.trim().length > 0 || images.length > 0 useEffect(() => { if (!webSearch) { setShowWebLabel(false); return } setShowWebLabel(true) const timer = setTimeout(() => setShowWebLabel(false), 1000) return () => clearTimeout(timer) }, [webSearch]) const resizeTextarea = useCallback(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_HEIGHT)}px` }, []) useEffect(() => { if (!isActive) return const handler = (e: MouseEvent | TouchEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsActive(false) } } document.addEventListener('mousedown', handler) document.addEventListener('touchstart', handler) return () => { document.removeEventListener('mousedown', handler) document.removeEventListener('touchstart', handler) } }, [isActive]) function handleImageUpload(e: React.ChangeEvent) { const files = e.target.files if (!files) return for (let i = 0; i < files.length; i++) { const reader = new FileReader() reader.onload = (ev) => { if (ev.target?.result) { setImages((prev) => [...prev, ev.target!.result as string]) } } reader.readAsDataURL(files[i]) } e.target.value = '' } function handleSend() { if (!canSend) return setMessage('') setImages([]) if (textareaRef.current) { textareaRef.current.style.height = 'auto' } } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() } } const springOrFade = reducedMotion ? { duration: 0.15 } : { type: 'spring' as const, stiffness: 350, damping: 28 } return (
{} {}
{}
{}
{}
{}