A Pen by mike-at-redspace on CodePen.
Created
January 30, 2026 10:22
-
-
Save mike-at-redspace/5b6b9af79911cccfdb744d65469b41bb to your computer and use it in GitHub Desktop.
iFrame Viewport Emulator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| header | |
| select#device | |
| .custom-controls(style="display: none") | |
| label(for="customWidth") Width: | |
| input#customWidth(type="number", min="100", value="390") | |
| label(for="customHeight") Height: | |
| input#customHeight(type="number", min="100", value="844") | |
| button#rotate Rotate | |
| input#url(type="text", placeholder="Enter URL", value="https://es-d-7154511120260130-019c04d5-d578-7ba2-9380-ef8f489fc48b.codepen.dev") | |
| button#load Load | |
| button#zoomOut − | |
| button#zoomIn + | |
| button#zoomReset Fit | |
| span#zoomLevel 100% | |
| main | |
| span#size -- | |
| #stage.stage | |
| .device-container | |
| iframe#preview(src="about:blank") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| document.addEventListener("DOMContentLoaded", () => { | |
| const CONFIG = { | |
| padding: 128, | |
| headerHeight: 56, | |
| zoomStep: 0.1, | |
| minZoom: 0.1, | |
| maxZoom: 3.0, | |
| resizeDelay: 150, | |
| defaultUrl: "https://es-d-7154511120260130-019c04d5-d578-7ba2-9380-ef8f489fc48b.codepen.dev", | |
| defaultDevice: "iphoneProMax" | |
| }; | |
| const DEVICES = { | |
| // --- Phones (iOS) --- | |
| iphoneSE: { w: 375, h: 667, rotatable: true, name: "iPhone SE" }, | |
| iphone14: { w: 390, h: 844, rotatable: true, name: "iPhone 14 / 15" }, | |
| iphonePro: { w: 393, h: 852, rotatable: true, name: "iPhone Pro" }, | |
| iphoneProMax: { w: 430, h: 932, rotatable: true, name: "iPhone Pro Max" }, | |
| // --- Phones (Android) --- | |
| androidSmall: { w: 360, h: 740, rotatable: true, name: "Android Small" }, | |
| androidMedium: { w: 412, h: 915, rotatable: true, name: "Android Medium" }, | |
| androidLarge: { w: 480, h: 1040, rotatable: true, name: "Android Large" }, | |
| // --- Foldables --- | |
| foldClosed: { w: 390, h: 850, rotatable: true, name: "Foldable (Closed)" }, | |
| foldOpen: { w: 768, h: 1024, rotatable: true, name: "Foldable (Open)" }, | |
| // --- Tablets --- | |
| ipadMini: { w: 768, h: 1024, rotatable: true, name: "iPad Mini" }, | |
| ipad: { w: 810, h: 1080, rotatable: true, name: "iPad" }, | |
| ipadAir: { w: 820, h: 1180, rotatable: true, name: "iPad Air" }, | |
| ipadPro11: { w: 834, h: 1194, rotatable: true, name: "iPad Pro 11″" }, | |
| ipadPro13: { w: 1024, h: 1366, rotatable: true, name: "iPad Pro 13″" }, | |
| // --- Laptops (Fixed Orientation) --- | |
| macbookAir: { w: 1440, h: 900, rotatable: false, name: "MacBook Air" }, | |
| macbookPro14: { w: 1512, h: 982, rotatable: false, name: "MacBook Pro 14″" }, | |
| macbookPro16: { w: 1728, h: 1117, rotatable: false, name: "MacBook Pro 16″" }, | |
| windowsLaptop: { w: 1366, h: 768, rotatable: false, name: "Windows Laptop" }, | |
| // --- Desktops (Fixed Orientation) --- | |
| desktopHD: { w: 1920, h: 1080, rotatable: false, name: "Full HD Desktop" }, | |
| desktopQHD: { w: 2560, h: 1440, rotatable: false, name: "2K / QHD Display" }, | |
| desktop4K: { w: 3840, h: 2160, rotatable: false, name: "4K Display" }, | |
| desktop5K: { w: 5120, h: 2880, rotatable: false, name: "5K Display" }, | |
| desktop8K: { w: 7680, h: 4320, rotatable: false, name: "8K Display" }, | |
| // --- Ultrawide (Fixed Orientation) --- | |
| ultrawide34: { w: 3440, h: 1440, rotatable: false, name: "34″ Ultrawide" }, | |
| ultrawide49: { w: 5120, h: 1440, rotatable: false, name: "49″ Super Ultrawide" }, | |
| // --- Custom --- | |
| custom: { w: 390, h: 844, rotatable: true, name: "Custom" } | |
| }; | |
| const state = { | |
| zoom: 1, | |
| currentDevice: CONFIG.defaultDevice, | |
| isLandscape: false, | |
| }; | |
| const elements = { | |
| stage: document.getElementById("stage"), | |
| frame: document.querySelector(".device-container"), | |
| iframe: document.getElementById("preview"), | |
| // Controls | |
| deviceSelect: document.getElementById("device"), | |
| rotateBtn: document.getElementById("rotate"), | |
| urlInput: document.getElementById("url"), | |
| loadBtn: document.getElementById("load"), | |
| // Custom Dimensions | |
| customControls: document.querySelector(".custom-controls"), | |
| customWidth: document.getElementById("customWidth"), | |
| customHeight: document.getElementById("customHeight"), | |
| // Info / Status | |
| sizeSpan: document.getElementById("size"), | |
| zoomLevel: document.getElementById("zoomLevel"), | |
| // Zoom Buttons | |
| zoomInBtn: document.getElementById("zoomIn"), | |
| zoomOutBtn: document.getElementById("zoomOut"), | |
| zoomResetBtn: document.getElementById("zoomReset") | |
| }; | |
| /** | |
| * Initialize the application | |
| */ | |
| function init() { | |
| buildDeviceSelect(); | |
| applyDevice(CONFIG.defaultDevice); | |
| loadUrl(); | |
| setupEventListeners(); | |
| } | |
| /** | |
| * Populate the device dropdown with categorized options | |
| */ | |
| function buildDeviceSelect() { | |
| const select = elements.deviceSelect; | |
| select.innerHTML = ""; | |
| const categories = { | |
| Phones: /^(iphone|android)/i, | |
| Foldables: /^fold/i, | |
| Tablets: /^ipad/i, | |
| Laptops: /macbook|windowsLaptop/i, | |
| Desktops: /^desktop/i, | |
| Ultrawide: /^ultrawide/i, | |
| Custom: /^custom/i | |
| }; | |
| Object.entries(categories).forEach(([groupName, regex]) => { | |
| const keys = Object.keys(DEVICES).filter(key => regex.test(key)); | |
| if (!keys.length) return; | |
| const optgroup = document.createElement("optgroup"); | |
| optgroup.label = groupName; | |
| keys.forEach(key => { | |
| const option = document.createElement("option"); | |
| option.value = key; | |
| if (CONFIG.defaultDevice == key) { | |
| option.selected = true; | |
| } | |
| option.textContent = DEVICES[key].name; | |
| optgroup.appendChild(option); | |
| }); | |
| select.appendChild(optgroup); | |
| }); | |
| } | |
| /** | |
| * Switch the active device context. | |
| * Handles logic for enabling/disabling controls based on device capabilities. | |
| * * @param {string} deviceKey - Key from DEVICES object | |
| * @param {boolean} skipBestFit - If true, prevents auto-zooming (useful during rotation anims) | |
| */ | |
| function applyDevice(deviceKey, skipBestFit = false) { | |
| const device = DEVICES[deviceKey]; | |
| if (!device) return; | |
| state.currentDevice = deviceKey; | |
| // Force Portrait if the new device does not support rotation (e.g., Laptops) | |
| if (!device.rotatable) { | |
| state.isLandscape = false; | |
| } | |
| // UI Updates | |
| elements.rotateBtn.style.display = device.rotatable ? "block" : "none"; | |
| elements.customControls.style.display = deviceKey === "custom" ? "flex" : "none"; | |
| updateRotateButtonText(); | |
| // Dimension Calculations | |
| const { width, height } = getDimensions(deviceKey, device); | |
| // Apply Dimensions (using rAF for smoother rendering) | |
| requestAnimationFrame(() => { | |
| elements.frame.style.width = `${width}px`; | |
| elements.frame.style.height = `${height}px`; | |
| elements.sizeSpan.textContent = `${width} × ${height}`; | |
| if (!skipBestFit) { | |
| bestFit(); | |
| } | |
| }); | |
| } | |
| /** | |
| * Calculate effective dimensions based on rotation state and custom inputs. | |
| */ | |
| function getDimensions(deviceKey, device) { | |
| let width = device.w; | |
| let height = device.h; | |
| // Handle Custom Inputs | |
| if (deviceKey === "custom") { | |
| width = Math.max(100, parseInt(elements.customWidth.value) || 390); | |
| height = Math.max(100, parseInt(elements.customHeight.value) || 844); | |
| } | |
| // Swap if Landscape | |
| if (state.isLandscape && device.rotatable) { | |
| return { width: height, height: width }; | |
| } | |
| return { width, height }; | |
| } | |
| /** | |
| * Calculates the optimal zoom to fit the device within the viewport | |
| * without scrolling. relies on CSS Flexbox for centering. | |
| */ | |
| function bestFit() { | |
| const availableWidth = window.innerWidth - CONFIG.padding; | |
| const availableHeight = window.innerHeight - CONFIG.headerHeight - CONFIG.padding; | |
| const device = DEVICES[state.currentDevice]; | |
| const { width, height } = getDimensions(state.currentDevice, device); | |
| // Calculate scale ratio | |
| const scale = Math.min( | |
| availableWidth / width, | |
| availableHeight / height, | |
| 1 // Never auto-zoom larger than actual size | |
| ); | |
| setZoom(scale); | |
| showButtonFeedback(elements.zoomResetBtn); | |
| } | |
| /** | |
| * Applies the CSS transform for scaling. | |
| * Note: No manual top/left positioning is needed as CSS Flexbox handles centering. | |
| */ | |
| function setZoom(newZoom) { | |
| // Clamp zoom levels | |
| state.zoom = Math.max(CONFIG.minZoom, Math.min(CONFIG.maxZoom, newZoom)); | |
| // Apply scale | |
| elements.stage.style.transform = `scale(${state.zoom})`; | |
| elements.zoomLevel.textContent = `${Math.round(state.zoom * 100)}%`; | |
| updateZoomButtons(); | |
| } | |
| function adjustZoom(delta) { | |
| setZoom(state.zoom + delta); | |
| } | |
| /** | |
| * Toggles orientation only if the device allows it. | |
| */ | |
| function toggleRotation() { | |
| const device = DEVICES[state.currentDevice]; | |
| // Guard Clause: Prevent rotation on unsupported devices | |
| if (!device || !device.rotatable) return; | |
| state.isLandscape = !state.isLandscape; | |
| updateRotateButtonText(); | |
| showButtonFeedback(elements.rotateBtn); | |
| // Animate the transition | |
| elements.frame.style.transition = "width 0.3s ease, height 0.3s ease"; | |
| // Apply dimensions, skip bestFit immediately to allow animation start | |
| applyDevice(state.currentDevice, true); | |
| // Re-fit after animation finishes | |
| setTimeout(() => { | |
| elements.frame.style.transition = ""; | |
| bestFit(); | |
| }, 100); | |
| } | |
| function loadUrl() { | |
| let url = elements.urlInput.value.trim() || CONFIG.defaultUrl; | |
| if (!/^https?:\/\//i.test(url)) { | |
| url = "https://" + url; | |
| } | |
| // Basic URL validation | |
| try { | |
| new URL(url); | |
| elements.iframe.src = url; | |
| elements.urlInput.value = url; // Update input if protocol was added | |
| } catch (e) { | |
| alert("Please enter a valid URL."); | |
| } | |
| } | |
| function updateRotateButtonText() { | |
| const icon = state.isLandscape ? "↻" : "↺"; | |
| const orientation = state.isLandscape ? "Portrait" : "Landscape"; | |
| elements.rotateBtn.innerHTML = `${icon} ${orientation}`; | |
| } | |
| function updateZoomButtons() { | |
| elements.zoomInBtn.disabled = state.zoom >= CONFIG.maxZoom; | |
| elements.zoomOutBtn.disabled = state.zoom <= CONFIG.minZoom; | |
| } | |
| function showButtonFeedback(btn) { | |
| btn.classList.add("active"); | |
| setTimeout(() => btn.classList.remove("active"), 200); | |
| } | |
| function setupEventListeners() { | |
| // Device Selection | |
| elements.deviceSelect.addEventListener("change", (e) => { | |
| applyDevice(e.target.value); | |
| }); | |
| // Rotation | |
| elements.rotateBtn.addEventListener("click", toggleRotation); | |
| // Custom Inputs | |
| ['input', 'change'].forEach(evt => { | |
| elements.customWidth.addEventListener(evt, () => state.currentDevice === 'custom' && applyDevice('custom')); | |
| elements.customHeight.addEventListener(evt, () => state.currentDevice === 'custom' && applyDevice('custom')); | |
| }); | |
| // URL Loading | |
| elements.loadBtn.addEventListener("click", loadUrl); | |
| elements.urlInput.addEventListener("keypress", (e) => { | |
| if (e.key === "Enter") loadUrl(); | |
| }); | |
| // Zoom Controls | |
| elements.zoomInBtn.addEventListener("click", () => { | |
| adjustZoom(CONFIG.zoomStep); | |
| showButtonFeedback(elements.zoomInBtn); | |
| }); | |
| elements.zoomOutBtn.addEventListener("click", () => { | |
| adjustZoom(-CONFIG.zoomStep); | |
| showButtonFeedback(elements.zoomOutBtn); | |
| }); | |
| elements.zoomResetBtn.addEventListener("click", bestFit); | |
| // Window Resize (Debounced) | |
| let resizeTimer; | |
| window.addEventListener("resize", () => { | |
| clearTimeout(resizeTimer); | |
| resizeTimer = setTimeout(bestFit, CONFIG.resizeDelay); | |
| }); | |
| // Keyboard Shortcuts | |
| document.addEventListener("keydown", (e) => { | |
| if (e.ctrlKey || e.metaKey) { | |
| if (e.key === "=" || e.key === "+") { | |
| e.preventDefault(); | |
| adjustZoom(CONFIG.zoomStep); | |
| showButtonFeedback(elements.zoomInBtn); | |
| } else if (e.key === "-") { | |
| e.preventDefault(); | |
| adjustZoom(-CONFIG.zoomStep); | |
| showButtonFeedback(elements.zoomOutBtn); | |
| } else if (e.key === "0") { | |
| e.preventDefault(); | |
| bestFit(); | |
| } | |
| } | |
| }); | |
| } | |
| init(); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script src="https://cdn.skypack.dev/css-device-frames"></script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&family=Poppins:wght@600;700&display=swap"); | |
| :root { | |
| --bg: linear-gradient(100deg, color(display-p3 0.11 0.57 0.99) 14%, color(display-p3 0.35 0.21 0.75) 98%); | |
| --panel: rgba(0, 0, 0, 0.4); | |
| --border: rgba(255, 255, 255, 0.15); | |
| --text: #e6e8eb; | |
| --text-muted: #a1a1aa; | |
| --font-sans: "Inter", system-ui, -apple-system, sans-serif; | |
| --font-mono: "JetBrains Mono", monospace; | |
| --font-heading: "Poppins", system-ui, sans-serif; | |
| --space-1: 4px; | |
| --space-2: 8px; | |
| --space-3: 12px; | |
| --space-4: 16px; | |
| --h-header: 60px; | |
| --h-input: 36px; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| margin: 0; | |
| height: 100%; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: var(--font-sans); | |
| overflow: hidden; | |
| } | |
| main { | |
| height: calc(100% - var(--h-header)); | |
| width: 100%; | |
| position: relative; | |
| overflow: hidden; | |
| .stage { | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transform-origin: center; | |
| transition: transform 240ms cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| } | |
| .device-container { | |
| width: 390px; | |
| height: 844px; | |
| padding: var(--space-3); | |
| flex-shrink: 0; | |
| background: #111; | |
| border: 2px solid #555; | |
| border-radius: 20px; | |
| box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.6); | |
| iframe { | |
| width: 100%; | |
| height: 100%; | |
| border: none; | |
| background: #fff; | |
| display: block; | |
| } | |
| } | |
| header { | |
| height: var(--h-header); | |
| padding: 0 var(--space-4); | |
| gap: var(--space-1); | |
| z-index: 20; | |
| display: flex; | |
| align-items: center; | |
| background: var(--panel); | |
| border-bottom: 1px solid var(--border); | |
| backdrop-filter: blur(8px); | |
| strong { | |
| font-family: var(--font-heading); | |
| font-weight: 600; | |
| margin-right: var(--space-1); | |
| } | |
| } | |
| #zoomLevel, | |
| select, | |
| button, | |
| input, | |
| .badge { | |
| height: var(--h-input); | |
| padding: 0 var(--space-3); | |
| background: rgba(0, 0, 0, 0.5); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: var(--space-2); | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| line-height: normal; | |
| transition: all 0.2s ease; | |
| &:hover { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-color: rgba(255, 255, 255, 0.3); | |
| } | |
| } | |
| button, | |
| .badge, | |
| #zoomLevel { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| input, | |
| select { | |
| padding-block: 0; | |
| padding-inline: var(--space-3); | |
| &:focus { | |
| outline: none; | |
| border-color: #3b82f6; | |
| background-color: rgba(0, 0, 0, 0.8); | |
| } | |
| } | |
| select#device { | |
| cursor: pointer; | |
| width: fit-content; | |
| } | |
| input { | |
| &[type="number"] { | |
| width: 80px; | |
| } | |
| &#url { | |
| width: 100%; | |
| } | |
| } | |
| button { | |
| &:active, | |
| &.active { | |
| transform: scale(0.96); | |
| background-color: rgba(59, 130, 246, 0.3); | |
| border-color: #3b82f6; | |
| color: #fff; | |
| } | |
| &#zoomIn, | |
| &#zoomOut { | |
| min-width: var(--h-input); | |
| padding: 0; | |
| font-size: 16px; | |
| } | |
| &#zoomReset { | |
| min-width: 60px; | |
| } | |
| &#rotate { | |
| min-width: 120px; | |
| text-align: center; | |
| } | |
| } | |
| #zoomLevel { | |
| border-color: transparent; | |
| color: var(--text-muted); | |
| } | |
| #size { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(0, 0, 0, 0.5); | |
| border: 1px solid transparent; | |
| color: var(--text-muted); | |
| border-radius: var(--space-2); | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| position: absolute; | |
| bottom: var(--space-2); | |
| right: var(--space-2); | |
| height: 32px; | |
| padding: 0 var(--space-3); | |
| pointer-events: none; | |
| } | |
| .custom-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: var(--space-2); | |
| padding-left: var(--space-3); | |
| margin-left: var(--space-1); | |
| border-left: 1px solid var(--border); | |
| } | |
| label { | |
| font-family: var(--font-sans); | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| margin-right: var(--space-1); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment