Spring Drag Release
A full-screen spring physics playground with click targets and drag-release velocity.
Click to move target • Drag the blue circle and release to see velocity-based spring
import { addEventListeners, css, on, ref, type Handle } from 'remix/ui'
import { dragVelocityEvents } from './drag-release.ts'
import { spring, type SpringPreset } from 'remix/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
mix={[
ref((node) => {
canvas = node
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}),
css({
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 5,
}),
]}
/>
)
}
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 () => (
<div
mix={[
css({
position: 'fixed',
inset: 0,
backgroundColor: '#1a1a2e',
cursor: 'crosshair',
overflow: 'hidden',
}),
]}
>
{/* Pointer trail */}
<PointerTrail />
{/* Target circle */}
<div
mix={[
css({
position: 'absolute',
width: '80px',
height: '80px',
borderRadius: '50%',
backgroundColor: 'transparent',
border: '3px dashed rgba(233, 69, 96, 0.5)',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
}),
]}
style={{
left: `${targetX}px`,
top: `${targetY}px`,
transition: `left ${springValue}, top ${springValue}`,
}}
/>
{/* Draggable circle */}
<div
className="draggable"
mix={[
css({
position: 'absolute',
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#0ea5e9',
boxShadow: '0 0 20px rgba(14, 165, 233, 0.5)',
transform: 'translate(-50%, -50%)',
userSelect: 'none',
touchAction: 'none',
zIndex: 10,
}),
on('transitionend', () => {
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 */}
<div
className="controls"
mix={[
css({
position: 'absolute',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '4px',
padding: '4px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
fontFamily: 'system-ui, sans-serif',
fontSize: '14px',
cursor: 'default',
}),
]}
>
{(Object.keys(spring.presets) as SpringPreset[]).map((preset) => (
<label
key={preset}
mix={[
css({
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: '6px',
color: '#fff',
cursor: 'pointer',
transition: 'background-color 150ms ease',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
'&:has(input:checked)': {
backgroundColor: 'rgba(14, 165, 233, 0.3)',
},
}),
]}
>
<input
type="radio"
name="spring-preset"
value={preset}
checked={selectedPreset === preset}
mix={[
css({ accentColor: '#0ea5e9' }),
on('change', () => {
selectedPreset = preset
springValue = spring(selectedPreset)
handle.update()
}),
]}
/>
{preset}
</label>
))}
</div>
{/* Instructions */}
<div
mix={[
css({
position: 'absolute',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
color: '#ffffff80',
fontFamily: 'system-ui, sans-serif',
fontSize: '14px',
textAlign: 'center',
}),
]}
>
Click to move target • Drag the blue circle and release to see
velocity-based spring
</div>
</div>
)
}