import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import * as THREE from "three";
import { Canvas, extend, useFrame, useThree } from "@react-three/fiber";
import { Environment, Lightformer, useGLTF } from "@react-three/drei";
import {
BallCollider,
CuboidCollider,
Physics,
RigidBody,
useRopeJoint,
useSphericalJoint,
} from "@react-three/rapier";
import { MeshLineGeometry, MeshLineMaterial } from "meshline";
import "./pass.css";
extend({ MeshLineGeometry, MeshLineMaterial });
const MODEL_URL = "/static/assets/tag.glb";
useGLTF.preload(MODEL_URL);
function fitText(ctx, text, maxWidth, initialSize) {
let size = initialSize;
while (size > 30) {
ctx.font = `600 ${size}px Arial, sans-serif`;
if (ctx.measureText(text).width <= maxWidth) break;
size -= 2;
}
return size;
}
function createBadgeTexture(pass) {
const canvas = document.createElement("canvas");
canvas.width = 912;
canvas.height = 1288;
const ctx = canvas.getContext("2d");
const stateColors = {
VALID: "#b9f8d3",
"NOT ACTIVE": "#e8c986",
EXPIRED: "#df9298",
};
const accent = stateColors[pass.status] || "#ffffff";
ctx.fillStyle = "#050505";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#ffffff1f";
ctx.lineWidth = 1.5;
ctx.strokeRect(30, 30, 852, 1228);
ctx.fillStyle = "#ffffff";
ctx.font = "700 34px Arial, sans-serif";
ctx.fillText("PASS", 58, 79);
ctx.fillStyle = "#8f8f8f";
ctx.fillText("PORT", 154, 79);
ctx.font = "500 19px monospace";
ctx.fillText("DIGITAL ACCESS SYSTEM", 58, 110);
ctx.textAlign = "right";
ctx.fillStyle = accent;
ctx.font = "600 34px monospace";
const statusWidth = ctx.measureText(pass.status).width;
ctx.beginPath();
ctx.arc(852 - statusWidth - 24, 74, 9, 0, Math.PI * 2);
ctx.fill();
ctx.fillText(pass.status, 852, 86);
ctx.textAlign = "left";
ctx.strokeStyle = "#ffffff2d";
ctx.beginPath();
ctx.moveTo(58, 145);
ctx.lineTo(854, 145);
ctx.stroke();
ctx.fillStyle = "#929292";
ctx.font = "500 22px monospace";
ctx.fillText("TEMPORARY ACCESS CREDENTIAL", 58, 220);
ctx.textAlign = "right";
ctx.fillText(`NO. ${pass.credentialId}`, 854, 220);
ctx.textAlign = "left";
ctx.fillStyle = "#ffffff";
ctx.font = "650 54px Arial, sans-serif";
ctx.fillText("GUEST ACCESS", 55, 350);
ctx.fillStyle = "#8e8e8e";
ctx.font = "400 25px Arial, sans-serif";
ctx.fillText("Temporary entry credential", 58, 398);
ctx.fillStyle = "#858585";
ctx.font = "500 22px monospace";
ctx.fillText("PASS HOLDER", 58, 510);
ctx.fillStyle = "#ffffff";
const holderSize = fitText(ctx, pass.holder, 796, 66);
ctx.font = `600 ${holderSize}px Arial, sans-serif`;
ctx.fillText(pass.holder, 55, 590);
ctx.strokeStyle = "#ffffff2d";
ctx.beginPath();
ctx.moveTo(58, 660);
ctx.lineTo(854, 660);
ctx.stroke();
ctx.fillStyle = "#858585";
ctx.font = "500 22px monospace";
ctx.fillText("AUTHORIZED LOCATION", 58, 718);
ctx.fillText("VALID FROM", 520, 718);
ctx.fillStyle = "#ffffff";
const locationSize = fitText(ctx, pass.location, 400, 32);
ctx.font = `500 ${locationSize}px Arial, sans-serif`;
ctx.fillText(pass.location, 58, 764);
ctx.font = "500 30px monospace";
ctx.fillText(pass.validFrom, 520, 762);
ctx.fillStyle = "#858585";
ctx.font = "500 22px monospace";
ctx.fillText("VALID UNTIL", 520, 832);
ctx.fillStyle = "#ffffff";
ctx.font = "500 30px monospace";
ctx.fillText(pass.validUntil, 520, 876);
ctx.strokeStyle = "#ffffff2d";
ctx.beginPath();
ctx.moveTo(58, 960);
ctx.lineTo(854, 960);
ctx.stroke();
ctx.fillStyle = accent;
ctx.beginPath();
ctx.arc(70, 1024, 13, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#050505";
ctx.font = "700 14px Arial, sans-serif";
ctx.fillText("✓", 65, 1029);
ctx.fillStyle = "#9a9a9a";
ctx.font = "500 20px monospace";
ctx.fillText("DIGITALLY SIGNED ACCESS CREDENTIAL", 100, 1020);
ctx.fillText("ISSUER / PASSPORT", 100, 1045);
ctx.textAlign = "right";
ctx.textAlign = "left";
ctx.fillStyle = "#616161";
ctx.font = "500 19px monospace";
ctx.fillText("TAP CARD TO VIEW VERIFICATION DETAILS", 58, 1205);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 16;
return texture;
}
function createBackTexture(pass) {
const canvas = document.createElement("canvas");
canvas.width = 912;
canvas.height = 1288;
const ctx = canvas.getContext("2d");
const accent = pass.status === "VALID" ? "#b9f8d3" : pass.status === "EXPIRED" ? "#df9298" : "#e8c986";
ctx.fillStyle = "#050505";
ctx.fillRect(0, 0, 912, 1288);
ctx.strokeStyle = "#ffffff24";
ctx.lineWidth = 1.5;
ctx.strokeRect(30, 30, 852, 1228);
ctx.fillStyle = "#fff";
ctx.font = "700 32px Arial, sans-serif";
ctx.fillText("PASS PORT", 58, 82);
ctx.textAlign = "right";
ctx.fillStyle = "#777";
ctx.font = "500 19px monospace";
ctx.fillText("CREDENTIAL / REVERSE", 854, 82);
ctx.textAlign = "left";
ctx.fillStyle = "#8c8c8c";
ctx.font = "500 21px monospace";
ctx.fillText("VERIFICATION", 58, 210);
ctx.fillStyle = "#ffffff";
ctx.font = "650 48px Arial, sans-serif";
ctx.fillText("Credential details", 55, 275);
ctx.fillStyle = "#8c8c8c";
ctx.font = "500 21px monospace";
ctx.fillText("CURRENT STATUS", 58, 390);
ctx.fillStyle = accent;
ctx.font = "600 56px Arial, sans-serif";
ctx.fillText(pass.status, 55, 450);
ctx.strokeStyle = "#ffffff2d";
ctx.beginPath();
ctx.moveTo(58, 505);
ctx.lineTo(854, 505);
ctx.stroke();
const rows = [
["ISSUER", "PASSPORT"],
["SIGNATURE", "HS256"],
["CREDENTIAL NO.", pass.credentialId],
["PASS HOLDER", pass.holder],
["ACCESS POINT", pass.location],
];
rows.forEach(([label, value], index) => {
const y = 570 + index * 92;
ctx.fillStyle = "#777";
ctx.font = "500 20px monospace";
ctx.fillText(label, 58, y);
ctx.textAlign = "right";
ctx.fillStyle = "#fff";
ctx.font = "500 27px Arial, sans-serif";
ctx.fillText(value, 854, y);
ctx.textAlign = "left";
ctx.strokeStyle = "#ffffff17";
ctx.beginPath();
ctx.moveTo(58, y + 34);
ctx.lineTo(854, y + 34);
ctx.stroke();
});
ctx.fillStyle = "#777";
ctx.font = "500 20px monospace";
ctx.fillText("Credit", 58, 1060);
ctx.fillStyle = "#d5d5d5";
ctx.font = "500 23px Arial, sans-serif";
ctx.fillText("https://github.com/hjopel/vercel-3d-card", 58, 1100);
ctx.fillText("@imnyang", 58, 1135);
ctx.fillStyle = "#666";
ctx.font = "500 18px monospace";
ctx.fillText("TAP CARD TO RETURN TO FRONT", 58, 1210);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 16;
return texture;
}
function createBandTexture() {
const canvas = document.createElement("canvas");
canvas.width = 1024;
canvas.height = 160;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#090909";
ctx.fillRect(0, 0, 1024, 160);
ctx.strokeStyle = "#333";
ctx.strokeRect(0, 4, 1024, 152);
ctx.fillStyle = "#eee";
ctx.font = "600 35px Arial, sans-serif";
ctx.textBaseline = "middle";
for (let x = 30; x < 1024; x += 330) {
ctx.fillText("PASS PORT", x, 80);
ctx.fillStyle = "#555";
ctx.fillText(" / ACCESS", x + 190, 80);
ctx.fillStyle = "#eee";
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
return texture;
}
function Band({ pass, flipped, onFlip, maxSpeed = 50, minSpeed = 10 }) {
const band = useRef();
const fixed = useRef();
const joint1 = useRef();
const joint2 = useRef();
const joint3 = useRef();
const card = useRef();
const cardVisual = useRef();
const { nodes, materials } = useGLTF(MODEL_URL);
const bandTexture = useMemo(() => createBandTexture(), []);
const badgeTexture = useMemo(() => createBadgeTexture(pass), [pass]);
const backTexture = useMemo(() => createBackTexture(pass), [pass]);
const { width, height } = useThree((state) => state.size);
const curve = useMemo(
() => new THREE.CatmullRomCurve3([
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
]),
[],
);
const vec = useMemo(() => new THREE.Vector3(), []);
const angularVelocity = useMemo(() => new THREE.Vector3(), []);
const rotation = useMemo(() => new THREE.Vector3(), []);
const direction = useMemo(() => new THREE.Vector3(), []);
const [dragged, setDragged] = useState(false);
const [hovered, setHovered] = useState(false);
const segmentProps = {
canSleep: true,
colliders: false,
angularDamping: 2,
linearDamping: 2,
};
useRopeJoint(fixed, joint1, [[0, 0, 0], [0, 0, 0], 1]);
useRopeJoint(joint1, joint2, [[0, 0, 0], [0, 0, 0], 1]);
useRopeJoint(joint2, joint3, [[0, 0, 0], [0, 0, 0], 1]);
useSphericalJoint(joint3, card, [[0, 0, 0], [0, 1.45, 0]]);
useEffect(() => {
document.body.style.cursor = hovered ? (dragged ? "grabbing" : "grab") : "auto";
return () => { document.body.style.cursor = "auto"; };
}, [hovered, dragged]);
useEffect(() => () => {
badgeTexture.dispose();
backTexture.dispose();
bandTexture.dispose();
}, [badgeTexture, backTexture, bandTexture]);
useFrame((state, delta) => {
if (cardVisual.current) {
cardVisual.current.rotation.y = THREE.MathUtils.damp(
cardVisual.current.rotation.y,
flipped ? Math.PI : 0,
7,
delta,
);
}
if (dragged) {
vec.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera);
direction.copy(vec).sub(state.camera.position).normalize();
vec.add(direction.multiplyScalar(state.camera.position.length()));
[card, joint1, joint2, joint3, fixed].forEach((ref) => ref.current?.wakeUp());
card.current?.setNextKinematicTranslation({
x: vec.x - dragged.x,
y: vec.y - dragged.y,
z: vec.z - dragged.z,
});
}
if (fixed.current) {
[joint1, joint2].forEach((ref) => {
if (!ref.current.lerped) {
ref.current.lerped = new THREE.Vector3().copy(ref.current.translation());
}
const distance = ref.current.lerped.distanceTo(ref.current.translation());
const clampedDistance = Math.max(0.1, Math.min(1, distance));
ref.current.lerped.lerp(
ref.current.translation(),
delta * (minSpeed + clampedDistance * (maxSpeed - minSpeed)),
);
});
curve.points[0].copy(joint3.current.translation());
curve.points[1].copy(joint2.current.lerped);
curve.points[2].copy(joint1.current.lerped);
curve.points[3].copy(fixed.current.translation());
band.current.geometry.setPoints(curve.getPoints(32));
angularVelocity.copy(card.current.angvel());
rotation.copy(card.current.rotation());
card.current.setAngvel({
x: angularVelocity.x,
y: angularVelocity.y - rotation.y * 0.25,
z: angularVelocity.z,
}, false);
}
});
curve.curveType = "chordal";
bandTexture.wrapS = bandTexture.wrapT = THREE.RepeatWrapping;
return (
<>
setHovered(true)}
onPointerOut={() => setHovered(false)}
onPointerUp={(event) => {
event.target.releasePointerCapture(event.pointerId);
setDragged(false);
}}
onPointerDown={(event) => {
event.target.setPointerCapture(event.pointerId);
setDragged(event.point.clone().sub(vec.copy(card.current.translation())));
}}
onClick={(event) => {
event.stopPropagation();
if (event.delta < 5) onFlip();
}}
>
>
);
}
function PassScene({ pass, flipped, onFlip }) {
return (
);
}
function App() {
const element = document.getElementById("pass-root");
const [flipped, setFlipped] = useState(false);
const pass = useMemo(() => ({
holder: element.dataset.holder,
location: element.dataset.location,
status: element.dataset.status,
validFrom: element.dataset.validFrom,
validUntil: element.dataset.validUntil,
credentialId: element.dataset.credentialId,
}), [element]);
return (
<>
Loading credential}>
setFlipped((value) => !value)} />
>
);
}
createRoot(document.getElementById("pass-root")).render();