-
-
Notifications
You must be signed in to change notification settings - Fork 36.2k
Description
Description
(THOSE WHO CAN FIX IT OUT I WOULD LOVE TO GIVE OUT A GIFT FROM BITCOIN WORTH 100$ I TRIED MY BEST)
I'm using Three.js with CSS3DRenderer to position an iframe on a 3D model's screen mesh. The model itself is responsive and works correctly, but the iframe positioning breaks on mobile and tablet devices.
The Problem:
- On mobile/tablet, the iframe renders below the model instead of on the screen mesh
- The iframe shifts up/down incorrectly during scroll
- Desktop works perfectly (no address bar to cause issues)
- The root cause is that CSS3DRenderer doesn't account for scroll offset when calculating CSS transforms
Why it happens:
Mobile browsers have a separate compositor thread (GPU) that handles scroll independently from the JavaScript thread (CPU). When the address bar hides/shows, window.innerHeight changes, but CSS3DRenderer's transform calculations don't account for this scroll offset, causing the iframe to be positioned incorrectly in viewport space.
What I've Tried
-
Adjusting
position.ywith scroll offset:- Calculated
totalOffset = window.scrollY + container.getBoundingClientRect().top - Applied:
css3dObject.position.y = baseY - (totalOffset * multiplier) - ❌ Doesn't work because CSS3DRenderer overrides
position.yevery frame
- Calculated
-
Adding
translateYto CSS transform after render:- Used
requestAnimationFrameandsetTimeoutto apply transform after CSS3DRenderer - Applied:
iframe.style.transform = '${matrix3d} translateY(-${totalOffset}px)' - ❌ Doesn't work because CSS3DRenderer resets transform every frame
- Used
-
Using
getBoundingClientRect()for offset calculation:- Values are correct (verified in console)
- But can't be applied effectively due to CSS3DRenderer's render cycle
-
Various multipliers and approaches:
- Tried multipliers from 0.0015 to 6.5
- None work because the issue is architectural, not a calculation problem
Reproduction steps
1.Load the 3D model with iframe on desktop → Works perfectly ✅
2.Load the same on mobile/tablet → Iframe is positioned below the model ❌
3.Scroll the page on mobile → Iframe shifts incorrectly ❌
4.Address bar hides/shows → window.innerHeight changes, iframe position breaks ❌
Code
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { CSS3DRenderer, CSS3DObject } from "three/examples/jsm/renderers/CSS3DRenderer";
export default function ThreeDTV() {
const containerRef = useRef(null);
const iframeRef = useRef(null);
const cssObjectRef = useRef(null);
const controlsRef = useRef(null);
const channels = [
"https://ai-chattt.vercel.app/",
"https://e-commercet.vercel.app/",
"https://modifio-g8hd5pz11-my-team-58e3ebda.vercel.app/",
"https://startuppitch-pk7g38l7p-my-team-58e3ebda.vercel.app/",
"https://emre-watch.vercel.app/",
];
const [currentChannel, setCurrentChannel] = useState(0);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
50,
container.clientWidth / container.clientHeight,
0.1,
5000
);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.domElement.style.zIndex = "0";
renderer.domElement.style.position = "absolute";
renderer.domElement.style.top = "0";
renderer.domElement.style.left = "0";
container.appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 1.5);
scene.add(ambient);
const directional = new THREE.DirectionalLight(0xffffff, 2);
directional.position.set(5, 10, 10);
scene.add(directional);
const rendererCSS = new CSS3DRenderer();
rendererCSS.setSize(container.clientWidth, container.clientHeight);
rendererCSS.domElement.style.position = "absolute";
rendererCSS.domElement.style.top = "0";
rendererCSS.domElement.style.left = "0";
rendererCSS.domElement.style.zIndex = "10";
rendererCSS.domElement.style.pointerEvents = "auto";
container.appendChild(rendererCSS.domElement);
const controls = new OrbitControls(camera, rendererCSS.domElement);
controls.enableDamping = true;
controls.enablePan = true;
controls.enableZoom = false;
controls.enableRotate = true;
controlsRef.current = controls;
const onWheel = (e) => {
const target = e.target;
if (container.contains(target) && !iframeRef.current?.contains(target)) {
controls.enabled = true;
} else {
controls.enabled = false;
}
};
container.addEventListener("wheel", onWheel, { passive: true });
const onDocumentClick = (e) => {
if (!container.contains(e.target)) {
controls.enabled = false;
}
};
document.addEventListener("click", onDocumentClick, true);
window.addEventListener("keydown", (e) => {
if (e.key === "=" || e.key === "+") camera.position.z -= 1;
if (e.key === "-") camera.position.z += 1;
controls.update();
});
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let screenMeshObject = null;
let allMeshes = [];
const onPointerDown = (event) => {
if (!container.contains(event.target)) {
controls.enabled = false;
return;
}
mouse.x = (event.clientX / container.clientWidth) * 2 - 1;
mouse.y = -(event.clientY / container.clientHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
let intersects = [];
if (allMeshes.length > 0) {
intersects = raycaster.intersectObjects(allMeshes, true);
} else if (screenMeshObject) {
intersects = raycaster.intersectObject(screenMeshObject);
}
if (intersects.length > 0) {
const hitMesh = intersects[0].object;
if (hitMesh === screenMeshObject) {
controls.enabled = false;
} else {
controls.enabled = true;
}
} else {
controls.enabled = true;
}
};
const onPointerUp = () => {
controls.enabled = true;
};
rendererCSS.domElement.addEventListener("pointerdown", onPointerDown, false);
document.addEventListener("pointerup", onPointerUp, false);
const loader = new GLTFLoader();
loader.load(
"/models/3dtv.glb",
(gltf) => {
const tv = gltf.scene;
const getScale = () => {
const width = window.innerWidth;
if (width < 640) return 0.48;
else if (width < 1024) return 1.2;
else if (width < 1280) return 1.2;
else if (width < 1440) return 1.2;
else if (width < 1920) return 1.3;
else return 1.3;
};
const scale = getScale();
tv.scale.setScalar(scale);
scene.add(tv);
tv.traverse((child) => {
if (child.isMesh) {
allMeshes.push(child);
}
});
const box = new THREE.Box3().setFromObject(tv);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
// CAMERA - Same for all devices
const cameraYOffset = size.y * 0.5;
const cameraDistance = size.z * 2 + 1;
camera.position.copy(
center.clone().add(new THREE.Vector3(0, cameraYOffset, cameraDistance))
);
camera.lookAt(center);
controls.target.copy(center);
controls.update();
let screenMesh = null;
tv.traverse((child) => {
if (child.isMesh && child.name === "TV_49Zoll_Screen1_0") {
screenMesh = child;
screenMeshObject = child;
}
});
const iframe = document.createElement("iframe");
iframe.src = channels[currentChannel];
iframe.style.border = "0";
iframe.style.pointerEvents = "auto";
iframe.style.zIndex = "100";
iframe.style.background = "white";
iframe.style.overflow = "hidden";
iframe.style.position = "absolute";
iframe.style.top = "0";
iframe.style.left = "0";
iframe.style.opacity = "0";
iframe.style.transition = "opacity 0.5s ease-in-out";
iframe.style.willChange = "opacity";
iframe.style.display = "block";
iframe.allow = "autoplay; fullscreen";
iframeRef.current = iframe;
iframe.onload = () => {
iframe.style.opacity = "1";
};
const iframeObj = new CSS3DObject(iframe);
cssObjectRef.current = iframeObj;
if (screenMesh) {
const screenBox = new THREE.Box3().setFromObject(screenMesh);
const screenCenter = screenBox.getCenter(new THREE.Vector3());
const screenSize = screenBox.getSize(new THREE.Vector3());
// =====================================================
// PERFECT SCALEFACTOR CALCULATION
// =====================================================
// Laptop L Reference (PERFECT):
// - Viewport: 1440px
// - Model scale: 1.3
// - ScaleFactor: 1200
// - Iframe style: 1716px × 983px
// - Iframe displayed: 1058px × 507px
// - Display ratio: 1058 / 1716 = 0.6166
// - Viewport ratio: 1058 / 1440 = 0.735 (73.5% of viewport)
const LAPTOP_L_VIEWPORT = 1440;
const LAPTOP_L_SCALE = 1.3;
const LAPTOP_L_SCALEFACTOR = 1200;
const LAPTOP_L_VIEWPORT_RATIO = 0.735; // iframe is 73.5% of viewport
const currentViewport = window.innerWidth;
const currentScale = scale;
// Target: Mobile iframe should be 73.5% of viewport (same as Laptop L)
const targetDisplayWidth = currentViewport * LAPTOP_L_VIEWPORT_RATIO;
// CSS3D rendering equation:
// displayedSize = (styleSize / scaleFactor) × perspectiveFactor
// perspectiveFactor depends on camera distance and FOV
// From Laptop L we know:
// 1058 = (1716 / 1200) × perspectiveFactor
// perspectiveFactor = 1058 / (1716 / 1200) = 1058 / 1.43 = 739.86
// But this perspectiveFactor changes with model scale!
// When model is smaller (0.48 vs 1.3), everything in 3D space is smaller
// So we need to adjust scaleFactor by the INVERSE of scale ratio
const scaleRatio = currentScale / LAPTOP_L_SCALE; // 0.48 / 1.3 = 0.369
// For mobile to have same visual size as Laptop L:
// We need scaleFactor that produces 73.5% viewport width
// Formula:
// scaleFactor = (screenSize.x × LAPTOP_L_SCALEFACTOR) / (targetDisplayWidth / currentViewport × LAPTOP_L_VIEWPORT)
// Simplified:
// scaleFactor = LAPTOP_L_SCALEFACTOR × (LAPTOP_L_VIEWPORT / currentViewport) × (currentScale / LAPTOP_L_SCALE)^-1
const getScaleFactor = () => {
// KEEP SCALEFACTOR SAME AS LAPTOP L FOR ALL DEVICES
// This keeps iframe size perfect relative to model
return 1000; // Same for everyone!
};
const scaleFactor = getScaleFactor();
console.log("🎯 SCALE INFO:", {
viewport: window.innerWidth,
modelScale: scale,
scaleFactor: scaleFactor,
css3dScale: (1 / scaleFactor).toFixed(6),
});
iframe.style.width = `${screenSize.x * scaleFactor}px`;
iframe.style.height = `${screenSize.y * scaleFactor}px`;
// 🔥 FIX THE ZOOM ISSUE HERE! 🔥
// Scale DOWN the iframe CONTENT (not the iframe element itself)
const isMobile = window.innerWidth < 1024;
if (isMobile) {
// Mobile/Tablet: Scale down the iframe content
// This fixes the "zoomed in" problem!
const contentScale = 0.5; // 50% = show 2x more content
iframe.style.transform = `scale(${contentScale})`;
iframe.style.transformOrigin = '0 0'; // Scale from top-left
// Increase iframe size to compensate for scale
iframe.style.width = `${screenSize.x * scaleFactor / contentScale}px`;
iframe.style.height = `${screenSize.y * scaleFactor / contentScale}px`;
console.log("📱 Mobile content scale applied:", contentScale);
}
iframeObj.position.copy(screenCenter);
iframeObj.position.z += 0.01;
iframeObj.rotation.copy(screenMesh.rotation);
iframeObj.scale.set(
1 / scaleFactor,
1 / scaleFactor,
1 / scaleFactor
);
screenMesh.material.opacity = 0;
screenMesh.material.transparent = true;
}
scene.add(iframeObj);
const screenLight = new THREE.PointLight(0xffffff, 2, 10);
screenLight.position.copy(iframeObj.position);
screenLight.position.z += 0.1;
scene.add(screenLight);
// Position check
setTimeout(() => {
const iframe = document.querySelector("iframe");
const rect = iframe.getBoundingClientRect();
console.log("📍 FINAL RESULT:");
console.log("═".repeat(50));
console.log(`Iframe Position: ${rect.left.toFixed(0)}px, ${rect.top.toFixed(0)}px`);
console.log(`Iframe Size: ${rect.width.toFixed(0)}px × ${rect.height.toFixed(0)}px`);
console.log(`Viewport Coverage: ${((rect.width / window.innerWidth) * 100).toFixed(1)}%`);
console.log(`Visible: ${rect.top < window.innerHeight && rect.bottom > 0 ? "✅ YES" : "❌ NO"}`);
console.log("═".repeat(50));
}, 200);
},
undefined,
(err) => console.error("Model error:", err)
);
const changeChannel = (index) => {
if (iframeRef.current && channels[index]) {
iframeRef.current.style.opacity = "0";
iframeRef.current.onload = () => {
if (iframeRef.current) {
iframeRef.current.style.opacity = "1";
}
};
iframeRef.current.src = channels[index];
setCurrentChannel(index);
}
};
window.changeTVChannel = changeChannel;
const animate = () => {
requestAnimationFrame(animate);
controls.update();
if (cssObjectRef.current) {
cssObjectRef.current.element.style.visibility = "visible";
cssObjectRef.current.element.style.opacity = "1";
}
renderer.render(scene, camera);
rendererCSS.render(scene, camera);
};
animate();
const handleResize = () => {
if (!containerRef.current) return;
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
rendererCSS.setSize(width, height);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
container.removeEventListener("wheel", onWheel);
document.removeEventListener("click", onDocumentClick, true);
rendererCSS.domElement.removeEventListener("pointerdown", onPointerDown, false);
document.removeEventListener("pointerup", onPointerUp, false);
delete window.changeTVChannel;
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
if (container.contains(rendererCSS.domElement)) {
container.removeChild(rendererCSS.domElement);
}
};
}, [currentChannel, channels]);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return (
<div
ref={containerRef}
style={{
width: "100%",
height: "100vh",
minHeight: "600px",
position: "relative",
overflow: "hidden",
margin: "0 auto",
display: "flex",
justifyContent: "center",
alignItems: "center",
touchAction: "manipulation",
}}
>
<div
style={{
position: "absolute",
bottom: "clamp(20px, 5vw, 40px)",
left: "50%",
transform: "translateX(-50%)",
background: "rgba(15, 15, 15, 0.95)",
backdropFilter: "blur(12px)",
padding: "clamp(12px, 3vw, 16px) clamp(20px, 5vw, 28px)",
borderRadius: "20px",
display: "flex",
gap: "clamp(8px, 2vw, 10px)",
zIndex: 100,
boxShadow:
"0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1)",
fontFamily: "system-ui, -apple-system, sans-serif",
border: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
{channels.map((_, index) => (
<button
key={index}
onClick={() =>
window.changeTVChannel && window.changeTVChannel(index)
}
aria-label={Switch to channel ${index + 1}}
title={Channel ${index + 1}}
style={{
padding: "clamp(10px, 2.5vw, 12px) clamp(16px, 4vw, 20px)",
background:
currentChannel === index
? "linear-gradient(135deg, #00ff88 0%, #00cc6a 100%)"
: "rgba(255, 255, 255, 0.1)",
color: currentChannel === index ? "#000" : "#fff",
border:
currentChannel === index
? "none"
: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "12px",
cursor: "pointer",
fontWeight: "600",
fontSize: "clamp(13px, 3.5vw, 15px)",
minWidth: "clamp(45px, 10vw, 50px)",
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
transform: currentChannel === index ? "scale(1.05)" : "scale(1)",
boxShadow:
currentChannel === index
? "0 4px 16px rgba(0, 255, 136, 0.4)"
: "none",
}}
onMouseOver={(e) => {
if (currentChannel !== index) {
e.currentTarget.style.background = "rgba(255, 255, 255, 0.15)";
e.currentTarget.style.transform = "scale(1.05)";
}
}}
onMouseOut={(e) => {
if (currentChannel !== index) {
e.currentTarget.style.background = "rgba(255, 255, 255, 0.1)";
e.currentTarget.style.transform = "scale(1)";
}
}}
>
{index + 1}
))}
);
}
Live example
https://my-llty0t7zb-my-team-58e3ebda.vercel.app
https://my-8dwd4ivt0-my-team-58e3ebda.vercel.app
https://my-jw9b6ajtk-my-team-58e3ebda.vercel.app
https://my-a5axzavq7-my-team-58e3ebda.vercel.app
Screenshots
Version
^0.180.0
Device
Mobile
Browser
No response
OS
No response