A 50-line vanilla JavaScript snippet for detecting mobile vs desktop in HTML-based eLearning modules — without user-agent sniffing, without a build step, and without layout flash.
Extracted as a standalone utility because the problem it solves comes up constantly in SCORM/xAPI authoring and HTML-based courseware.
SCORM modules are HTML pages delivered inside an LMS iframe. You can't control what device the learner opens them on, and you often can't rely on the LMS to tell you. Many eLearning audiences split cleanly between people on desktop and people on mobile — and those two groups benefit from completely different UI patterns:
- Desktop: multi-column layouts, hover states, keyboard navigation, larger content density
- Mobile: full-viewport cards, swipe gestures, bottom navigation bar, larger tap targets, no hover
Standard CSS @media (max-width: 768px) gets you responsive layouts. It doesn't get you a genuinely different experience — different interaction model, different navigation paradigm, different component behaviour.
device-router.js sets a single HTML attribute that becomes the hook for all of that divergence: layout, interaction, animation, navigation.
Two signals, either of which triggers the mobile experience:
pointer: coarse → the primary pointing device is a finger (touch screen)
innerWidth ≤ 767 → viewport is phone-width even if not a touch device
pointer: coarse is the reliable one. It's a CSS Level 4 media feature that detects the precision of the input device — coarse means finger, fine means mouse. It correctly identifies:
- iPhones and Android phones ✓
- iPads and Android tablets ✓
- Touch-screen laptops (Surface, etc.) — treated as mobile ✓
- Desktop browsers with DevTools responsive mode — treated as mobile ✓
The innerWidth check is a fallback for edge cases: very narrow browser windows on desktop, or LMS iframes that are constrained to a small fixed size.
The script sets data-device="mobile" or data-device="desktop" on the <html> element:
<html lang="en" data-device="mobile">This one attribute becomes the scope prefix for everything else in the stylesheet.
The script loads with no defer or async — it blocks the parser for ~1ms. This is intentional. By the time the browser applies any CSS, data-device is already set. If the script ran deferred (after paint), learners on mobile would briefly see the desktop layout before it snapped to mobile — a flash that's especially visible on first load in an LMS.
The script re-runs on resize (debounced at 200ms) and orientationchange (delayed 150ms to let the viewport settle). This means:
- Rotating phone from portrait to landscape updates the attribute live
- Opening DevTools and toggling device mode works during development
- LMS iframes that resize dynamically (rare but it happens) stay accurate
Drop the script into <head> before your stylesheets:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Must come before CSS so the attribute is set before styles apply -->
<script src="device-router.js"></script>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="mobile.css">
</head>That's it. No npm, no bundler, no dependencies.
All mobile overrides live in their own file (mobile.css) scoped with the [data-device="mobile"] prefix. Desktop styles are completely untouched.
/* mobile.css */
/* App shell — fixed-height viewport, no body scroll */
[data-device="mobile"] body {
overflow: hidden;
}
[data-device="mobile"] .module-wrapper {
height: 100dvh;
display: flex;
flex-direction: column;
}
/* Show mobile nav, hide desktop nav */
.mobile-nav {
display: none;
}
[data-device="mobile"] .mobile-nav {
display: flex;
}
[data-device="mobile"] .desktop-footer-nav {
display: none !important;
}
/* Component adjustments */
[data-device="mobile"] .flipcard-grid {
grid-template-columns: repeat(2, 1fr);
}
[data-device="mobile"] .quiz-option {
min-height: 56px; /* larger tap target */
}Because [data-device="mobile"] adds an attribute selector (specificity 0,1,0), it wins over most base class rules (specificity 0,1,0 or lower) without needing !important. The exception is the inline <style> block common in SCORM templates — use a class + attribute combination there to beat it cleanly:
/* Beats .screen.active { animation: fadeIn ... } in an inline <style> */
[data-device="mobile"] .screen.active {
animation: slideIn 480ms cubic-bezier(0,0,0.2,1) forwards;
}The script also sets window.DEVICE for runtime checks:
// Swipe gestures — mobile only
if (window.DEVICE === 'mobile') {
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchend', handleTouchEnd, { passive: true });
}
// Different analytics event
analytics.track('module_opened', { device: window.DEVICE });This is the pattern used in the Movember modules. The HTML contains both nav elements; CSS shows the right one based on data-device.
HTML — both navs always in the DOM:
<!-- Desktop: sticky footer with Back / Continue buttons -->
<footer class="desktop-nav">
<button data-action="prev">← Back</button>
<button data-action="next">Continue →</button>
</footer>
<!-- Mobile: fixed bottom bar with chevrons + progress -->
<nav class="mobile-nav">
<button data-action="prev" aria-label="Previous">‹</button>
<div class="mobile-nav__progress">
<span id="navLabel">1 / 9</span>
<div class="progress-bar"><div id="navFill" style="width:11%"></div></div>
</div>
<button data-action="next" aria-label="Next">›</button>
</nav>CSS — each nav hidden by default, revealed by data-device:
.mobile-nav { display: none; }
.desktop-nav { display: flex; }
[data-device="mobile"] .mobile-nav { display: flex; }
[data-device="mobile"] .desktop-nav { display: none !important; }JS — swipe handler wired up only on mobile:
var touchStartX, touchStartY;
document.addEventListener('touchstart', function (e) {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
}, { passive: true });
document.addEventListener('touchend', function (e) {
var dx = e.changedTouches[0].screenX - touchStartX;
var dy = e.changedTouches[0].screenY - touchStartY;
if (Math.abs(dx) < 60) return; // too short
if (Math.abs(dy) > Math.abs(dx) / 1.5) return; // mostly vertical = scroll
if (dx < 0) goNext(); // swipe left → advance
else goPrev(); // swipe right → back
}, { passive: true });You can get a long way with @media (max-width: 767px). The reason this pattern exists instead:
-
Interaction logic, not just layout. Swipe gestures, touch-specific event handlers, and different animation directions need a JS flag — media queries can't drive those.
-
pointer: coarsevsmax-width. A tablet in landscape is often > 768px wide but still a touch device.pointer: coarsecatches it;max-widthdoesn't. -
Single source of truth. Both CSS and JS read the same
data-deviceattribute. There's no risk of the media query threshold in CSS diverging from the breakpoint check in JS. -
LMS iframe constraints. Some LMSes deliver SCORM content in fixed-size iframes. The viewport width might be 1024px regardless of the learner's actual device, making CSS breakpoints unreliable.
pointer: coarsedetects the input device regardless of how the iframe is sized.
UA strings are unreliable, increasingly spoofed, and don't reflect capability — they reflect browser identity. pointer: coarse is a capability signal: it directly answers "does the user interact with touch?" without any string parsing. It's supported in all modern browsers and degrades gracefully (the matchMedia call is guarded with a !! check).
| Feature | Support |
|---|---|
matchMedia |
All modern browsers, IE10+ |
pointer: coarse media feature |
Chrome 41+, Firefox 64+, Safari 9+, Edge 12+ |
data-* attributes |
Universal |
orientationchange event |
All mobile browsers |
For IE9 and below, matchMedia is absent — the script falls back gracefully to the innerWidth check only, which still catches narrow viewports.
This utility was built for an internal eLearning platform — a set of SCORM 1.2-compliant HTML modules delivered to employees through an LMS.
The learner population split into two groups with different device patterns: one group predominantly on desktop in an office environment, another predominantly on mobile in the field. Rather than author and maintain two versions of each module, the platform uses a single HTML file. device-router.js detects the device at load time, sets the attribute, and lets CSS and JavaScript branch from there. The result is a desktop experience that feels like a web course and a mobile experience that feels like a native app — from the same source file.
device-router.js — The detection script (load in <head>, no defer/async)
README.md — This file
The companion CSS pattern (mobile.css) and swipe gesture handling are described in the examples above. They're intentionally not packaged here — they're application-level code that you'll write to match your own module structure.
MIT. Built by e-Fuse.