{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "Lanyard-TS-CSS",
	"title": "Lanyard",
	"description": "Swinging 3D lanyard / badge card with realistic inertial motion.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "Lanyard.css",
			"target": "@components/Lanyard.css",
			"content": ".lanyard-wrapper {\n  position: relative;\n  z-index: 0;\n  width: 100%;\n  height: 100vh;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  transform: scale(1);\n  transform-origin: center;\n}\n"
		},
		{
			"type": "registry:component",
			"path": "Lanyard.tsx",
			"content": "/* eslint-disable react/no-unknown-property */\n'use client';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { Canvas, extend, useFrame, type ThreeElement, type ThreeEvent } from '@react-three/fiber';\nimport { useGLTF, useTexture, Environment, Lightformer } from '@react-three/drei';\nimport {\n  BallCollider,\n  CuboidCollider,\n  Physics,\n  RigidBody,\n  useRopeJoint,\n  useSphericalJoint,\n  type RapierRigidBody,\n  type RigidBodyProps\n} from '@react-three/rapier';\nimport { MeshLineGeometry, MeshLineMaterial } from 'meshline';\nimport * as THREE from 'three';\n\n// replace with your own imports, see the usage snippet for details\nimport cardGLB from './card.glb';\nimport lanyard from './lanyard.png';\n\nimport './Lanyard.css';\n\nextend({ MeshLineGeometry, MeshLineMaterial });\n\ndeclare module '@react-three/fiber' {\n  interface ThreeElements {\n    meshLineGeometry: ThreeElement<typeof MeshLineGeometry>;\n    meshLineMaterial: ThreeElement<typeof MeshLineMaterial>;\n  }\n}\n\n// 1x1 transparent pixel — lets useTexture be called unconditionally when a\n// front/back image isn't supplied.\nconst BLANK_PIXEL =\n  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';\n\n// The card model's front face is UV-mapped to the LEFT half of the texture\n// atlas and the back face to the RIGHT half (measured from card.glb). Each\n// custom image is composited into its own half so the two faces render\n// independently, aspect-preserving (no stretching).\nconst FRONT_UV_RECT = { x: 0, y: 0, w: 0.5, h: 0.755 };\nconst BACK_UV_RECT = { x: 0.5, y: 0, w: 0.5, h: 0.757 };\n\ninterface LanyardProps {\n  position?: [number, number, number];\n  gravity?: [number, number, number];\n  fov?: number;\n  transparent?: boolean;\n  frontImage?: string | null;\n  backImage?: string | null;\n  imageFit?: 'cover' | 'contain';\n  lanyardImage?: string | null;\n  lanyardWidth?: number;\n}\n\nexport default function Lanyard({\n  position = [0, 0, 30],\n  gravity = [0, -40, 0],\n  fov = 20,\n  transparent = true,\n  frontImage = null,\n  backImage = null,\n  imageFit = 'cover',\n  lanyardImage = null,\n  lanyardWidth = 1\n}: LanyardProps) {\n  const [isMobile, setIsMobile] = useState<boolean>(() => typeof window !== 'undefined' && window.innerWidth < 768);\n\n  useEffect(() => {\n    const handleResize = (): void => setIsMobile(window.innerWidth < 768);\n    window.addEventListener('resize', handleResize);\n    return () => window.removeEventListener('resize', handleResize);\n  }, []);\n\n  return (\n    <div className=\"lanyard-wrapper\">\n      <Canvas\n        camera={{ position, fov }}\n        dpr={[1, isMobile ? 1.5 : 2]}\n        gl={{ alpha: transparent }}\n        onCreated={({ gl }) => gl.setClearColor(new THREE.Color(0x000000), transparent ? 0 : 1)}\n      >\n        <ambientLight intensity={Math.PI} />\n        <Physics gravity={gravity} timeStep={isMobile ? 1 / 30 : 1 / 60}>\n          <Band\n            isMobile={isMobile}\n            frontImage={frontImage}\n            backImage={backImage}\n            imageFit={imageFit}\n            lanyardImage={lanyardImage}\n            lanyardWidth={lanyardWidth}\n          />\n        </Physics>\n        <Environment blur={0.75}>\n          <Lightformer\n            intensity={2}\n            color=\"white\"\n            position={[0, -1, 5]}\n            rotation={[0, 0, Math.PI / 3]}\n            scale={[100, 0.1, 1]}\n          />\n          <Lightformer\n            intensity={3}\n            color=\"white\"\n            position={[-1, -1, 1]}\n            rotation={[0, 0, Math.PI / 3]}\n            scale={[100, 0.1, 1]}\n          />\n          <Lightformer\n            intensity={3}\n            color=\"white\"\n            position={[1, 1, 1]}\n            rotation={[0, 0, Math.PI / 3]}\n            scale={[100, 0.1, 1]}\n          />\n          <Lightformer\n            intensity={10}\n            color=\"white\"\n            position={[-10, 0, 14]}\n            rotation={[0, Math.PI / 2, Math.PI / 3]}\n            scale={[100, 10, 1]}\n          />\n        </Environment>\n      </Canvas>\n    </div>\n  );\n}\n\ninterface BandProps {\n  maxSpeed?: number;\n  minSpeed?: number;\n  isMobile?: boolean;\n  frontImage?: string | null;\n  backImage?: string | null;\n  imageFit?: 'cover' | 'contain';\n  lanyardImage?: string | null;\n  lanyardWidth?: number;\n}\n\ntype LanyardRigidBody = RapierRigidBody & {\n  lerped?: THREE.Vector3;\n};\n\nfunction Band({\n  maxSpeed = 50,\n  minSpeed = 0,\n  isMobile = false,\n  frontImage = null,\n  backImage = null,\n  imageFit = 'cover',\n  lanyardImage = null,\n  lanyardWidth = 1\n}: BandProps) {\n  const band = useRef<THREE.Mesh<InstanceType<typeof MeshLineGeometry>, InstanceType<typeof MeshLineMaterial>>>(null!);\n  const fixed = useRef<RapierRigidBody>(null!);\n  const j1 = useRef<LanyardRigidBody>(null!);\n  const j2 = useRef<LanyardRigidBody>(null!);\n  const j3 = useRef<RapierRigidBody>(null!);\n  const card = useRef<RapierRigidBody>(null!);\n\n  const vec = new THREE.Vector3();\n  const ang = new THREE.Vector3();\n  const rot = new THREE.Vector3();\n  const dir = new THREE.Vector3();\n\n  const segmentProps: RigidBodyProps = {\n    type: 'dynamic',\n    canSleep: true,\n    colliders: false,\n    angularDamping: 4,\n    linearDamping: 4\n  };\n\n  const getLerped = (body: LanyardRigidBody): THREE.Vector3 => {\n    if (!body.lerped) {\n      body.lerped = new THREE.Vector3().copy(body.translation());\n    }\n\n    return body.lerped;\n  };\n\n  const { nodes, materials } = useGLTF(cardGLB) as any;\n  const texture = useTexture(lanyardImage || lanyard);\n  // useTexture must be called unconditionally; use a blank pixel when an image\n  // isn't supplied for a given face, then skip compositing it below.\n  const frontTex = useTexture(frontImage || BLANK_PIXEL);\n  const backTex = useTexture(backImage || BLANK_PIXEL);\n\n  // Composite the front/back images into the card's texture atlas (front = left\n  // half, back = right half). Each image is drawn aspect-preserving (no stretch).\n  const cardMap = useMemo(() => {\n    const baseMap = materials.base.map as THREE.Texture;\n    if (!frontImage && !backImage) return baseMap;\n\n    const baseImg = baseMap.image as any;\n    const W = baseImg.width;\n    const H = baseImg.height;\n    const canvas = document.createElement('canvas');\n    canvas.width = W;\n    canvas.height = H;\n    const ctx = canvas.getContext('2d');\n    if (!ctx) return baseMap;\n    // Keep the original baked atlas for the card edges and any untouched face.\n    ctx.drawImage(baseImg, 0, 0, W, H);\n\n    const drawFitted = (img: any, rect: typeof FRONT_UV_RECT) => {\n      const rx = rect.x * W;\n      const ry = rect.y * H;\n      const rw = rect.w * W;\n      const rh = rect.h * H;\n      const pick = imageFit === 'contain' ? Math.min : Math.max;\n      const scale = pick(rw / img.width, rh / img.height);\n      const dw = img.width * scale;\n      const dh = img.height * scale;\n      const dx = rx + (rw - dw) / 2;\n      const dy = ry + (rh - dh) / 2;\n      ctx.save();\n      ctx.beginPath();\n      ctx.rect(rx, ry, rw, rh);\n      ctx.clip();\n      ctx.drawImage(img, dx, dy, dw, dh);\n      ctx.restore();\n    };\n\n    if (frontImage && frontTex.image) drawFitted(frontTex.image, FRONT_UV_RECT);\n    if (backImage && backTex.image) drawFitted(backTex.image, BACK_UV_RECT);\n\n    const composite = new THREE.CanvasTexture(canvas);\n    composite.colorSpace = THREE.SRGBColorSpace;\n    composite.flipY = baseMap.flipY;\n    composite.anisotropy = 16;\n    composite.needsUpdate = true;\n    return composite;\n  }, [frontImage, backImage, imageFit, frontTex, backTex, materials.base.map]);\n  const [curve] = useState(\n    () =>\n      new THREE.CatmullRomCurve3([new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()])\n  );\n  const [dragged, drag] = useState<false | THREE.Vector3>(false);\n  const [hovered, hover] = useState(false);\n\n  useRopeJoint(fixed, j1, [[0, 0, 0], [0, 0, 0], 1]);\n  useRopeJoint(j1, j2, [[0, 0, 0], [0, 0, 0], 1]);\n  useRopeJoint(j2, j3, [[0, 0, 0], [0, 0, 0], 1]);\n  useSphericalJoint(j3, card, [\n    [0, 0, 0],\n    [0, 1.45, 0]\n  ]);\n\n  useEffect(() => {\n    if (hovered) {\n      document.body.style.cursor = dragged ? 'grabbing' : 'grab';\n      return () => {\n        document.body.style.cursor = 'auto';\n      };\n    }\n  }, [hovered, dragged]);\n\n  useFrame((state, delta) => {\n    if (dragged && typeof dragged !== 'boolean') {\n      vec.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera);\n      dir.copy(vec).sub(state.camera.position).normalize();\n      vec.add(dir.multiplyScalar(state.camera.position.length()));\n      [card, j1, j2, j3, fixed].forEach(ref => ref.current?.wakeUp());\n      card.current?.setNextKinematicTranslation({\n        x: vec.x - dragged.x,\n        y: vec.y - dragged.y,\n        z: vec.z - dragged.z\n      });\n    }\n    if (fixed.current) {\n      [j1, j2].forEach(ref => {\n        const lerped = getLerped(ref.current);\n        const clampedDistance = Math.max(0.1, Math.min(1, lerped.distanceTo(ref.current.translation())));\n        lerped.lerp(ref.current.translation(), delta * (minSpeed + clampedDistance * (maxSpeed - minSpeed)));\n      });\n      curve.points[0].copy(j3.current.translation());\n      curve.points[1].copy(getLerped(j2.current));\n      curve.points[2].copy(getLerped(j1.current));\n      curve.points[3].copy(fixed.current.translation());\n      band.current.geometry.setPoints(curve.getPoints(isMobile ? 16 : 32));\n      ang.copy(card.current.angvel());\n      rot.copy(card.current.rotation());\n      card.current.setAngvel({ x: ang.x, y: ang.y - rot.y * 0.25, z: ang.z }, true);\n    }\n  });\n\n  curve.curveType = 'chordal';\n  texture.wrapS = texture.wrapT = THREE.RepeatWrapping;\n\n  return (\n    <>\n      <group position={[0, 4, 0]}>\n        <RigidBody ref={fixed} {...segmentProps} type=\"fixed\" />\n        <RigidBody position={[0.5, 0, 0]} ref={j1} {...segmentProps} type=\"dynamic\">\n          <BallCollider args={[0.1]} />\n        </RigidBody>\n        <RigidBody position={[1, 0, 0]} ref={j2} {...segmentProps} type=\"dynamic\">\n          <BallCollider args={[0.1]} />\n        </RigidBody>\n        <RigidBody position={[1.5, 0, 0]} ref={j3} {...segmentProps} type=\"dynamic\">\n          <BallCollider args={[0.1]} />\n        </RigidBody>\n        <RigidBody\n          position={[2, 0, 0]}\n          ref={card}\n          {...segmentProps}\n          type={dragged ? 'kinematicPosition' : 'dynamic'}\n        >\n          <CuboidCollider args={[0.8, 1.125, 0.01]} />\n          <group\n            scale={2.25}\n            position={[0, -1.2, -0.05]}\n            onPointerOver={() => hover(true)}\n            onPointerOut={() => hover(false)}\n            onPointerUp={(e: ThreeEvent<PointerEvent>) => {\n              (e.target as Element).releasePointerCapture(e.pointerId);\n              drag(false);\n            }}\n            onPointerDown={(e: ThreeEvent<PointerEvent>) => {\n              (e.target as Element).setPointerCapture(e.pointerId);\n              drag(new THREE.Vector3().copy(e.point).sub(vec.copy(card.current.translation())));\n            }}\n          >\n            <mesh geometry={nodes.card.geometry}>\n              <meshPhysicalMaterial\n                map={cardMap}\n                map-anisotropy={16}\n                clearcoat={isMobile ? 0 : 1}\n                clearcoatRoughness={0.15}\n                roughness={0.9}\n                metalness={0.8}\n              />\n            </mesh>\n            <mesh geometry={nodes.clip.geometry} material={materials.metal} material-roughness={0.3} />\n            <mesh geometry={nodes.clamp.geometry} material={materials.metal} />\n          </group>\n        </RigidBody>\n      </group>\n      <mesh ref={band}>\n        <meshLineGeometry />\n        <meshLineMaterial\n          color=\"white\"\n          depthTest={false}\n          resolution={isMobile ? [1000, 2000] : [1000, 1000]}\n          useMap\n          map={texture}\n          repeat={[-4, 1]}\n          lineWidth={lanyardWidth}\n        />\n      </mesh>\n    </>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}