Subframe
Animation & motionDraggable physics stickers

Sticker gravity

Eight graphic club seals tumble into a shallow tray and collide as individual draggable bodies.

Drag and release a seal, or pause and arrange it with the keyboard.

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"

function useReduced() {
  const [reduced, setReduced] = useState(false)
  useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setReduced(q.matches)
    sync()
    q.addEventListener("change", sync)
    return () => q.removeEventListener("change", sync)
  }, [])
  return reduced
}

type Disk = { x: number; y: number; vx: number; vy: number; r: number; angle: number; spin: number }
const marks = [
  ["#f14724", "#fff5d0", "NORTH"],
  ["#194df1", "#fff", "↗"],
  ["#f3d331", "#171c2c", "RUN"],
  ["#f2f1e6", "#191b22", "08"],
  ["#131f40", "#f0f5da", "SIDE B"],
  ["#ed474a", "#fff", "↗"],
  ["#d6dede", "#131f40", "MILE"],
  ["#fff5ca", "#df3b21", "GO"],
]
export default function StickerGravity({ active = true }: { active?: boolean }) {
  const canvas = useRef<HTMLCanvasElement>(null),
    [playing, setPlaying] = useState(true),
    [selected, setSelected] = useState(0),
    reduced = useReduced(),
    live = useRef({ active, playing, reduced }),
    control = useRef<{
      wake: () => void
      stop: () => void
      reset: () => void
      move: (dx: number, dy: number) => void
      select: (index: number) => void
    } | null>(null)
  live.current = { active, playing, reduced }
  useEffect(() => {
    const c = canvas.current!,
      ctx = c.getContext("2d")
    if (!ctx) return
    let w = 600,
      h = 300,
      raf = 0,
      last = 0,
      accumulator = 0,
      settledFrames = 0,
      disks: Disk[] = [],
      held = -1,
      pointer = -1,
      grab = { x: 0, y: 0 },
      before: Disk | null = null,
      chosen = 0
    const draw = () => {
      ctx.clearRect(0, 0, w, h)
      for (let i = 0; i < disks.length; i++) {
        const d = disks[i],
          m = marks[i]
        ctx.save()
        ctx.translate(d.x, d.y)
        ctx.rotate(d.angle)
        ctx.shadowColor = "#19342525"
        ctx.shadowBlur = 3
        ctx.shadowOffsetY = 2
        ctx.fillStyle = m[0]
        ctx.beginPath()
        ctx.arc(0, 0, d.r - 1, 0, Math.PI * 2)
        ctx.fill()
        ctx.shadowColor = "transparent"
        ctx.lineWidth = 1
        ctx.strokeStyle = m[1] + "99"
        ctx.beginPath()
        ctx.arc(0, 0, d.r - 5, 0, Math.PI * 2)
        ctx.stroke()
        ctx.fillStyle = m[1]
        ctx.textAlign = "center"
        ctx.textBaseline = "middle"
        ctx.font = `${m[2] === "↗" ? "normal" : "bold"} ${
          d.r * (m[2] === "↗" ? 1.4 : m[2].length > 4 ? 0.39 : 0.53)
        }px Arial`
        ctx.fillText(m[2], 0, 0)
        ctx.restore()
      }
      if (held >= 0 || document.activeElement === c) {
        const d = disks[held >= 0 ? held : chosen]
        ctx.strokeStyle = "#151e44"
        ctx.setLineDash([2, 3])
        ctx.beginPath()
        ctx.arc(d.x, d.y, d.r + 5, 0, Math.PI * 2)
        ctx.stroke()
        ctx.setLineDash([])
      }
    }
    const init = () => {
      const radius = Math.max(21, Math.min(34, w / 9.5))
      disks = marks.map((_, i) => ({
        x: w * 0.15 + (i % 4) * ((w * 0.7) / 3),
        y: h * 0.17 + Math.floor(i / 4) * radius * 2.4,
        vx: (i % 2 ? 0.7 : -0.7) * 40,
        vy: 0,
        r: radius,
        angle: (i - 3) * 0.17,
        spin: i % 2 ? 0.2 : -0.2,
      }))
      draw()
    }
    const resize = () => {
      const oldW = w,
        oldH = h
      w = c.clientWidth
      h = c.clientHeight
      const d = Math.min(devicePixelRatio, 2)
      const display = c.getBoundingClientRect()
      c.width = Math.max(1, Math.round(display.width * d))
      c.height = Math.max(1, Math.round(display.height * d))
      ctx.setTransform(c.width / Math.max(1, w), 0, 0, c.height / Math.max(1, h), 0, 0)
      if (disks.length) {
        disks.forEach((p) => {
          p.x = Math.max(p.r, Math.min(w - p.r, (p.x / oldW) * w))
          p.y = Math.max(p.r, Math.min(h - p.r, (p.y / oldH) * h))
        })
        draw()
      } else init()
    }
    const solve = (dt: number) => {
      for (let i = 0; i < disks.length; i++) {
        const d = disks[i]
        if (i === held) continue
        d.vy += 590 * dt
        d.vx *= 0.998
        d.vy *= 0.998
        d.x += d.vx * dt
        d.y += d.vy * dt
        d.angle += d.spin * dt
        d.spin *= 0.998
        if (d.x < d.r) {
          d.x = d.r
          d.vx = Math.abs(d.vx) * 0.62
        }
        if (d.x > w - d.r) {
          d.x = w - d.r
          d.vx = -Math.abs(d.vx) * 0.62
        }
        if (d.y < d.r) {
          d.y = d.r
          d.vy = Math.abs(d.vy) * 0.5
        }
        if (d.y > h - d.r) {
          d.y = h - d.r
          d.vy = -Math.abs(d.vy) * 0.47
          d.vx *= 0.975
          d.spin = (d.vx / d.r) * 0.5
        }
      }
      for (let pass = 0; pass < 3; pass++)
        for (let i = 0; i < disks.length; i++)
          for (let j = i + 1; j < disks.length; j++) {
            const a = disks[i],
              b = disks[j],
              dx = b.x - a.x,
              dy = b.y - a.y,
              len = Math.hypot(dx, dy) || 0.001,
              overlap = a.r + b.r - len
            if (overlap <= 0) continue
            const nx = dx / len,
              ny = dy / len,
              ia = i === held ? 0 : 1,
              ib = j === held ? 0 : 1,
              total = ia + ib
            if (!total) continue
            a.x -= (nx * overlap * ia) / total
            a.y -= (ny * overlap * ia) / total
            b.x += (nx * overlap * ib) / total
            b.y += (ny * overlap * ib) / total
            const approach = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny
            if (approach < 0) {
              const impulse = (-1.45 * approach) / total
              a.vx -= impulse * nx * ia
              a.vy -= impulse * ny * ia
              b.vx += impulse * nx * ib
              b.vy += impulse * ny * ib
              const tangent = (b.vx - a.vx) * -ny + (b.vy - a.vy) * nx
              a.spin += (tangent / a.r) * 0.012
              b.spin -= (tangent / b.r) * 0.012
            }
          }
    }
    const tick = (time: number) => {
      raf = 0
      if (!live.current.active || !live.current.playing || live.current.reduced || document.hidden) return
      accumulator += Math.min(0.045, (time - last) / 1000 || 0.016)
      last = time
      while (accumulator >= 1 / 120) {
        solve(1 / 120)
        accumulator -= 1 / 120
      }
      for (const d of disks) {
        d.x = Math.max(d.r, Math.min(w - d.r, d.x))
        d.y = Math.max(d.r, Math.min(h - d.r, d.y))
      }
      draw()
      const still = held < 0 && disks.every((d) => Math.abs(d.vx) < 2 && Math.abs(d.vy) < 3 && Math.abs(d.spin) < 0.04)
      settledFrames = still ? settledFrames + 1 : 0
      if (settledFrames < 50) raf = requestAnimationFrame(tick)
    }
    const wake = () => {
      if (!raf && live.current.active && live.current.playing && !live.current.reduced && !document.hidden) {
        last = 0
        accumulator = 0
        settledFrames = 0
        raf = requestAnimationFrame(tick)
      }
    }
    const stop = () => {
      cancelAnimationFrame(raf)
      raf = 0
      last = 0
    }
    const pos = (e: PointerEvent) => {
      const r = c.getBoundingClientRect()
      return { x: ((e.clientX - r.left) / r.width) * w, y: ((e.clientY - r.top) / r.height) * h }
    }
    const down = (e: PointerEvent) => {
      if (e.button !== 0) return
      const p = pos(e)
      held = -1
      for (let i = disks.length - 1; i >= 0; i--) {
        if (Math.hypot(disks[i].x - p.x, disks[i].y - p.y) < disks[i].r) {
          held = i
          break
        }
      }
      if (held < 0) return
      before = { ...disks[held] }
      chosen = held
      setSelected(held)
      pointer = e.pointerId
      c.setPointerCapture(pointer)
      grab = { x: p.x - disks[held].x, y: p.y - disks[held].y }
      draw()
      wake()
    }
    const move = (e: PointerEvent) => {
      if (held < 0 || pointer !== e.pointerId) return
      const p = pos(e),
        d = disks[held],
        x = Math.max(d.r, Math.min(w - d.r, p.x - grab.x)),
        y = Math.max(d.r, Math.min(h - d.r, p.y - grab.y))
      d.vx = Math.max(-500, Math.min(500, (x - d.x) * 25))
      d.vy = Math.max(-500, Math.min(500, (y - d.y) * 25))
      d.x = x
      d.y = y
      draw()
    }
    const up = (e: PointerEvent) => {
      if (e.pointerId !== pointer) return
      if (e.type === "pointercancel" && held >= 0 && before) disks[held] = before
      held = -1
      pointer = -1
      before = null
      draw()
      wake()
    }
    const visibility = () => (document.hidden ? stop() : wake())
    let resizeFrame = 0
    const scheduleResize = () => {
      cancelAnimationFrame(resizeFrame)
      // Ancestor transforms can settle after layout observers have run.
      resizeFrame = requestAnimationFrame(() => {
        resizeFrame = requestAnimationFrame(resize)
      })
    }
    const transforms = new MutationObserver(scheduleResize)
    for (let ancestor = c.parentElement; ancestor; ancestor = ancestor.parentElement) {
      transforms.observe(ancestor, { attributes: true, attributeFilter: ["style", "class"] })
    }
    window.addEventListener("resize", scheduleResize)
    const ro = new ResizeObserver(resize)
    ro.observe(c)
    resize()
    c.addEventListener("focus", draw)
    c.addEventListener("blur", draw)
    c.addEventListener("pointerdown", down)
    c.addEventListener("pointermove", move)
    c.addEventListener("pointerup", up)
    c.addEventListener("pointercancel", up)
    document.addEventListener("visibilitychange", visibility)
    control.current = {
      wake,
      stop,
      select: (index) => {
        chosen = index
        setSelected(index)
        draw()
      },
      reset: () => {
        init()
        wake()
      },
      move: (dx, dy) => {
        const d = disks[chosen]
        d.x = Math.max(d.r, Math.min(w - d.r, d.x + dx))
        d.y = Math.max(d.r, Math.min(h - d.r, d.y + dy))
        d.vx = 0
        d.vy = 0
        draw()
      },
    }
    wake()
    return () => {
      stop()
      ro.disconnect()
      transforms.disconnect()
      window.removeEventListener("resize", scheduleResize)
      cancelAnimationFrame(resizeFrame)
      c.removeEventListener("focus", draw)
      c.removeEventListener("blur", draw)
      c.removeEventListener("pointerdown", down)
      c.removeEventListener("pointermove", move)
      c.removeEventListener("pointerup", up)
      c.removeEventListener("pointercancel", up)
      document.removeEventListener("visibilitychange", visibility)
      control.current = null
    }
  }, [])
  useEffect(() => {
    if (active && playing && !reduced) control.current?.wake()
    else control.current?.stop()
  }, [active, playing, reduced])
  return (
    <section className="sf-sticker-gravity" inert={!active}>
      <header>
        <b>MILE CLUB</b>
        <span>Stickers / set 08</span>
      </header>
      <canvas
        ref={canvas}
        tabIndex={0}
        role="img"
        aria-label={`Eight movable club stickers. Drag to throw. Space selects a sticker. Arrow keys move the selected ${marks[selected][2]} sticker.`}
        onKeyDown={(e) => {
          if (e.key === " ") {
            e.preventDefault()
            setPlaying(false)
            control.current?.select((selected + 1) % marks.length)
          }
          if (e.key.startsWith("Arrow")) {
            e.preventDefault()
            setPlaying(false)
            control.current?.move(
              e.key === "ArrowLeft" ? -10 : e.key === "ArrowRight" ? 10 : 0,
              e.key === "ArrowUp" ? -10 : e.key === "ArrowDown" ? 10 : 0,
            )
          }
        }}
      />
      <footer>
        <button type="button" onClick={() => setPlaying(!playing)} disabled={reduced}>
          {playing && !reduced ? "Pause" : "Play"}
        </button>
        <span>{reduced ? "Motion reduced" : "8 stickers"}</span>
        <button type="button" onClick={() => control.current?.reset()}>
          Reset ↺
        </button>
      </footer>
      <style>{`.sf-sticker-gravity{width:100%;height:100%;box-sizing:border-box;background:#f6f7f2;color:#141d34;display:flex;flex-direction:column;overflow:hidden;font-family:Arial,sans-serif}.sf-sticker-gravity header{height:50px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;padding:0 22px;border-bottom:1px solid #d9ded5;font-size:10px}.sf-sticker-gravity header b{font-weight:900;letter-spacing:-.06em;font-size:19px;font-stretch:condensed}.sf-sticker-gravity canvas{display:block;flex:1;min-height:0;width:100%;touch-action:none;cursor:grab}.sf-sticker-gravity canvas:active{cursor:grabbing}.sf-sticker-gravity footer{height:45px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-top:1px solid #d9ded5}.sf-sticker-gravity footer span{font:9px Arial;color:#888f83}.sf-sticker-gravity button{border:0;background:none;min-height:44px;padding:0 7px;font:11px Arial;color:inherit;cursor:pointer}.sf-sticker-gravity button:disabled{opacity:.4;cursor:default}.sf-sticker-gravity button:focus-visible,.sf-sticker-gravity canvas:focus-visible{outline:2px solid #234cdb;outline-offset:-3px}`}</style>
    </section>
  )
}

Where it works well

Turn a small set of circular badges into a playful physical arrangement.

Reduced motion

The simulation begins paused; badges can still be placed directly with pointer or keyboard controls.

Things to know

Eight bounded circular bodies with a fixed-step impulse solver. This is intentionally not general polygon physics.

More examples