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.