Skip to content

100$ CSS3DRenderer iframe positioning breaks on mobile/tablet due to scroll offset not being accounted for #32723

@emreyn1

Description

@emreyn1

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

  1. Adjusting position.y with scroll offset:

    • Calculated totalOffset = window.scrollY + container.getBoundingClientRect().top
    • Applied: css3dObject.position.y = baseY - (totalOffset * multiplier)
    • ❌ Doesn't work because CSS3DRenderer overrides position.y every frame
  2. Adding translateY to CSS transform after render:

    • Used requestAnimationFrame and setTimeout to apply transform after CSS3DRenderer
    • Applied: iframe.style.transform = '${matrix3d} translateY(-${totalOffset}px)'
    • ❌ Doesn't work because CSS3DRenderer resets transform every frame
  3. Using getBoundingClientRect() for offset calculation:

    • Values are correct (verified in console)
    • But can't be applied effectively due to CSS3DRenderer's render cycle
  4. 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

Image Image

Version

^0.180.0

Device

Mobile

Browser

No response

OS

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions