Skip to content

Instantly share code, notes, and snippets.

@mike-at-redspace
Created January 30, 2026 10:22
Show Gist options
  • Select an option

  • Save mike-at-redspace/5b6b9af79911cccfdb744d65469b41bb to your computer and use it in GitHub Desktop.

Select an option

Save mike-at-redspace/5b6b9af79911cccfdb744d65469b41bb to your computer and use it in GitHub Desktop.
iFrame Viewport Emulator
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")
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();
});
<script src="https://cdn.skypack.dev/css-device-frames"></script>
@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