Skip to content

e-Fuse/elearning-device-detective

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 

Repository files navigation

device-router.js

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.


The problem

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.


How it works

Detection

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.

Setting the attribute

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.

Why synchronous in <head>

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.

Re-detection

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

Installation

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.


Using the attribute in CSS

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;
}

Using the flag in JavaScript

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 });

Full example: split navigation

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 });

Why not just use CSS media queries?

You can get a long way with @media (max-width: 767px). The reason this pattern exists instead:

  1. 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.

  2. pointer: coarse vs max-width. A tablet in landscape is often > 768px wide but still a touch device. pointer: coarse catches it; max-width doesn't.

  3. Single source of truth. Both CSS and JS read the same data-device attribute. There's no risk of the media query threshold in CSS diverging from the breakpoint check in JS.

  4. 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: coarse detects the input device regardless of how the iframe is sized.


Why not user-agent sniffing?

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).


Browser support

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.


Background

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.


Files

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.


Licence

MIT. Built by e-Fuse.

About

Built for the e-Fuse internal eLearning platform. Extracted here as a standalone utility because the problem it solves comes up constantly in SCORM/xAPI authoring and HTML-based courseware.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors