LAB_05 // LANYARD

LAN
YARD

Three.js // Verlet Rope // Drag The Card

The React Bits Lanyard, rebuilt for Vue without the physics engine or the GLB: the rope is a hand-rolled verlet chain, and the ID card is drawn live on a canvas in this site's own design. Grab it, throw it around, let it swing.

01

THE_BADGE

DRAG THE CARD

CARD CONFIGURATOR — everything below redraws the badge live

YOUR CONFIG [+]
<LabLanyard
  handle="SAMUEL_RATZEL" role="WEB_DEVELOPER"
  site="SRATZEL.DEV" email="contact@sratzel.dev" location="KARLSRUHE_DE"
  accent="#FF3B00" :gravity="32" :strap-width="0.26"
  strap-color="#FF3B00" strap-text="SRATZEL.DEV ///"
/>
THE WHOLE COMPONENT [+]
<template>
  <div ref="wrapRef" class="lanyard-wrapper">
    <canvas ref="canvasRef" class="lanyard-canvas"></canvas>
  </div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
import * as THREE from "three"
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"

// Vue take on React Bits' Lanyard (https://reactbits.dev/components/lanyard).
// No GLB, no physics engine: the card (rounded corners, punched slot) is
// generated on a canvas in the portfolio's design, the rope is a verlet chain,
// and dragging moves the whole card kinematically like the original.
// ponytail: verlet in the card plane plus a velocity-based yaw tilt — it swings
// and drags like the original, but cannot fully flip; swap in rapier for that.
const props = withDefaults(defineProps<{
  handle?: string
  role?: string
  site?: string
  email?: string
  photo?: string        // url, drawn grayscale on the card
  location?: string
  accent?: string
  gravity?: number
  strapWidth?: number
  strapColor?: string
  strapText?: string
}>(), {
  handle: "SAMUEL_RATZEL",
  role: "WEB_DEVELOPER",
  site: "SRATZEL.DEV",
  email: "contact@sratzel.dev",
  photo: "/image_me_512.jpg",
  location: "KARLSRUHE_DE",
  accent: "#FF3B00",
  gravity: 32,
  strapWidth: 0.26,
  strapColor: "#FF3B00",
  strapText: "SRATZEL.DEV ///",
})

const wrapRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)

/* card + rope dimensions (world units) */
const CARD_W = 1.35
const CARD_H = 1.9
const SEG = 0.55
const FIXED = new THREE.Vector3(0, 3.6, 0) // above the visible frame, like the original

/* ── verlet chain: fixed → 3 rope nodes → card top → card bottom ── */
type Node = { pos: THREE.Vector3; prev: THREE.Vector3; pinned: boolean }
let nodes: Node[] = []
const CONSTRAINTS: [number, number, number][] = [
  [0, 1, SEG], [1, 2, SEG], [2, 3, SEG], [3, 4, SEG], [4, 5, CARD_H],
]

let dragging = false
const dragTargetTop = new THREE.Vector3()
const dragTargetBottom = new THREE.Vector3()

function resetChain() {
  // start horizontal so the card drops and swings in
  nodes = []
  for (let i = 0; i < 6; i++) {
    const x = i <= 4 ? i * SEG : 4 * SEG
    const y = i === 5 ? FIXED.y - CARD_H : FIXED.y
    const p = new THREE.Vector3(FIXED.x + x, y, 0)
    nodes.push({ pos: p, prev: p.clone(), pinned: i === 0 })
  }
}

function simStep(dt: number) {
  const g = props.gravity
  for (const n of nodes) {
    if (n.pinned) continue
    const vx = (n.pos.x - n.prev.x) * 0.985
    const vy = (n.pos.y - n.prev.y) * 0.985
    n.prev.copy(n.pos)
    n.pos.x += vx
    n.pos.y += vy - g * dt * dt
  }
  for (let k = 0; k < 40; k++) {
    for (const [a, b, len] of CONSTRAINTS) {
      const na = nodes[a], nb = nodes[b]
      const dx = nb.pos.x - na.pos.x
      const dy = nb.pos.y - na.pos.y
      const d = Math.hypot(dx, dy) || 1e-6
      const diff = (d - len) / d
      const wa = na.pinned ? 0 : 0.5
      const wb = nb.pinned ? 0 : 0.5
      const s = wa + wb || 1
      na.pos.x += dx * diff * (wa / s)
      na.pos.y += dy * diff * (wa / s)
      nb.pos.x -= dx * diff * (wb / s)
      nb.pos.y -= dy * diff * (wb / s)
    }
    if (dragging) {
      // kinematic drag, like the original: the whole card follows the pointer
      nodes[4].pos.copy(dragTargetTop)
      nodes[4].prev.copy(dragTargetTop)
      nodes[5].pos.copy(dragTargetBottom)
      nodes[5].prev.copy(dragTargetBottom)
    }
    nodes[0].pos.copy(FIXED)
  }
}

/* ── card textures, drawn in the portfolio's design ── */
const CORNER = 30 // px, card corner radius on the 540x760 canvas

function cardBase(dark: boolean): [CanvasRenderingContext2D, number, number] {
  const W = 540, H = 760
  const c = document.createElement("canvas")
  c.width = W; c.height = H
  const x = c.getContext("2d")!
  // rounded card silhouette — everything outside stays transparent
  x.beginPath()
  x.roundRect(0, 0, W, H, CORNER)
  x.clip()
  x.fillStyle = dark ? "#111111" : "#f9f9f9"
  x.fillRect(0, 0, W, H)
  x.strokeStyle = dark ? props.accent : "#1b1b1b"
  x.lineWidth = 12
  x.beginPath()
  x.roundRect(6, 6, W - 12, H - 12, CORNER - 6)
  x.stroke()
  // faint grid, like the site background
  x.strokeStyle = dark ? "rgba(240,236,227,0.08)" : "rgba(27,27,27,0.07)"
  x.lineWidth = 1
  for (let gx = 40; gx < W; gx += 40) { x.beginPath(); x.moveTo(gx, 12); x.lineTo(gx, H - 12); x.stroke() }
  for (let gy = 40; gy < H; gy += 40) { x.beginPath(); x.moveTo(12, gy); x.lineTo(W - 12, gy); x.stroke() }
  return [x, W, H]
}

function punchSlot(x: CanvasRenderingContext2D, W: number) {
  // the hole the clamp grips through
  x.globalCompositeOperation = "destination-out"
  x.beginPath()
  x.roundRect(W / 2 - 70, 34, 140, 22, 11)
  x.fill()
  x.globalCompositeOperation = "source-over"
}

function toTexture(canvas: HTMLCanvasElement): THREE.CanvasTexture {
  const t = new THREE.CanvasTexture(canvas)
  t.colorSpace = THREE.SRGBColorSpace
  t.anisotropy = 8
  return t
}

function frontTexture(photoImg: HTMLImageElement | null): THREE.CanvasTexture {
  // backstage pass: dark card, hazard stripes, huge ALL ACCESS
  const [x, W, H] = cardBase(true)
  const A = props.accent
  const CREAM = "#f0ece3"
  const GRAY = "rgba(240,236,227,0.55)"

  // diagonal stripe band under the slot
  x.save()
  x.beginPath(); x.rect(24, 82, W - 48, 52); x.clip()
  x.strokeStyle = A; x.lineWidth = 16
  for (let sx = -80; sx < W + 100; sx += 42) {
    x.beginPath(); x.moveTo(sx, 146); x.lineTo(sx + 64, 70); x.stroke()
  }
  x.restore()

  // ALL ACCESS, stacked, accent
  x.fillStyle = A
  x.font = "400 150px 'Bebas Neue', sans-serif"
  x.fillText("ALL", 52, 296)
  let ap = 150
  do { x.font = `400 ${ap}px 'Bebas Neue', sans-serif`; ap -= 4 }
  while (ap > 60 && x.measureText("ACCESS").width > W - 104)
  x.fillText("ACCESS", 52, 428)

  // season row + vertical margin label
  x.fillStyle = GRAY
  x.font = "700 17px 'IBM Plex Mono', monospace"
  x.fillText(`${props.site} // SEASON 2026`, 52, 462)
  x.save(); x.translate(W - 34, 210); x.rotate(Math.PI / 2)
  x.font = "700 15px 'IBM Plex Mono', monospace"
  x.fillText(`ALL_AREAS // ${props.location}`, 0, 0); x.restore()

  // photo, grayscale, accent frame
  if (photoImg) {
    x.save()
    x.beginPath(); x.roundRect(52, 480, 148, 148, 12); x.clip()
    x.filter = "grayscale(1) contrast(1.1)"
    x.drawImage(photoImg, 52, 480, 148, 148)
    x.restore()
    x.strokeStyle = A; x.lineWidth = 4
    x.beginPath(); x.roundRect(52, 480, 148, 148, 12); x.stroke()
  }

  // name + spec rows beside the photo
  x.fillStyle = CREAM
  const name = props.handle.replace(/_/g, " ")
  let np = 58
  do { x.font = `400 ${np}px 'Bebas Neue', sans-serif`; np -= 4 }
  while (np > 26 && x.measureText(name).width > W - 226 - 36)
  x.fillText(name, 226, 542)
  x.fillStyle = A
  x.font = "700 17px 'IBM Plex Mono', monospace"
  x.fillText(props.role, 226, 574)
  x.fillStyle = GRAY
  x.font = "700 14px 'IBM Plex Mono', monospace"
  x.fillText(props.email, 226, 602)
  x.fillText(props.location, 226, 626)

  // barcode + accent block + ID number
  let bx2 = 52
  x.fillStyle = CREAM
  for (let i = 0; bx2 < W - 190; i++) {
    const w = [3, 7, 2, 5, 4, 2, 6][i % 7]
    x.fillRect(bx2, 656, w, 52)
    bx2 += w + [3, 2, 5, 3, 2, 4, 3][i % 7]
  }
  x.fillStyle = A; x.fillRect(W - 178, 656, 46, 52)
  x.fillStyle = GRAY
  x.save(); x.translate(W - 110, 710); x.rotate(-Math.PI / 2)
  x.font = "700 15px 'IBM Plex Mono', monospace"
  x.fillText("ID 65842·2026", 0, 0); x.restore()

  punchSlot(x, W)
  return toTexture(x.canvas)
}

function backTexture(): THREE.CanvasTexture {
  const [x, W] = cardBase(true)
  x.fillStyle = "#f0ece3"
  x.font = "400 92px 'Bebas Neue', sans-serif"
  x.fillText("THE LAB", 42, 190)
  x.fillStyle = props.accent
  x.font = "700 20px 'IBM Plex Mono', monospace"
  x.fillText("LIVE EXPERIMENTS //", 42, 236)
  x.fillText("NO WRITE-UPS", 42, 264)
  x.fillStyle = "rgba(240,236,227,0.65)"
  x.font = "400 19px 'IBM Plex Mono', monospace"
  x.fillText("IF FOUND, RETURN TO", 42, 640)
  x.fillStyle = props.accent
  x.font = "700 34px 'IBM Plex Mono', monospace"
  x.fillText(props.site.toLowerCase(), 42, 686)
  punchSlot(x, W)
  return toTexture(x.canvas)
}

function strapTexture(): THREE.CanvasTexture {
  const c = document.createElement("canvas")
  c.width = 1024; c.height = 128
  const x = c.getContext("2d")!
  x.fillStyle = props.strapColor; x.fillRect(0, 0, 1024, 128)
  x.fillStyle = "rgba(255,255,255,0.92)"
  x.font = "700 58px 'IBM Plex Mono', monospace"
  x.textBaseline = "middle"
  x.fillText(props.strapText, 24, 66)
  x.fillText(props.strapText, 536, 66)
  const t = new THREE.CanvasTexture(c)
  t.colorSpace = THREE.SRGBColorSpace
  t.wrapS = t.wrapT = THREE.RepeatWrapping
  return t
}

/* ── three scene ── */
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let cardGroup: THREE.Group
let cardMat: { front: THREE.MeshPhysicalMaterial; back: THREE.MeshPhysicalMaterial }
let ribbonGeo: THREE.BufferGeometry
let strapMat: THREE.MeshBasicMaterial
let raycaster: THREE.Raycaster
let raf = 0
let running = false
let io: IntersectionObserver | undefined
const grabOffsetTop = new THREE.Vector3()
const grabOffsetBottom = new THREE.Vector3()
const RIBBON_N = 48
const curve = new THREE.CatmullRomCurve3(
  Array.from({ length: 5 }, () => new THREE.Vector3()), false, "chordal",
)

function buildScene() {
  const canvas = canvasRef.value!
  renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
  renderer.setClearColor(0x000000, 0)
  scene = new THREE.Scene()
  camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100)
  camera.position.set(0, 0.45, 9.5)
  camera.lookAt(0, 0.45, 0)

  const pmrem = new THREE.PMREMGenerator(renderer)
  scene.environment = pmrem.fromScene(new RoomEnvironment()).texture

  scene.add(new THREE.AmbientLight(0xffffff, 0.9))
  const key = new THREE.DirectionalLight(0xffffff, 1.4)
  key.position.set(2, 4, 5)
  scene.add(key)

  // card: two planes back to back — the texture alpha carves the rounded
  // corners and the slot, so looking through the hole actually sees through
  const glossy = () => new THREE.MeshPhysicalMaterial({
    transparent: true, alphaTest: 0.4,
    roughness: 0.8, metalness: 0.3,
    clearcoat: 1, clearcoatRoughness: 0.15,
  })
  cardMat = { front: glossy(), back: glossy() }
  const plane = new THREE.PlaneGeometry(CARD_W, CARD_H)
  const frontMesh = new THREE.Mesh(plane, cardMat.front)
  frontMesh.position.z = 0.012
  const backMesh = new THREE.Mesh(plane, cardMat.back)
  backMesh.rotation.y = Math.PI
  backMesh.position.z = -0.012

  const metal = new THREE.MeshStandardMaterial({ color: 0xd8d8dd, metalness: 1, roughness: 0.25 })
  const clamp = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.12, 0.06), metal)
  clamp.position.y = CARD_H / 2 - 0.11 // grips through the slot
  const pin = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.12, 16), metal)
  pin.rotation.x = Math.PI / 2
  pin.position.y = CARD_H / 2 + 0.02

  cardGroup = new THREE.Group()
  cardGroup.add(frontMesh, backMesh, clamp, pin)
  scene.add(cardGroup)

  // strap ribbon, rebuilt from the verlet chain every frame
  ribbonGeo = new THREE.BufferGeometry()
  ribbonGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(RIBBON_N * 2 * 3), 3))
  ribbonGeo.setAttribute("uv", new THREE.BufferAttribute(new Float32Array(RIBBON_N * 2 * 2), 2))
  const idx: number[] = []
  for (let i = 0; i < RIBBON_N - 1; i++) {
    const a = i * 2
    idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2)
  }
  ribbonGeo.setIndex(idx)
  strapMat = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, depthTest: false })
  const ribbon = new THREE.Mesh(ribbonGeo, strapMat)
  ribbon.renderOrder = 1
  scene.add(ribbon)

  raycaster = new THREE.Raycaster()
  refreshTextures()
}

