Subframe
Animation & motionPhysics lanyard badge

Pinned pass

A numbered athletics pass swings from a constrained cord and keeps its weight when released.

Drag the pass and 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"

function useCalm(active: boolean) {
  const [reduced, setReduced] = useState(true)
  useEffect(() => {
    const media = matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setReduced(media.matches)
    sync()
    media.addEventListener("change", sync)
    return () => media.removeEventListener("change", sync)
  }, [])
  return reduced || !active
}

type Knot = { x: number; y: number; px: number; py: number }
export default function PinnedPass({ active = true }: { active?: boolean }) {
  const calm = useCalm(active),
    root = useRef<HTMLDivElement>(null),
    badge = useRef<HTMLButtonElement>(null),
    cord = useRef<SVGPathElement>(null),
    knots = useRef<Knot[]>([]),
    dimensions = useRef({ width: 0, height: 0 }),
    drag = useRef<{ id: number; dx: number; dy: number; x: number; y: number } | null>(null),
    start = useRef<() => void>(() => {}),
    paint = useRef<() => void>(() => {}),
    [back, setBack] = useState(false)
  useEffect(() => {
    const el = root.current,
      card = badge.current,
      path = cord.current
    if (!el || !card || !path) return
    let raf = 0,
      count = 0,
      previous = 0,
      accumulator = 0,
      w = el.clientWidth,
      h = el.clientHeight,
      length = 0
    const draw = () => {
      const ps = knots.current,
        p = ps[ps.length - 1],
        a = ps[ps.length - 2]
      if (!p) return
      path.setAttribute("d", ps.map((v, i) => `${i ? "L" : "M"}${v.x} ${v.y}`).join(" "))
      const angle = Math.max(-42, Math.min(42, (Math.atan2(p.x - a.x, p.y - a.y) * -180) / Math.PI))
      card.style.transform = `translate(${p.x - 58}px,${p.y + 4}px) rotate(${angle}deg)`
    }
    const reset = () => {
      w = el.clientWidth
      h = el.clientHeight
      length = Math.max(75, h - 206)
      if (dimensions.current.width === w && dimensions.current.height === h && knots.current.length) {
        draw()
        return
      }
      dimensions.current = { width: w, height: h }
      knots.current = Array.from({ length: 9 }, (_, i) => ({
        x: w / 2,
        y: 10 + (i * length) / 8,
        px: w / 2,
        py: 10 + (i * length) / 8,
      }))
      draw()
    }
    const simulate = () => {
      const ps = knots.current,
        segment = length / 8
      for (let i = 1; i < ps.length; i++) {
        const p = ps[i],
          vx = (p.x - p.px) * 0.973,
          vy = (p.y - p.py) * 0.973
        p.px = p.x
        p.py = p.y
        p.x += vx
        p.y += vy + 0.22
      }
      for (let k = 0; k < 12; k++) {
        ps[0].x = w / 2
        ps[0].y = 10
        if (drag.current) {
          const d = drag.current,
            p = ps[8]
          p.x = Math.max(60, Math.min(w - 60, d.x))
          p.y = Math.max(30, Math.min(h - 184, d.y))
        }
        for (let i = 0; i < 8; i++) {
          const a = ps[i],
            b = ps[i + 1],
            dx = b.x - a.x,
            dy = b.y - a.y,
            d = Math.hypot(dx, dy) || 1,
            correction = ((d - segment) / d) * 0.5
          if (i) {
            a.x += dx * correction
            a.y += dy * correction
          }
          if (!(drag.current && i === 7)) {
            b.x -= dx * correction
            b.y -= dy * correction
          }
        }
      }
      draw()
    }
    const frame = (time: number) => {
      raf = 0
      accumulator += previous ? Math.min(50, time - previous) : 1000 / 60
      previous = time
      while (accumulator >= 1000 / 60) {
        simulate()
        count++
        accumulator -= 1000 / 60
      }
      if (!document.hidden && (drag.current || count < 220)) raf = requestAnimationFrame(frame)
    }
    const kick = () => {
      cancelAnimationFrame(raf)
      count = 0
      previous = 0
      accumulator = 0
      if (calm) {
        if (drag.current) {
          const d = drag.current
          knots.current.forEach((p, i) => {
            p.x = w / 2 + ((d.x - w / 2) * i) / 8
            p.y = 10 + ((d.y - 10) * i) / 8
            p.px = p.x
            p.py = p.y
          })
        }
        draw()
      } else if (!document.hidden) raf = requestAnimationFrame(frame)
    }
    paint.current = () => {
      cancelAnimationFrame(raf)
      raf = 0
      draw()
    }
    start.current = kick
    const ro = new ResizeObserver(reset)
    ro.observe(el)
    reset()
    const visibility = () => {
      if (document.hidden) cancelAnimationFrame(raf)
      else if (!calm) kick()
    }
    document.addEventListener("visibilitychange", visibility)
    return () => {
      cancelAnimationFrame(raf)
      ro.disconnect()
      document.removeEventListener("visibilitychange", visibility)
      start.current = () => {}
      paint.current = () => {}
    }
  }, [calm])
  return (
    <section className="sf-pinned-pass" ref={root} aria-label="Hanging event pass">
      <style>{`.sf-pinned-pass{width:100%;height:100%;min-height:280px;position:relative;overflow:hidden;background:#c92720;color:#fff8dc;font-family:Arial,sans-serif}.sf-pinned-pass *{box-sizing:border-box}.sf-pinned-pass .pp-cord{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.sf-pinned-pass .pp-card{position:absolute;left:0;top:0;width:116px;height:172px;border:0;padding:6px;border-radius:5px;background:linear-gradient(140deg,#eee7d2,#d3c9ac);color:#17291c;box-shadow:0 10px 22px #65151050;cursor:grab;touch-action:none;transform-origin:58px -4px;text-align:left}.sf-pinned-pass .pp-card:before{content:'';position:absolute;top:-9px;left:50px;width:16px;height:16px;border:3px solid #e3ddc9;border-radius:50%;background:#413a27;box-shadow:inset 1px 1px 2px #0008}.sf-pinned-pass .pp-card:focus-visible{outline:2px solid white;outline-offset:5px}.sf-pinned-pass .pp-inner{height:100%;background:#f2eedb;padding:9px;display:flex;flex-direction:column;overflow:hidden}.sf-pinned-pass .pp-inner small{font-size:7px;font-weight:700;letter-spacing:.1em}.sf-pinned-pass .pp-number{font-family:Impact,'Arial Narrow',sans-serif;font-size:70px;line-height:.98;letter-spacing:-.07em;margin:10px 0 8px}.sf-pinned-pass .pp-inner strong{font-size:11px;line-height:1.05}.sf-pinned-pass .pp-stripes{height:14px;width:100%;margin-top:auto;background:repeating-linear-gradient(90deg,#1b2d20 0 1px,transparent 1px 3px,#1b2d20 3px 5px,transparent 5px 7px)}.sf-pinned-pass .pp-bottom{position:absolute;bottom:14px;left:20px;right:20px;display:flex;align-items:center;justify-content:space-between;font-size:10px;letter-spacing:.15em}.sf-pinned-pass .pp-bottom button{border:1px solid #fff6;background:transparent;color:inherit;min-height:44px;padding:0 13px;font:inherit;cursor:pointer}.sf-pinned-pass .pp-bottom button:focus-visible{outline:2px solid #fff;outline-offset:3px}`}</style>
      <svg className="pp-cord">
        <path ref={cord} fill="none" stroke="#281d19" strokeWidth="6" />
        <path d="M0 9H600" stroke="#a31d18" strokeWidth="1" />
      </svg>
      <button
        ref={badge}
        className="pp-card"
        aria-label="Drag event pass; use arrow keys to move it"
        onPointerDown={(e) => {
          if (e.button !== 0) return
          const element = root.current!
          const r = element.getBoundingClientRect(),
            p = knots.current[8]
          drag.current = {
            id: e.pointerId,
            dx: ((e.clientX - r.left) * element.clientWidth) / r.width - p.x,
            dy: ((e.clientY - r.top) * element.clientHeight) / r.height - p.y,
            x: p.x,
            y: p.y,
          }
          e.currentTarget.setPointerCapture(e.pointerId)
        }}
        onPointerMove={(e) => {
          const d = drag.current
          if (!d || d.id !== e.pointerId) return
          const element = root.current!
          const r = element.getBoundingClientRect()
          d.x = ((e.clientX - r.left) * element.clientWidth) / r.width - d.dx
          d.y = ((e.clientY - r.top) * element.clientHeight) / r.height - d.dy
          start.current()
        }}
        onPointerUp={(e) => {
          drag.current = null
          if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
          start.current()
        }}
        onPointerCancel={() => {
          drag.current = null
          start.current()
        }}
        onKeyDown={(e) => {
          if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home"].includes(e.key)) {
            e.preventDefault()
            const el = root.current!
            const p = knots.current[8]
            const dx = e.key === "ArrowLeft" ? -15 : e.key === "ArrowRight" ? 15 : 0,
              dy = e.key === "ArrowUp" ? -12 : e.key === "ArrowDown" ? 12 : 0
            const tx = e.key === "Home" ? el.clientWidth / 2 : Math.max(60, Math.min(el.clientWidth - 60, p.x + dx)),
              ty = e.key === "Home" ? el.clientHeight - 196 : Math.max(35, Math.min(el.clientHeight - 184, p.y + dy))
            knots.current.forEach((v, i) => {
              v.x = el.clientWidth / 2 + ((tx - el.clientWidth / 2) * i) / 8
              v.y = 10 + ((ty - 10) * i) / 8
              v.px = v.x
              v.py = v.y
            })
            paint.current()
          }
        }}
      >
        <div className="pp-inner">
          {back ? (
            <>
              <small>RUN CLUB / 2026</small>
              <strong style={{ fontSize: 20, marginTop: 26 }}>
                TAKE THE
                <br />
                LONG WAY.
              </strong>
              <small style={{ marginTop: 17 }}>
                SATURDAY
                <br />
                07:00 / GATE B
              </small>
              <div className="pp-stripes" />
            </>
          ) : (
            <>
              <small>FIELD DAY / 2026</small>
              <div className="pp-number">08</div>
              <strong>
                ALL ACCESS
                <br />
                TRACK & FIELD
              </strong>
              <div className="pp-stripes" />
            </>
          )}
        </div>
      </button>
      <div className="pp-bottom">
        <span>FIELD DAY</span>
        <button onClick={() => setBack(!back)}>{back ? "Front" : "Reverse"} ↗</button>
      </div>
    </section>
  )
}

Where it works well

Add direct manipulation to a small event credential or collectible.

Reduced motion

The pass follows deliberate input without inertial playback; arrow keys place it immediately.

Things to know

A two-dimensional constrained cord and rigid card with approximate rotation; no cloth, collision or metal shading engine.

More examples