Animation & motionDrag-and-release physics ball
Pull and release
A satin sphere and its ground shadow turn a bounded pull into a finite rebound.

Pull the ball, then release
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
*
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import { useEffect, useRef, useState } from "react"
export default function InertialLauncher({ active = true }: { active?: boolean }) {
const [pull, setPull] = useState({ x: 0, y: 0 }),
[dragging, setDragging] = useState(false)
const [scale, setScale] = useState({ x: 1, y: 1 })
const [reduced, setReduced] = useState(false)
const ref = useRef<HTMLDivElement>(null),
ball = useRef<HTMLButtonElement>(null),
shadow = useRef<HTMLDivElement>(null),
shadowAnimation = useRef<Animation | null>(null),
animation = useRef<Animation | null>(null),
position = useRef({ x: 0, y: 0 }),
grab = useRef<{ pointerId: number; x: number; y: number; start: { x: number; y: number } } | null>(null)
useEffect(() => {
const q = matchMedia("(prefers-reduced-motion: reduce)"),
sync = () => {
setReduced(q.matches)
if (q.matches) {
animation.current?.cancel()
shadowAnimation.current?.cancel()
}
}
sync()
q.addEventListener("change", sync)
return () => q.removeEventListener("change", sync)
}, [])
useEffect(() => {
if (active) {
if (animation.current?.playState === "paused") animation.current.play()
if (shadowAnimation.current?.playState === "paused") shadowAnimation.current.play()
} else {
animation.current?.pause()
shadowAnimation.current?.pause()
}
}, [active])
useEffect(
() => () => {
animation.current?.cancel()
shadowAnimation.current?.cancel()
},
[],
)
const launch = (offset = position.current) => {
setDragging(false)
grab.current = null
position.current = { x: 0, y: 0 }
setPull(position.current)
setScale({ x: 1, y: 1 })
animation.current?.cancel()
shadowAnimation.current?.cancel()
if (!active || reduced) return
const horizontalLimit = Math.max(0, Math.min(105, ((ref.current?.clientWidth ?? 320) - 84) / 2 - 12))
const x = Math.max(-horizontalLimit, Math.min(horizontalLimit, -offset.x * 1.35))
const height = 42 + Math.abs(offset.y) * 0.8
const frames = [
{ x: offset.x, y: offset.y, sx: scale.x, sy: scale.y, at: 0, ease: "cubic-bezier(.2,.7,.35,1)" },
{ x, y: -height, sx: 0.99, sy: 1.01, at: 0.29, ease: "cubic-bezier(.55,0,.9,.45)" },
{ x: x * 0.65, y: 0, sx: 1.08, sy: 0.92, at: 0.53, ease: "cubic-bezier(.2,.7,.35,1)" },
{ x: x * 0.35, y: -height * 0.3, sx: 0.99, sy: 1.01, at: 0.7, ease: "cubic-bezier(.55,0,.9,.45)" },
{ x: x * 0.12, y: 0, sx: 1.025, sy: 0.975, at: 0.85, ease: "cubic-bezier(.2,.7,.35,1)" },
{ x: 0, y: 0, sx: 1, sy: 1, at: 1, ease: "linear" },
]
animation.current =
ball.current?.animate(
frames.map((frame) => ({
transform: `translate(${frame.x}px,${frame.y}px) scale(${frame.sx},${frame.sy})`,
offset: frame.at,
easing: frame.ease,
})),
{ duration: 1100 },
) ?? null
shadowAnimation.current =
shadow.current?.animate(
frames.map((frame) => ({
transform: `translateX(${frame.x}px) scale(${1 + Math.max(0, -frame.y) / 140})`,
opacity: 1 - Math.min(0.65, Math.max(0, -frame.y) / 140),
offset: frame.at,
easing: frame.ease,
})),
{ duration: 1100 },
) ?? null
}
return (
<div className="sf-launch" ref={ref} data-drag={dragging}>
<style>{`
.sf-launch { position:relative; height:100%; min-height:300px; overflow:hidden; display:grid; place-items:center; background:#f7f6f3; font:12px Arial,sans-serif; }
.sf-launch * { box-sizing:border-box; }
.sf-launch-shadow { position:absolute; left:calc(50% - 37px); top:calc(50% + 39px); width:74px; height:8px; border-radius:50%; background:#4a362623; filter:blur(5px); pointer-events:none; }
.sf-launch-shadow:before { content:""; position:absolute; inset:-7px -15px; border-radius:50%; background:#60422a14; filter:blur(7px); }
.sf-launch-shadow:after { content:""; position:absolute; inset:2px 13px; border-radius:50%; background:#311c184d; filter:blur(1px); }
.sf-launch-ball { position:relative; width:84px; height:84px; padding:0; border:0; border-radius:50%; background:radial-gradient(ellipse at 29% 19%,#ffbb94 0%,#f89c71 13%,#e46640 39%,#bc3f29 65%,#8c2b21 87%,#6f2922 100%); box-shadow:inset -3px -4px 5px #5c211a40,inset 1px 1px 2px #ffcf9c88; cursor:grab; touch-action:none; -webkit-tap-highlight-color:transparent; }
.sf-launch-ball:before { content:""; position:absolute; top:10px; left:17px; width:26px; height:12px; border-radius:50%; transform:rotate(-35deg); background:linear-gradient(150deg,#ffe7c8b3,#ffe0bd00 85%); filter:blur(1.5px); }
.sf-launch-ball:after { content:""; position:absolute; inset:1px; border-radius:50%; box-shadow:inset 0 -2px 3px #ffe4c026; background:radial-gradient(ellipse at 69% 81%,#ffb27714,transparent 42%); }
.sf-launch-ball:focus-visible { outline:2px solid #ac482b; outline-offset:8px; }
.sf-launch[data-drag=true] .sf-launch-ball { cursor:grabbing; }
`}</style>
<div
ref={shadow}
className="sf-launch-shadow"
aria-hidden="true"
style={{
transform: `translateX(${pull.x}px) scale(${1 + Math.max(0, -pull.y) / 140})`,
opacity: 1 - Math.min(0.65, Math.max(0, -pull.y) / 140),
}}
/>
<button
ref={ball}
type="button"
className="sf-launch-ball"
aria-label="Pull and release the ball. Press Enter to bounce."
style={{ transform: `translate(${pull.x}px,${pull.y}px) scale(${scale.x},${scale.y})` }}
onPointerDown={(e) => {
if (grab.current) return
const matrix = new DOMMatrixReadOnly(getComputedStyle(e.currentTarget).transform)
const start = { x: matrix.m41, y: matrix.m42 }
position.current = start
grab.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, start }
setPull(start)
setScale({ x: matrix.m11, y: matrix.m22 })
animation.current?.cancel()
shadowAnimation.current?.cancel()
animation.current = null
shadowAnimation.current = null
setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
const gesture = grab.current
if (!gesture || gesture.pointerId !== e.pointerId) return
const { start } = gesture
const area = ref.current!
const bounds = area.getBoundingClientRect()
const dx = ((e.clientX - gesture.x) * area.clientWidth) / bounds.width
const dy = ((e.clientY - gesture.y) * area.clientHeight) / bounds.height
position.current = {
x: Math.max(Math.min(-65, start.x), Math.min(Math.max(65, start.x), start.x + dx)),
y: Math.max(Math.min(-65, start.y), Math.min(Math.max(65, start.y), start.y + dy)),
}
setPull(position.current)
}}
onPointerUp={(e) => {
if (grab.current?.pointerId === e.pointerId) launch()
}}
onPointerCancel={() => {
grab.current = null
position.current = { x: 0, y: 0 }
setDragging(false)
setPull(position.current)
setScale({ x: 1, y: 1 })
}}
onLostPointerCapture={() => {
if (grab.current) {
grab.current = null
position.current = { x: 0, y: 0 }
setDragging(false)
setPull(position.current)
setScale({ x: 1, y: 1 })
}
}}
onClick={(e) => {
if (e.detail === 0) {
launch({ x: 0, y: 0 })
}
}}
/>
</div>
)
}
Where it works well
Playful direct manipulation and a tangible release response.
Reduced motion
Releasing returns directly to rest with no flight.
Things to know
Authored bounded rebound keyframes, not a general physics engine.