import { addEventListeners, css, on, ref, type Handle } from '@remix-run/ui' import { dragVelocityEvents } from './drag-release.ts' import { spring, type SpringPreset } from '@remix-run/ui/animation' interface TrailPoint { x: number y: number time: number } function PointerTrail(handle: Handle) { let canvas: HTMLCanvasElement let points: TrailPoint[] = [] let isDown = false let releaseTime = 0 let animationId: number | null = null let maxAge = 150 // ms - how long points stay in the trail while dragging let fadeDuration = 800 // ms - how long the trail fades after release function draw() { if (!canvas) return let ctx = canvas.getContext('2d') if (!ctx) return let now = performance.now() ctx.clearRect(0, 0, canvas.width, canvas.height) // Calculate overall opacity based on release time let overallOpacity = 1 if (!isDown && releaseTime > 0) { let elapsed = now - releaseTime overallOpacity = Math.max(0, 1 - elapsed / fadeDuration) if (overallOpacity <= 0) { points = [] animationId = null return } } // Filter out old points while dragging if (isDown) { points = points.filter((p) => now - p.time < maxAge) } if (points.length < 2) { if (isDown || overallOpacity > 0) { animationId = requestAnimationFrame(draw) } else { animationId = null } return } // Draw the trail as a tapered path ctx.beginPath() ctx.lineCap = 'round' ctx.lineJoin = 'round' for (let i = 1; i < points.length; i++) { let prev = points[i - 1] let curr = points[i] // Age-based opacity (older = more transparent) let age = isDown ? (now - prev.time) / maxAge : 0 let segmentOpacity = (1 - age) * overallOpacity // Thickness tapers from thin (old) to thick (new) let progress = i / (points.length - 1) let thickness = 2 + progress * 8 ctx.beginPath() ctx.moveTo(prev.x, prev.y) ctx.lineTo(curr.x, curr.y) ctx.strokeStyle = `rgba(14, 165, 233, ${segmentOpacity * 0.6})` ctx.lineWidth = thickness ctx.stroke() } // Draw a red glow at the end to show direction if (points.length > 0) { let last = points[points.length - 1] let gradient = ctx.createRadialGradient(last.x, last.y, 0, last.x, last.y, 20) gradient.addColorStop(0, `rgba(239, 68, 68, ${overallOpacity * 0.6})`) gradient.addColorStop(1, 'rgba(239, 68, 68, 0)') ctx.fillStyle = gradient ctx.beginPath() ctx.arc(last.x, last.y, 20, 0, Math.PI * 2) ctx.fill() } animationId = requestAnimationFrame(draw) } function startDrawing() { if (!animationId) { animationId = requestAnimationFrame(draw) } } if (typeof document !== 'undefined') { addEventListeners(document, handle.signal, { pointerdown(event) { if (!(event.target as HTMLElement).closest('.draggable')) return isDown = true releaseTime = 0 points = [{ x: event.clientX, y: event.clientY, time: performance.now() }] startDrawing() }, pointermove(event) { if (!isDown) return points.push({ x: event.clientX, y: event.clientY, time: performance.now() }) }, pointerup() { if (!isDown) return isDown = false releaseTime = performance.now() }, pointercancel() { isDown = false releaseTime = performance.now() }, }) } return () => ( { canvas = node canvas.width = window.innerWidth canvas.height = window.innerHeight }), css({ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 5, }), ]} /> ) } /** * @name Spring Drag Release * @description A full-screen spring physics playground with click targets and drag-release velocity. */ export default function SpringDemo(handle: Handle) { let viewportWidth = globalThis.window?.innerWidth ?? 1000 let viewportHeight = globalThis.window?.innerHeight ?? 700 // Target circle position (click to move) let targetX = viewportWidth / 2 let targetY = viewportHeight / 2 // Draggable circle state let dragX = viewportWidth / 2 - 150 let dragY = viewportHeight / 2 let isDragging = false let isAnimating = false // Current spring transitions (separate for X and Y to capture 2D velocity) let transitionX = '' let transitionY = '' let selectedPreset: SpringPreset = 'bouncy' // Get default spring transition for target circle let springValue = spring(selectedPreset) if (typeof document !== 'undefined') { addEventListeners(document, handle.signal, { click(event) { // Ignore clicks on controls or when dragging if ((event.target as HTMLElement).closest('.controls')) return if ((event.target as HTMLElement).closest('.draggable')) return targetX = event.clientX targetY = event.clientY handle.update() }, }) } return () => (
{/* Pointer trail */} {/* Target circle */}
{/* Draggable circle */}
{ isAnimating = false handle.update() }), on('pointerdown', (event) => { event.preventDefault() isDragging = true isAnimating = false handle.update() }), on('pointermove', (event) => { if (!isDragging) return dragX = event.clientX dragY = event.clientY handle.update() }), dragVelocityEvents(), on(dragVelocityEvents.release, (event) => { isDragging = false // Calculate distance to target on each axis let distX = targetX - dragX let distY = targetY - dragY if (Math.abs(distX) < 1 && Math.abs(distY) < 1) { handle.update() return } // Create separate springs for X and Y with their own normalized velocities // normalizedVelocity = velocity / distance (with sign!) for each axis // The sign matters: positive = moving toward target, negative = moving away if (Math.abs(distX) >= 1) { let normalizedVelocityX = event.velocityX / distX normalizedVelocityX = Math.max(-20, Math.min(20, normalizedVelocityX)) transitionX = String(spring(selectedPreset, { velocity: normalizedVelocityX })) } else { transitionX = String(spring(selectedPreset)) } if (Math.abs(distY) >= 1) { let normalizedVelocityY = event.velocityY / distY normalizedVelocityY = Math.max(-20, Math.min(20, normalizedVelocityY)) transitionY = String(spring(selectedPreset, { velocity: normalizedVelocityY })) } else { transitionY = String(spring(selectedPreset)) } // Animate to target isAnimating = true dragX = targetX dragY = targetY handle.update() }), ]} style={{ left: `${dragX}px`, top: `${dragY}px`, cursor: isDragging ? 'grabbing' : 'grab', transition: isAnimating ? `left ${transitionX}, top ${transitionY}` : 'none', }} /> {/* Controls */}
{(Object.keys(spring.presets) as SpringPreset[]).map((preset) => ( ))}
{/* Instructions */}
Click to move target • Drag the blue circle and release to see velocity-based spring
) }