function refreshTextures() {
  strapMat.map?.dispose()
  strapMat.map = strapTexture()
  strapMat.needsUpdate = true
  cardMat.back.map?.dispose()
  cardMat.back.map = backTexture()
  cardMat.back.needsUpdate = true
  cardMat.front.map?.dispose()
  const img = new Image()
  img.onload = () => {
    cardMat.front.map = frontTexture(img)
    cardMat.front.needsUpdate = true
  }
  img.onerror = () => {
    cardMat.front.map = frontTexture(null)
    cardMat.front.needsUpdate = true
  }
  img.src = props.photo
}

const up = new THREE.Vector3()
const tangent = new THREE.Vector3()
const perp = new THREE.Vector3()
const Z = new THREE.Vector3(0, 0, 1)
const Y = new THREE.Vector3(0, 1, 0)
const yawQuat = new THREE.Quaternion()
let yaw = 0

function syncMeshes() {
  const top = nodes[4].pos, bottom = nodes[5].pos
  cardGroup.position.copy(top).add(bottom).multiplyScalar(0.5)
  up.copy(top).sub(bottom).normalize()
  cardGroup.quaternion.setFromUnitVectors(Y, up)
  // sideways velocity tilts the card around its own axis — fakes the 3D swing
  const vx = top.x - nodes[4].prev.x
  yaw += (THREE.MathUtils.clamp(-vx * 10, -0.6, 0.6) - yaw) * 0.1
  yawQuat.setFromAxisAngle(Y, yaw)
  cardGroup.quaternion.multiply(yawQuat)

  for (let i = 0; i < 5; i++) curve.points[i].copy(nodes[i].pos)
  const pts = curve.getPoints(RIBBON_N - 1)
  const pos = ribbonGeo.getAttribute("position") as THREE.BufferAttribute
  const uv = ribbonGeo.getAttribute("uv") as THREE.BufferAttribute

  // elastic band: the print is pinned to the strap, not fed out of the top.
  // A fixed number of texture tiles (from the rest length) spreads over the
  // current length, and the strap thins as it stretches — rubber, not tape.
  const arcs = new Float32Array(RIBBON_N)
  let total = 0
  for (let i = 1; i < RIBBON_N; i++) {
    total += pts[i].distanceTo(pts[i - 1])
    arcs[i] = total
  }
  const rest = 4 * SEG
  const tiles = rest / (props.strapWidth * 8) // one tile is 8x as long as wide
  const hw = (props.strapWidth / 2) * THREE.MathUtils.clamp(Math.sqrt(rest / (total || rest)), 0.5, 1)

  for (let i = 0; i < RIBBON_N; i++) {
    const p = pts[i]
    const n = pts[Math.min(i + 1, RIBBON_N - 1)]
    tangent.copy(n).sub(pts[Math.max(i - 1, 0)]).normalize()
    perp.crossVectors(tangent, Z).normalize()
    pos.setXYZ(i * 2, p.x + perp.x * hw, p.y + perp.y * hw, p.z)
    pos.setXYZ(i * 2 + 1, p.x - perp.x * hw, p.y - perp.y * hw, p.z)
    // negative u unmirrors the text, same trick as the original's repeat=[-4,1]
    const u = -(arcs[i] / (total || 1)) * tiles
    uv.setXY(i * 2, u, 1)
    uv.setXY(i * 2 + 1, u, 0)
  }
  pos.needsUpdate = true
  uv.needsUpdate = true
  ribbonGeo.computeBoundingSphere()
}

function updateSize() {
  const wrap = wrapRef.value
  if (!wrap || !renderer) return
  const w = wrap.clientWidth, h = wrap.clientHeight
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  renderer.setSize(w, h, false)
  camera.aspect = w / h
  camera.updateProjectionMatrix()
}

let last = 0
let acc = 0
const STEP = 1 / 120

function loop(now: number) {
  const dt = Math.min((now - last) / 1000, 0.05)
  last = now
  acc += dt
  while (acc > STEP) {
    simStep(STEP)
    acc -= STEP
  }
  syncMeshes()
  renderer!.render(scene, camera)
  raf = requestAnimationFrame(loop)
}

function start() {
  if (running || !renderer) return
  running = true
  last = performance.now()
  raf = requestAnimationFrame(loop)
}

function stop() {
  running = false
  cancelAnimationFrame(raf)
}

/* ── pointer interaction ── */
const pointer = new THREE.Vector2()
const planePoint = new THREE.Vector3()
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0)

function toPointer(e: PointerEvent) {
  const rect = canvasRef.value!.getBoundingClientRect()
  pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
  pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
}

function onDown(e: PointerEvent) {
  if (!renderer) return
  toPointer(e)
  raycaster.setFromCamera(pointer, camera)
  if (raycaster.intersectObject(cardGroup, true).length) {
    raycaster.ray.intersectPlane(plane, planePoint)
    grabOffsetTop.copy(nodes[4].pos).sub(planePoint)
    grabOffsetBottom.copy(nodes[5].pos).sub(planePoint)
    dragTargetTop.copy(nodes[4].pos)
    dragTargetBottom.copy(nodes[5].pos)
    dragging = true
    canvasRef.value!.setPointerCapture(e.pointerId)
    canvasRef.value!.style.cursor = "grabbing"
  }
}

function onMove(e: PointerEvent) {
  if (!renderer) return
  toPointer(e)
  raycaster.setFromCamera(pointer, camera)
  if (dragging) {
    raycaster.ray.intersectPlane(plane, planePoint)
    dragTargetTop.copy(planePoint).add(grabOffsetTop)
    dragTargetBottom.copy(planePoint).add(grabOffsetBottom)
  } else {
    const hit = raycaster.intersectObject(cardGroup, true).length > 0
    canvasRef.value!.style.cursor = hit ? "grab" : ""
  }
}

function onUp(e: PointerEvent) {
  dragging = false
  canvasRef.value?.releasePointerCapture(e.pointerId)
  if (canvasRef.value) canvasRef.value.style.cursor = ""
}

watch(() => [props.strapColor, props.strapText, props.handle, props.role, props.site, props.email, props.photo, props.location, props.accent], () => {
  if (renderer) refreshTextures()
})

onMounted(async () => {
  resetChain()
  buildScene()
  updateSize()
  window.addEventListener("resize", updateSize)
  const canvas = canvasRef.value!
  canvas.addEventListener("pointerdown", onDown)
  canvas.addEventListener("pointermove", onMove)
  canvas.addEventListener("pointerup", onUp)
  canvas.addEventListener("pointercancel", onUp)

  // card text uses the site fonts — redraw once they are ready
  try {
    await Promise.all([
      document.fonts.load("400 105px 'Bebas Neue'"),
      document.fonts.load("700 20px 'IBM Plex Mono'"),
    ])
    refreshTextures()
  } catch { /* fallback fonts are fine */ }

  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    // settle instantly, render one static frame
    for (let i = 0; i < 600; i++) simStep(STEP)
    syncMeshes()
    renderer!.render(scene, camera)
    return
  }
  io = new IntersectionObserver(
    ([entry]) => (entry.isIntersecting ? start() : stop()),
    { threshold: 0.1 },
  )
  io.observe(wrapRef.value!)
})

onBeforeUnmount(() => {
  io?.disconnect()
  stop()
  window.removeEventListener("resize", updateSize)
  renderer?.dispose()
  renderer = null
})

/* re-hang the card from the start position */
function drop() {
  resetChain()
}
defineExpose({ drop })
</script>

<style scoped>
.lanyard-wrapper {
  position: relative;
  z-index: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  transform: scale(1);
  transform-origin: center;
}
.lanyard-canvas { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }
</style>

SOURCE: inspired by reactbits.dev/components/lanyard (React Three Fiber + Rapier + GLB). This version swaps the physics engine for a ~40-line verlet chain and the 3D model for a generated card, so the only dependency is three.