15. Hybrid Mobile and Progressive Web Applications
Most people now reach the Web from a phone, and many expect "an app" rather than "a website". This chapter covers the ways web technologies (HTML, CSS and JavaScript) reach mobile users, from the plain responsive site to the app sold in a store. It starts with the strategic choices and the constraints of mobile devices (15.1). Then it covers Progressive Web Apps, which add offline support, installation and push notifications to ordinary websites (15.2). The rest is about frameworks: jQuery Mobile, historically important and now deprecated (15.3); Apache Cordova and its successor Capacitor, which wrap a web app in a native shell (15.4); and Ionic, which gives that app a native-looking UI (15.5). The running example is a mobile client for the bookshelf API from Chapter 12.
<a id="151-mobile-web-strategies"></a>
15.1 Mobile Web Strategies
Before writing any code, a team has to decide how its product will reach phones. This subchapter compares native, hybrid and web approaches, explains the WebView component that hybrid apps are built on, and covers the realities of mobile development: small and varied screens, touch input, limited CPU and battery, and device features that are only partly available to web code.
Native vs Hybrid vs Web Applications
Why the choice matters. Suppose the bookshelf service needs a mobile presence. There are several ways to build it, and they differ in cost, reach, performance and access to device features. The decision is hard to reverse: it determines the languages the team writes, how updates reach users, and whether app store review stands between a bug fix and the users.
The main families.
- A native app is written in the platform's own language and UI toolkit: Swift or Objective-C with SwiftUI or UIKit on iOS, Kotlin or Java with Jetpack Compose or Android Views on Android. It is compiled for the device, distributed through the App Store or Google Play, and has full access to every platform API on release day.
- A mobile web app is an ordinary website designed for small screens. It runs in the browser, is reached by URL, and updates the moment you deploy. It can only use the APIs the browser exposes.
- A Progressive Web App (PWA) is a web app that also meets a set of requirements (HTTPS, a manifest, usually a service worker), so it can be installed on the home screen, work offline and receive push notifications (15.2).
- A hybrid app is a web app packaged inside a thin native shell. The shell shows the HTML, CSS and JavaScript in a WebView (an embedded browser component) and provides a bridge so JavaScript can call native code. It ships through the app stores like a native app. Apache Cordova (15.4), Capacitor and Ionic (15.5) belong here.
- A cross-platform native app is written once in a shared language but renders real native UI widgets or draws its own UI with a native graphics engine: React Native (JavaScript, native views), Flutter (Dart, own rendering engine), .NET MAUI (C#), Kotlin Multiplatform with Compose Multiplatform. These do not use a WebView for their UI, so they are related to this chapter but not part of it.
An analogy: a native app is a house built for one specific plot of land; a web app is a tent you can pitch anywhere but that offers fewer comforts; a hybrid app is a tent put up inside a prefabricated shell that gives it a front door, mailbox and electricity connection.
Native Hybrid Web / PWA
+-----------+ +------------------+ +----------------+
Your code | Swift / | | HTML, CSS, JS | | HTML, CSS, JS |
| Kotlin UI | +------------------+ +----------------+
| | | WebView + bridge | | Browser |
| | +------------------+ | (Chrome, |
| | | Native shell | | Safari...) |
+-----------+ +------------------+ +----------------+
Platform | iOS / Android operating system and hardware |
+---------------------------------------------------------------+
Delivery App store App store URL (+ install)
Comparing the approaches.
| Criterion | Native | Hybrid (Cordova, Capacitor) | Web / PWA |
|---|---|---|---|
| Languages | Swift, Kotlin (two codebases) | HTML, CSS, JS/TS (one codebase) + native plugins when needed | HTML, CSS, JS/TS (one codebase) |
| UI | Platform widgets, best fidelity | Web UI styled to look native | Web UI |
| Performance | Best, especially for animation, graphics, heavy computation | Good for business apps; WebView limits heavy graphics | Good; depends on the browser and device |
| Device APIs | Everything, immediately | Everything a plugin exposes (plugins can be written) | Only what browsers support; strongest on Android Chromium |
| Offline | Yes | Yes (assets are bundled in the app) | Yes, with a service worker |
| Distribution | App stores (review, fees) | App stores (review, fees) | URL, search engines; optional store listing |
| Updates | Store release; users must update | Store release (web assets can sometimes be live-updated within store rules) | Instant on next load |
| Discoverability | Store search | Store search | Links, search engines, sharing |
| Team skills | iOS + Android specialists | Web developers + some native knowledge | Web developers |
| Typical cost | Highest | Medium | Lowest |
How to decide. No approach is best in general. Useful questions:
- Which device features does the product need? If it needs background Bluetooth, deep OS integration (widgets, watch apps, CarPlay) or advanced camera processing, native or cross-platform native is likely. If it needs a camera, geolocation and notifications, all three can work.
- How demanding is the UI? Games, video editors and complex gesture-driven interfaces benefit from native rendering. Forms, lists and content screens (most business apps) work well in a WebView.
- Must it be in the app stores? Some audiences only look there, and some organizations distribute only through managed app stores. A PWA can also be listed in Google Play (Trusted Web Activity) and the Microsoft Store, but not in Apple's App Store without a native wrapper.
- What skills and budget does the team have? A web team can ship a PWA or hybrid app without hiring iOS and Android specialists.
- How often does it change? Frequent releases favor the web, where deployment is instant.
Many products combine approaches: a responsive website and PWA for reach, plus a hybrid or native app for users who want the store version, often sharing the same front-end code.
Key Takeaways
- Native apps use platform languages and toolkits: best performance and API access, highest cost, two codebases.
- Web apps and PWAs run in the browser from a URL: lowest cost, instant updates, limited to browser APIs.
- Hybrid apps package web code in a native shell with a WebView and a JavaScript-to-native bridge, and ship through app stores.
- Cross-platform native frameworks (React Native, Flutter) share code but render native or custom-drawn UI, not a WebView.
- Choose based on required device features, UI demands, store presence, team skills and release frequency.
Practice
- (Beginner) For each of these products, choose native, hybrid or PWA and justify the choice in two sentences: a restaurant's ordering app, a 3D racing game, an internal expense-report tool for a company with managed devices, a news site.
- (Intermediate) Write a one-page decision record for the bookshelf mobile client. List the features (browse books, scan an ISBN barcode with the camera, offline reading list, reminders), evaluate each against the three approaches, and recommend one with its main risks.
- (Interview) "When would you not build a hybrid app?" Hint: think about rendering-heavy UIs, features that depend on OS integration or background execution, and situations where the plugin you need does not exist or is unmaintained.
WebView-Based Architectures
Why understand the WebView. Every hybrid framework in this chapter is built on one component: the WebView. Its capabilities and limits are the capabilities and limits of your app. Knowing how it works explains why a hybrid app behaves differently on iOS and Android, why some CSS features are missing on old devices, and how JavaScript can trigger native code at all.
What a WebView is. A WebView is a browser engine without the browser around it: no address bar, no tabs, no bookmarks, just a rectangle that renders web content and runs JavaScript. A native app places it on screen like any other view. Many apps use WebViews for parts of their UI (help pages, checkout flows, in-app browsers), and hybrid apps use one for the whole UI.
| Platform | Component | Engine | How it is updated |
|---|---|---|---|
| Android | android.webkit.WebView (Android System WebView) | Chromium (Blink + V8) | Through Google Play, independently of OS updates (Android 5+) |
| iOS / iPadOS | WKWebView | WebKit (JavaScriptCore) | Only with iOS updates |
| Windows (desktop) | WebView2 | Chromium (Edge) | Evergreen runtime |
On iOS, the WebView engine is tied to the OS version, so an app's available web features depend on which iOS versions its users run. The older UIWebView was deprecated and Apple has rejected new apps that use it since 2020. On Android the WebView is Chromium and auto-updates, so feature support usually tracks recent Chrome versions.
The bridge. A web page normally cannot touch the camera API of the operating system or read the native contacts database. A hybrid shell adds a bridge: a channel for messages between the JavaScript in the WebView and native code in the shell.
+-------------------------- Native app process ---------------------------+
| |
| +------------------- WebView ------------------+ |
| | index.html, app.js, styles.css | |
| | | |
| | Camera.getPhoto({ quality: 80 }) | |
| | | ^ | |
| +--------|-------------------------|-----------+ |
| | 1. JSON message | 4. result delivered as |
| | {plugin, method, | a JS callback / resolved |
| | args, callbackId} | Promise |
| v | |
| +---------------- Bridge -----------------+ |
| | routes calls to registered plugins | |
| +-----------------------------------------+ |
| | 2. dispatch ^ 3. result {callbackId, data} |
| v | |
| +-------------- Native plugin (Swift / Kotlin) -------------+ |
| | opens the camera UI, waits for the photo, saves it | |
| +-----------------------------------------------------------+ |
+--------------------------------------------------------------------------+
The bridge is asynchronous: JavaScript sends a message and gets the answer later through a callback or Promise, because the native work (opening a camera, asking for permission) may take seconds. Arguments and results are serialized (usually as JSON), so you cannot pass functions or DOM nodes across it.
The platform primitives. Frameworks hide these, but it helps to see them once. On Android, the shell can expose a Java or Kotlin object to JavaScript, and can run JavaScript in the page:
// Android (Kotlin): a minimal hand-written bridge
class NativeBridge(private val activity: Activity, private val webView: WebView) {
@JavascriptInterface // only annotated methods are visible to JS
fun vibrate(ms: Long) {
val vibrator = activity.getSystemService(Vibrator::class.java)
vibrator.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE))
// Native -> JS: run code in the page (must happen on the UI thread)
activity.runOnUiThread {
webView.evaluateJavascript("window.onNativeEvent('vibrated', $ms)", null)
}
}
}
// Setup in the Activity
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(NativeBridge(this, webView), "AndroidBridge")
webView.loadUrl("https://appassets.androidplatform.net/assets/www/index.html")
// In the page: AndroidBridge is now a global object
window.onNativeEvent = (name, data) => console.log('native says', name, data);
if (window.AndroidBridge) {
AndroidBridge.vibrate(200); // calls the Kotlin method
}
On iOS, WKWebView uses message handlers instead: the page posts a message, and native code receives it.
// iOS (Swift): receive messages from JavaScript
class BridgeHandler: NSObject, WKScriptMessageHandler {
weak var webView: WKWebView?
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
guard message.name == "bridge",
let body = message.body as? [String: Any] else { return }
// ... do the native work, then reply by evaluating JavaScript
webView?.evaluateJavaScript("window.onNativeEvent('done', null)")
}
}
// Setup
let config = WKWebViewConfiguration()
let handler = BridgeHandler()
config.userContentController.add(handler, name: "bridge")
let webView = WKWebView(frame: .zero, configuration: config)
handler.webView = webView
// In the page on iOS
window.webkit.messageHandlers.bridge.postMessage({ action: 'vibrate', ms: 200 });
Writing this by hand for every feature on two platforms is tedious, which is why frameworks such as Cordova and Capacitor exist: they provide one JavaScript API, a plugin registry on each platform, callback bookkeeping and a library of ready-made plugins.
Where the web assets come from. A hybrid app usually bundles its HTML, CSS and JavaScript inside the app package and serves them from a local origin, for example https://localhost on Android with Capacitor or capacitor://localhost on iOS. Serving them from a real-looking origin (rather than file://) matters: file:// pages have an opaque origin, so cookies, fetch with CORS, service workers and many APIs behave badly or not at all. Some apps instead load a remote URL into the WebView; this allows instant updates but makes the app useless offline and is closely examined in app store review (Apple's guidelines reject apps that are just a repackaged website).
Security considerations. The bridge gives JavaScript native powers, so any script that runs in the WebView can use them. Rules that follow:
- Never load untrusted or third-party pages in the WebView that has the bridge. Open external links in the system browser or an in-app browser tab instead.
- Restrict navigation to your own origin (Cordova's
allow-navigation, Capacitor'sserver.allowNavigation). - Use a strict Content Security Policy (Chapter 17) so injected scripts cannot run.
- Treat data coming from JavaScript as untrusted input in native code (validate paths, sizes, URLs).
Key Takeaways
- A WebView is an embeddable browser engine; hybrid apps render their entire UI in one.
- Android's WebView is Chromium and updates through Google Play; iOS's
WKWebViewis WebKit and updates only with iOS. - A bridge passes asynchronous, serialized messages between JavaScript and native code; frameworks add a plugin system on top.
- Serve bundled assets from a local
https-like origin instead offile://so web APIs behave normally. - The bridge grants native power to any script in the WebView: restrict navigation, avoid untrusted content and use a CSP.
Practice
- (Beginner) Explain in your own words why a hybrid app might show a CSS feature correctly on a three-year-old Android phone but not on an iPhone that has not been updated for three years.
- (Intermediate) Design the message format for a small bridge that supports
getBatteryLevel()andshare(text). Show the JSON sent from JavaScript, the JSON sent back, and how the JavaScript side matches each reply to the right pending Promise.- (Interview) "Why is it dangerous to open arbitrary links inside the WebView of a hybrid app?" Hint: consider what a third-party page could call through the bridge, and what the native side can do on the user's behalf.
Responsive and Adaptive Mobile UI
Why mobile layout needs care. Phones range from about 320 to 430 CSS pixels wide, tablets reach over 1000, foldables change size while the app is running, and the same device can be held in portrait or landscape. Notches, rounded corners and gesture bars cover parts of the screen. A layout designed for a 1440-pixel desktop simply shrunk down would be unreadable. Chapter 2 (2.3) introduced media queries and breakpoints; this topic covers what changes when the target is a phone.
Responsive vs adaptive. Two broad approaches exist:
| Approach | Idea | How | Strengths | Weaknesses |
|---|---|---|---|---|
| Responsive | One layout that flows and rearranges continuously | Fluid grids, flexbox, grid, relative units, media and container queries | One codebase, works on unknown sizes | Same HTML and assets downloaded everywhere unless optimized |
| Adaptive | Several distinct layouts, one chosen per device class | Server picks a template (via Client Hints or user agent), or client picks components at breakpoints | Tailored experiences, smaller payloads per device | More layouts to maintain; device detection can be wrong |
In practice most sites are responsive at their core and adaptive in places: for example a bottom tab bar on phones and a side navigation on desktops, chosen at a breakpoint.
The viewport meta tag. Without it, mobile browsers assume the page was designed for a desktop and render it in a virtual viewport of about 980 pixels, then zoom out. Every mobile page needs:
<!-- width=device-width: the layout viewport matches the device width in CSS pixels
initial-scale=1: no initial zoom
viewport-fit=cover: draw under notches and rounded corners (then use safe-area insets) -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
Do not add user-scalable=no or maximum-scale=1: blocking zoom is an accessibility failure for users with low vision.
Mobile-first CSS. Write the base styles for the smallest screen, then add complexity for larger screens with min-width queries. Small screens get the simplest CSS, and you are forced to decide what really matters.
/* Base: phone. Single column, bottom navigation */
.layout {
display: grid;
grid-template-areas: "main" "nav";
grid-template-rows: 1fr auto;
min-height: 100dvh; /* dynamic viewport height: follows the browser's
collapsing address bar, unlike 100vh */
}
.nav {
grid-area: nav;
display: flex;
justify-content: space-around;
padding-bottom: env(safe-area-inset-bottom); /* keep buttons above the iOS home indicator */
}
/* Tablet and up: navigation moves to the side */
@media (min-width: 48rem) {
.layout {
grid-template-areas: "nav main";
grid-template-columns: 16rem 1fr;
grid-template-rows: 1fr;
}
.nav { flex-direction: column; justify-content: flex-start; }
}
Viewport units on mobile. The classic 100vh equals the height with the browser's address bar hidden, so content set to 100vh overflows when the bar is visible. CSS now offers three variants: svh (small: bars visible), lvh (large: bars hidden) and dvh (dynamic: changes as bars move). Use dvh for full-screen app layouts, svh when content must never be hidden.
Safe areas. With viewport-fit=cover, the page extends under the notch and home indicator. The env() function returns the insets you must keep clear:
.header {
padding-top: max(1rem, env(safe-area-inset-top)); /* at least 1rem, more under a notch */
padding-left: env(safe-area-inset-left); /* landscape: notch on the side */
padding-right: env(safe-area-inset-right);
}
Media features beyond width. Screen width does not tell you how the user interacts. Interaction media features describe the input device:
/* Primary input is a finger (imprecise, no hover): enlarge targets, avoid hover-only UI */
@media (pointer: coarse) {
.toolbar button { min-width: 48px; min-height: 48px; }
}
/* Hover effects only where hovering is possible */
@media (hover: hover) and (pointer: fine) {
.card:hover { box-shadow: 0 4px 12px rgb(0 0 0 / 0.15); }
}
/* Respect system preferences */
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
@media (prefers-color-scheme: dark) {
:root { --bg: #121212; --text: #e8e8e8; }
}
Container queries. A component such as a book card may appear in a narrow sidebar and in a wide grid on the same screen. Media queries ask about the viewport; container queries ask about the component's container, so the component adapts wherever it is placed:
.book-list { container-type: inline-size; } /* make the list a query container */
.book-card { display: grid; gap: 0.5rem; }
@container (min-width: 30rem) { /* the container is at least 30rem wide */
.book-card { grid-template-columns: 6rem 1fr; } /* cover image beside the text */
}
Responsive images. A phone on a cellular connection should not download a 2400-pixel hero image. srcset and sizes let the browser choose:
<img src="cover-400.jpg"
srcset="cover-400.jpg 400w, cover-800.jpg 800w, cover-1600.jpg 1600w"
sizes="(min-width: 48rem) 33vw, 100vw"
alt="Cover of Dune" width="400" height="600" loading="lazy">
<!-- width/height reserve space before loading, preventing layout shift (CLS) -->
Device detection. Avoid parsing the User-Agent string to decide layout: it is long, frequently spoofed and being reduced by browsers. Prefer CSS media features and JavaScript feature detection. If the server must know (adaptive serving), Chromium browsers send User-Agent Client Hints such as Sec-CH-UA-Mobile: ?1.
Key Takeaways
- Every mobile page needs
<meta name="viewport" content="width=device-width, initial-scale=1">; never disable zoom. - Responsive design uses one fluid layout; adaptive design picks between distinct layouts; most sites mix both.
- Write mobile-first CSS and add
min-widthbreakpoints for larger screens. - Use
dvh/svhinstead of100vh, andenv(safe-area-inset-*)withviewport-fit=cover. - Use
pointer,hover,prefers-*media features and container queries rather than width alone; serve sized images withsrcset.
Practice
- (Beginner) Build a bookshelf page with a header, a list of book cards and a bottom navigation bar on phones that becomes a left sidebar at 48rem. Test it in the browser's device emulation at 360 px and 1024 px.
- (Intermediate) Make the book card a container-query component that shows only the title under 20rem, title and author between 20rem and 30rem, and cover, title, author and description above 30rem. Place it both in a narrow sidebar and a wide main area.
- (Interview) "What is the difference between
100vhand100dvh, and why does it matter on phones?" Hint: think about what happens to the browser's address bar while scrolling and which height each unit measures.
Touch Interaction and Mobile Performance
Why touch is different. A mouse pointer is a precise, single point with a hover state. A finger is a wide, imprecise contact area of roughly 8 to 10 mm, it hides what it touches, it cannot hover, and several fingers can act at once. Phones also have slower CPUs, less memory and less reliable networks than the laptops developers test on. A page that feels fine on a desktop can feel sluggish and fiddly on a mid-range phone.
Touch targets. Small buttons cause mis-taps. Guidelines:
| Source | Recommended minimum target size |
|---|---|
| Apple Human Interface Guidelines | 44 x 44 points |
| Material Design (Android) | 48 x 48 dp, with about 8 dp between targets |
| WCAG 2.2, criterion 2.5.8 (level AA) | 24 x 24 CSS pixels (or enough spacing) |
A small icon can keep its visual size while its tappable area grows, using padding.
.icon-button {
/* 24px icon, 48px touch target */
inline-size: 48px;
block-size: 48px;
display: inline-grid;
place-items: center;
touch-action: manipulation; /* disables double-tap-to-zoom on this element,
so taps fire immediately */
-webkit-tap-highlight-color: transparent; /* remove the grey flash on iOS/Android;
provide your own :active style instead */
}
.icon-button:active { background: rgb(0 0 0 / 0.08); }
The 300 ms delay (history). Early mobile browsers waited about 300 ms after a tap to see whether a second tap (double-tap zoom) would follow before firing click. Libraries such as FastClick worked around it. Modern browsers skip the delay on pages with width=device-width in the viewport meta tag, and touch-action: manipulation removes it for specific elements. You may still see the workaround in legacy code (including jQuery Mobile's vclick, 15.3).
Use pointer events. Chapter 6 (6.3) covered mouse, touch and pointer events. For new code, pointer events are the best choice: one API for mouse, pen and touch, with pointerType telling you which.
// A swipe-to-delete gesture on a list item, using pointer events
const item = document.querySelector('.book-item');
let startX = 0;
let dx = 0;
item.addEventListener('pointerdown', (e) => {
startX = e.clientX;
item.setPointerCapture(e.pointerId); // keep receiving events even if the finger leaves the element
});
item.addEventListener('pointermove', (e) => {
if (!item.hasPointerCapture(e.pointerId)) return;
dx = Math.min(0, e.clientX - startX); // only allow dragging to the left
item.style.transform = `translateX(${dx}px)`; // transform: cheap, compositor-only
});
item.addEventListener('pointerup', () => {
if (dx < -120) item.remove(); // swiped far enough: delete
else item.style.transform = ''; // snap back
dx = 0;
});
/* Tell the browser that horizontal panning on this element is handled by JavaScript,
while vertical scrolling of the page still works natively */
.book-item { touch-action: pan-y; }
Passive listeners. When a page listens for touchstart, touchmove or wheel, the browser must wait for the handler to finish before scrolling, because the handler might call preventDefault(). Declaring a listener passive promises it will not, so scrolling stays smooth:
window.addEventListener('touchmove', updateParallax, { passive: true });
Chrome and Safari already treat touchstart/touchmove listeners on window, document and body as passive by default.
Measuring mobile performance. Google's Core Web Vitals measure what users feel, and are measured on real users in the field:
| Metric | Measures | Good | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading: when the main content appears | <= 2.5 s | > 4.0 s |
| INP (Interaction to Next Paint) | Responsiveness: delay from a tap to the next frame | <= 200 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | Visual stability: unexpected content movement | <= 0.1 | > 0.25 |
INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. Mobile devices fail INP far more often than desktops, because every millisecond of JavaScript costs more on a slower CPU.
Techniques that matter most on phones.
- Ship less JavaScript. Parsing and executing JavaScript is the biggest cost on mid-range phones. Use code splitting (
import()), remove unused dependencies, and render HTML on the server where possible (14.5). - Keep the main thread free. Break long tasks (over 50 ms) into smaller pieces; move heavy computation into a Web Worker (6.4). To give the browser a chance to handle input during long loops, yield regularly:
// Process 5,000 books without blocking taps for the whole duration
async function indexBooks(books) {
for (let i = 0; i < books.length; i++) {
addToSearchIndex(books[i]);
if (i % 200 === 0) {
// Yield to the browser so pending input and rendering can run.
// scheduler.yield() where available, otherwise a macrotask via setTimeout
await (globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0)));
}
}
}
- Animate only
transformandopacity. These can be handled by the GPU compositor without recalculating layout. Animatingtop,left,widthorheightforces layout on every frame. - Avoid layout thrashing. Reading layout (
offsetHeight,getBoundingClientRect) after writing styles forces synchronous layout; batch reads, then writes. - Skip off-screen work.
content-visibility: autolets the browser skip rendering off-screen sections;loading="lazy"defers images; virtualize long lists (render only visible rows). - Optimize images and fonts. Use modern formats (AVIF, WebP), correct sizes (
srcset) andfont-display: swap. - Design for slow and flaky networks. Test with network throttling, cache aggressively (15.2) and show skeleton screens instead of blank pages.
Test on real devices. Desktop emulation in DevTools simulates screen size and throttles CPU and network, but real mid-range Android phones expose problems emulation hides. Chrome's remote debugging (chrome://inspect) and Safari's Web Inspector let you profile a page running on a connected phone.
Key Takeaways
- Touch targets should be about 44 to 48 px; enlarge the hit area with padding rather than enlarging icons.
- Modern browsers have no 300 ms tap delay with a proper viewport;
touch-action: manipulationremoves it per element. - Use pointer events for gestures,
touch-actionto declare what the browser should handle, and passive listeners for scroll-related events. - Core Web Vitals (LCP, INP, CLS) measure real user experience; mobile CPUs make INP the hardest to pass.
- Ship less JavaScript, break up long tasks, animate only
transform/opacity, and test on real mid-range devices.
Practice
- (Beginner) Audit a page you built earlier with DevTools device emulation: find every interactive element smaller than 44 x 44 px and fix it without changing the visual icon size.
- (Intermediate) Implement pull-to-refresh on a scrollable book list using pointer events and
touch-action. It should only start when the list is scrolled to the top, follow the finger with atransform, and trigger a reload after 80 px.- (Interview) "A page feels laggy when users tap buttons on Android phones but fine on desktop. How do you investigate?" Hint: think about INP, CPU throttling in the Performance panel, long tasks, and what runs on the main thread when the tap happens.
Device Capabilities and Constraints
Why this matters. Mobile apps are attractive because phones have cameras, GPS, motion sensors, vibration motors and always-on connectivity. Before choosing web, PWA or hybrid, you need to know which of these a browser actually exposes, on which platforms, and under what conditions. You also need to design for what phones lack: plenty of battery, memory, storage and a reliable network.
Web APIs for device features. Support varies widely. Android Chromium is the most capable browser; Safari on iOS supports fewer device APIs; some APIs exist only in Chromium. The table summarizes the situation in 2026; always check current data (for example on caniuse.com or MDN) before relying on an API.
| Capability | Web API | Android Chrome | iOS Safari | Notes |
|---|---|---|---|---|
| Location | navigator.geolocation (6.4) | Yes | Yes | Permission prompt; HTTPS only |
| Camera / microphone | getUserMedia, <input type="file" capture> | Yes | Yes | capture opens the camera directly on mobile |
| Motion and orientation | DeviceMotionEvent, DeviceOrientationEvent | Yes | Yes, after requestPermission() | iOS requires an explicit permission call from a user gesture |
| Vibration | navigator.vibrate() | Yes | No | |
| Native share sheet | navigator.share() | Yes | Yes | Must be called from a user gesture |
| Keep screen on | Screen Wake Lock API | Yes | Yes (16.4+) | |
| Push notifications | Push API + Notifications (15.2) | Yes | Home-screen web apps only (16.4+) | |
| App badge | Badging API (navigator.setAppBadge) | Installed apps | Home-screen web apps (16.4+) | |
| Bluetooth, USB, NFC, Serial | Web Bluetooth, WebUSB, Web NFC, Web Serial | Mostly yes | No | Chromium-only |
| Contacts | Contact Picker API | Yes | No | |
| Network type | navigator.connection | Yes | No | Chromium-only |
| Background sync | Background Sync API (15.2) | Yes | No | Chromium-only |
When an API is missing, a hybrid app fills the gap with a native plugin (15.4).
Feature detection and progressive enhancement. Never assume an API exists because the device is a phone. Check for it, use it when available, and provide a fallback when it is not:
// Share a book: native share sheet when available, clipboard fallback otherwise
async function shareBook(book) {
const data = { title: book.title, text: `Read "${book.title}"`, url: `/books/${book.id}` };
if (navigator.canShare?.(data)) { // optional chaining: undefined if unsupported
try {
await navigator.share(data); // opens the OS share sheet
return;
} catch (err) {
if (err.name === 'AbortError') return; // the user closed the sheet: not an error
throw err;
}
}
await navigator.clipboard.writeText(new URL(data.url, location.origin).href);
showToast('Link copied');
}
// Motion sensors: iOS needs an explicit permission request from a user gesture
async function enableShakeToUndo() {
if (typeof DeviceMotionEvent?.requestPermission === 'function') {
const state = await DeviceMotionEvent.requestPermission(); // iOS 13+
if (state !== 'granted') return;
}
window.addEventListener('devicemotion', (e) => {
const a = e.accelerationIncludingGravity;
if (a && Math.hypot(a.x, a.y, a.z) > 25) undoLastAction();
});
}
document.querySelector('#enable-shake').addEventListener('click', enableShakeToUndo);
Permissions. Powerful features require user permission and a secure context (HTTPS or localhost). Good permission manners:
- Ask in context, right when the user tries the feature ("Scan barcode" button), not on page load. Prompts on load are often denied and some browsers suppress them.
- Explain the benefit before the prompt (a "pre-permission" screen in your own UI).
- Handle denial gracefully; a denied prompt usually cannot be re-shown by the page.
- Check status first with the Permissions API where supported:
await navigator.permissions.query({ name: 'geolocation' })returnsgranted,deniedorprompt.
Constraints of mobile devices.
| Constraint | Consequence | Design response |
|---|---|---|
| Battery | GPS, radio, wake locks and constant JavaScript drain it | Request location only when needed, batch network requests, stop timers when hidden |
| CPU and memory | Mid-range phones are several times slower than laptops; the OS kills background tabs and apps | Ship less JavaScript; save state so the app can be restored after being killed |
| Network | High latency, packet loss, offline periods, metered data | Cache assets and data, retry with backoff, offline-first design (15.2) |
| Storage | Quotas and eviction; Safari may clear script storage of non-installed sites after 7 days without a visit | Use navigator.storage.persist(); keep the server as source of truth |
| Background execution | Pages are frozen or discarded when hidden; JavaScript does not run in the background | Use the Page Lifecycle (visibilitychange), service worker events, or native plugins |
| Screen and input | Small screen, virtual keyboard covers half of it | Correct inputmode, autocomplete and enterkeyhint attributes; keep focused fields visible |
The visibilitychange event is the reliable signal that the user switched away. Mobile browsers may never fire unload or beforeunload, so save state when the page becomes hidden:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
saveDraftToIndexedDB(); // the page may be killed without further warning
navigator.sendBeacon('/api/v1/analytics', JSON.stringify(pendingEvents)); // survives page close
stopPolling();
} else {
startPolling();
}
});
The virtual keyboard is a device constraint that HTML can help with:
<!-- Numeric keypad, autofill hint, and a "search" action key on the keyboard -->
<input type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="off"
enterkeyhint="search" placeholder="ISBN">
<input type="email" autocomplete="email" enterkeyhint="next">
Key Takeaways
- Browser support for device APIs varies: Android Chromium supports the most, iOS Safari fewer, and some APIs are Chromium-only.
- Always use feature detection with fallbacks; hybrid apps cover gaps with native plugins.
- Powerful APIs require HTTPS and permission; ask in context, after explaining why, and handle denial.
- Design for limited battery, CPU, memory, storage and network, and for pages being frozen or killed in the background.
- Save state on
visibilitychange(notunload), and useinputmode,autocompleteandenterkeyhintfor the virtual keyboard.
Practice
- (Beginner) Write a small "capabilities" page that detects and lists which of these APIs the current browser supports: geolocation, vibration, Web Share, Wake Lock, Badging, Web Bluetooth, Background Sync. Open it on an Android phone and an iPhone and compare.
- (Intermediate) Add a "Find bookshops near me" button to the bookshelf client. It should check the permission state first, show an explanation before the first prompt, request location with a timeout, and display a manual city search if permission is denied or unavailable.
- (Interview) "Why should you not rely on
beforeunloadto save user data on mobile?" Hint: think about how mobile operating systems suspend and kill background tabs and apps, and which event fires reliably before that happens.
<a id="152-progressive-web-apps"></a>
15.2 Progressive Web Apps
A Progressive Web App is a website that the user can install, that works offline or on a bad connection, and that can re-engage users with notifications, while still being reached by URL and updated by deploying. This subchapter explains the defining characteristics, the Web App Manifest, service workers and their lifecycle, caching strategies, offline-first design, push notifications, installability and auditing.
PWA Characteristics and Criteria
Why PWAs exist. For years the choice was a website (easy to reach, instant updates, but no offline mode, no home screen icon, no notifications) or a native app (all of those, but a store download, a separate codebase, and slow updates). In 2015 Alex Russell and Frances Berriman coined the term Progressive Web App for sites that use new browser capabilities to get closer to native apps without giving up the web's strengths. "Progressive" refers to progressive enhancement: the app works as a normal website in any browser, and gains app-like features where the browser supports them.
Characteristics. A good PWA is commonly described as:
| Characteristic | Meaning | Enabled by |
|---|---|---|
| Progressive | Works for every user, regardless of browser; enhances where possible | Feature detection |
| Responsive | Fits any screen: phone, tablet, desktop | Responsive design (15.1) |
| Connectivity-independent | Works offline or on poor networks | Service worker + Cache API, IndexedDB |
| App-like | Feels like an app: own window, app shell navigation | Manifest display, app shell model |
| Fresh | Always up to date | Service worker update process |
| Safe | Served over HTTPS so content cannot be tampered with | TLS |
| Discoverable | Identifiable as an app by search engines and browsers | Manifest, metadata |
| Re-engageable | Can bring users back | Push notifications, badges |
| Installable | Can be added to the home screen, dock or start menu | Manifest + browser install criteria |
| Linkable | Shared and opened by URL, no installation needed | URLs, deep links |
The three technical building blocks.
+----------------------------------------------+
| Your web app |
| (responsive HTML, CSS, JavaScript) |
+----------------------------------------------+
HTTPS ----| required for service workers and most APIs |
+-----------------------+----------------------+
| Web App Manifest | Service Worker |
| (JSON metadata: | (JS proxy between |
| name, icons, | page and network: |
| display, colors) | caching, offline, |
| -> installability, | push, sync) |
| app look & feel | |
+-----------------------+----------------------+
- HTTPS. Service workers can intercept every request, so browsers only allow them on secure origins (plus
localhostfor development). - Web App Manifest. A JSON file that tells the browser the app's name, icons, colors, start URL and how it should be displayed when launched (next topic).
- Service worker. A script that runs separately from the page and acts as a programmable network proxy. It enables offline support, caching, push notifications and background sync.
Platform support. PWAs work best on Chromium browsers (Chrome, Edge, Samsung Internet) on Android and desktop, where installation, push, background sync and many device APIs are available. Safari supports service workers, the manifest and installation through "Add to Home Screen" on iOS and "Add to Dock" on macOS. Since iOS 16.4, web apps added to the home screen can receive push notifications and set badges. Firefox supports service workers and push everywhere, and installation on Android; its desktop support for installing web apps is limited. A PWA must therefore treat installation and the advanced APIs as optional enhancements.
PWA in the store. A PWA can also be packaged for app stores: Google Play through a Trusted Web Activity (a Chrome tab without browser UI, created with tools such as Bubblewrap or PWABuilder) and the Microsoft Store directly. For Apple's App Store a native wrapper with real native value is needed.
Key Takeaways
- A PWA is a website that uses modern browser features to be installable, offline-capable and re-engageable, while remaining linkable and instantly updatable.
- "Progressive" means it works as a normal site everywhere and adds features where supported.
- The three building blocks are HTTPS, a Web App Manifest and a service worker.
- Chromium offers the most PWA features; Safari supports the core features with fewer extras; always treat advanced features as enhancements.
- PWAs can be listed in Google Play (Trusted Web Activity) and the Microsoft Store.
Practice
- (Beginner) Open three websites you use often, check in DevTools (Application panel) whether they have a manifest and a service worker, and list which PWA characteristics each one meets.
- (Intermediate) For the bookshelf client, write a table mapping each PWA characteristic to a concrete feature (for example "connectivity-independent: the reading list is available offline") and the browser API that provides it, noting which ones will not work on iOS.
- (Interview) "What makes a website a Progressive Web App?" Hint: name the technical requirements (secure origin, manifest, service worker), then explain the experience goals and why "progressive" is in the name.
The Web App Manifest
Why a manifest. When a user installs a website, the operating system needs information that HTML does not provide: which name to show under the icon, which icons in which sizes, which page to open, whether to show browser controls, which color the splash screen should have. The Web App Manifest is a JSON file that answers these questions in one place.
Linking the manifest. Every page of the app references it from <head>:
<link rel="manifest" href="/manifest.webmanifest">
<!-- theme-color colors the browser UI / status bar; can vary by color scheme -->
<meta name="theme-color" content="#2e5d4b" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#16302a" media="(prefers-color-scheme: dark)">
<!-- iOS still uses this icon for the home screen in some versions -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png">
Serve the file with the application/manifest+json media type (.webmanifest is the conventional extension; .json also works).
A complete manifest.
{
"id": "/",
"name": "Bookshelf - Your Reading List",
"short_name": "Bookshelf",
"description": "Track the books you own, read and want to read.",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#2e5d4b",
"lang": "en",
"dir": "ltr",
"categories": ["books", "productivity"],
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{ "src": "/screens/list-narrow.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow", "label": "Your reading list" },
{ "src": "/screens/list-wide.png", "sizes": "1920x1080", "type": "image/png", "form_factor": "wide", "label": "Library overview" }
],
"shortcuts": [
{ "name": "Add a book", "url": "/books/new", "icons": [{ "src": "/icons/add-96.png", "sizes": "96x96" }] },
{ "name": "Reading list", "url": "/reading-list" }
],
"share_target": {
"action": "/share",
"method": "GET",
"params": { "title": "title", "text": "text", "url": "url" }
}
}
Members explained.
| Member | Purpose |
|---|---|
id | Stable identity of the app. If omitted, start_url is used, so changing start_url would create a "different" app. Set it once and never change it. |
name / short_name | Full name (install dialog, splash screen) and short name (under the home screen icon, about 12 characters). |
start_url | Page opened when the app is launched. A query parameter lets analytics distinguish launches from the home screen. |
scope | URLs considered part of the app. Navigating outside the scope opens the browser UI or a separate browser. |
display | fullscreen (no system UI, for games), standalone (own window, no browser UI, like an app), minimal-ui (own window with minimal navigation controls), browser (normal tab). |
display_override | Ordered list of preferred display modes, tried before display; for example window-controls-overlay on desktop draws the app into the title bar area. |
icons | Icons in several sizes. At least 192x192 and 512x512 for Chromium installability. purpose: "maskable" marks an icon with safe padding that the OS may crop into a circle, squircle or other shape. |
background_color | Splash screen background shown while the app loads. |
theme_color | Color of the title bar or status bar. |
screenshots | Shown in the richer install dialog on Android and desktop Chromium. |
shortcuts | Quick actions in the long-press menu of the icon (Android) or the taskbar or dock menu (desktop). |
share_target | Lets the installed app appear in the OS share sheet and receive shared content (Chromium). |
protocol_handlers, file_handlers | Register the app for custom URL schemes or file types (desktop Chromium). |
Maskable icons. Android launchers display icons in different shapes. A regular icon gets shrunk inside a white circle; a maskable icon fills the shape. Design maskable icons with the important content within the central 80 percent circle (the "safe zone"); tools such as maskable.app preview the result.
Display modes in CSS and JavaScript. The app can detect how it was launched, for example to hide an "Install" button or show its own back button in standalone mode, where there is no browser back button:
/* Only when launched as an installed app */
@media (display-mode: standalone), (display-mode: window-controls-overlay) {
.install-banner { display: none; }
.app-back-button { display: inline-flex; }
}
const isInstalled =
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true; // legacy iOS Safari property
Inspecting the manifest. In Chrome DevTools, the Application > Manifest pane shows the parsed manifest, icon previews, warnings and installability problems.
Key Takeaways
- The manifest is a JSON file linked with
<link rel="manifest">that describes the installed app's identity and appearance. - Essential members:
name,short_name,start_url,display,icons(192 and 512, plus a maskable icon),theme_color,background_color. - Set
idexplicitly and keep it stable;scopedefines which URLs belong to the app. display: standalonegives an app-like window; detect it with thedisplay-modemedia feature.- Optional members such as
shortcuts,screenshotsandshare_targetintegrate the app further with the OS.
Practice
- (Beginner) Write a manifest for the bookshelf client with name, short name, start URL, standalone display, colors and two icons. Link it, and check the Application > Manifest pane in DevTools for warnings.
- (Intermediate) Add a maskable icon, two shortcuts and a
share_targetthat accepts a shared URL. Implement the/shareroute so that sharing a book page from another app pre-fills the "Add a book" form.- (Interview) "What happens if you change
start_urlin a deployed PWA's manifest that has noid?" Hint: think about how the browser identifies an installed app and whether it would consider the result an update or a new app.
Service Worker Lifecycle
Why service workers. Normally, when the network is down, the browser shows its own error page and your code never gets a say. Earlier attempts to give pages offline power (the Application Cache, now removed) used a declarative file listing what to cache, and it behaved in surprising ways. The service worker takes the opposite approach: it gives you a JavaScript program that sits between your pages and the network and decides, request by request, what to answer.
Think of the service worker as a personal assistant at the front desk of your app. Every request from the page passes the desk. The assistant can forward it to the network, answer from a filing cabinet (the cache), or combine both. The assistant also works when the office (your page) is closed, for example to receive a push message.
What a service worker is.
- A JavaScript file running in its own worker thread, separate from any page. It has no DOM access; it communicates with pages through
postMessage. - Event-driven: the browser starts it when an event arrives (
fetch,push,sync,message) and terminates it when idle. Do not keep state in global variables; store it in IndexedDB or the Cache API. - Scoped: it controls pages under its scope, by default the directory containing the script. A worker at
/sw.jscontrols the whole origin; one at/app/sw.jscontrols only/app/. That is why the file is usually placed at the site root. - Secure origins only: HTTPS or
localhost. - Fully asynchronous: synchronous APIs such as
localStorageand synchronous XHR are not available.
Registration. The page registers the worker. Do it after the page has loaded so the worker's setup does not compete with the first render:
// main.js (page)
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
console.log('Service worker registered with scope', reg.scope);
} catch (err) {
console.error('Service worker registration failed', err);
}
});
}
The lifecycle. A service worker goes through a strict sequence of states. The design guarantees that only one version of your worker controls the pages at a time, so old pages never talk to a new worker that expects a different cache layout.
register('/sw.js')
|
v
+-------------+ install event: precache the app shell
| installing | event.waitUntil(promise) delays completion
+-------------+ (rejection -> worker is discarded: "redundant")
|
v
+-------------+ another version still controls open pages?
| installed |-- yes --> waits here ("waiting") until all those tabs are closed
| (waiting) | (or the new worker calls self.skipWaiting())
+-------------+
| no old version (or skipWaiting)
v
+-------------+ activate event: delete old caches, migrate data
| activating | event.waitUntil(promise)
+-------------+
|
v
+-------------+ handles fetch, push, sync, message events;
| activated | terminated when idle and restarted on the next event
+-------------+
|
v
+-------------+
| redundant | replaced by a newer version, or installation failed
+-------------+
A first-visit subtlety. On the very first visit, the page loaded before the worker existed, so it is not controlled. The worker becomes active, but it only controls pages loaded afterwards, unless it calls clients.claim() during activation.
A complete minimal worker.
// sw.js
const VERSION = 'v3';
const SHELL_CACHE = `shell-${VERSION}`;
const SHELL_ASSETS = ['/', '/index.html', '/app.js', '/styles.css', '/offline.html', '/icons/icon-192.png'];
// 1. INSTALL: download and store the app shell
self.addEventListener('install', (event) => {
event.waitUntil( // installation succeeds only if this resolves
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
);
});
// 2. ACTIVATE: remove caches from previous versions
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((name) => name.startsWith('shell-') && name !== SHELL_CACHE)
.map((name) => caches.delete(name))
);
await self.clients.claim(); // take control of already-open pages now
})());
});
// 3. FETCH: answer navigations from the network, falling back to an offline page
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => caches.match('/offline.html'))
);
return;
}
// Other requests: cache first, then network (strategies are covered in the next topic)
event.respondWith(
caches.match(event.request).then((cached) => cached ?? fetch(event.request))
);
});
How updates work. The browser checks for a new version of sw.js on navigations to pages in scope (and on functional events), at most about once every 24 hours unless you call registration.update(). By default it bypasses the HTTP cache for the worker script itself (updateViaCache: 'imports'). If the file differs by even one byte, the new version installs and then waits until no tab uses the old version. This surprises developers: a user who keeps one tab open forever never gets the update.
Two common patterns for updating promptly:
- Skip waiting immediately (
self.skipWaiting()ininstall). Simple, but a page loaded by the old worker may then request assets that the new worker has already deleted. Safe only when assets are versioned in their filenames and old ones remain available. - Ask the user ("A new version is available. Reload?"), then tell the waiting worker to skip waiting:
// sw.js: skip waiting only when the page asks
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
// main.js: detect a waiting worker and offer a reload
const reg = await navigator.serviceWorker.register('/sw.js');
function offerUpdate(worker) {
showBanner('A new version of Bookshelf is available.', 'Reload', () => {
worker.postMessage({ type: 'SKIP_WAITING' });
});
}
if (reg.waiting) offerUpdate(reg.waiting); // a new version was already waiting
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
// installed + an existing controller = an update (not the first install)
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
offerUpdate(newWorker);
}
});
});
// When the new worker takes control, reload once so the page uses the new assets
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!reloading) { reloading = true; window.location.reload(); }
});
Debugging. Chrome DevTools' Application > Service workers pane shows the active and waiting workers and has buttons to Update, Unregister and Skip waiting, plus an "Update on reload" checkbox that makes development bearable. chrome://serviceworker-internals lists every registration. A hard reload (Shift+Reload) bypasses the service worker for that load.
Key Takeaways
- A service worker is an event-driven script in its own thread that acts as a network proxy for pages in its scope; it has no DOM and no persistent globals.
- Lifecycle:
install(precache) -> waiting (while an old version controls pages) ->activate(clean up) -> idle/handling events -> redundant. event.waitUntil()extends install and activate until a Promise settles;clients.claim()controls already-open pages.- The browser installs a new worker when the script changes by one byte, but it waits until the old version's tabs close, unless
skipWaiting()is called. - Offer updates to the user with a "reload" prompt and a
SKIP_WAITINGmessage, and reload once oncontrollerchange.
Practice
- (Beginner) Register a service worker that logs every lifecycle event and every
fetchURL. Change one character in the worker, reload, and observe the waiting state in DevTools. Then close all tabs and reopen.- (Intermediate) Implement the "new version available" banner for the bookshelf client using the
SKIP_WAITINGmessage pattern. Verify that the page reloads exactly once after the user accepts.- (Interview) "Why does a new service worker wait before activating, and when is
skipWaiting()dangerous?" Hint: think about a page loaded with version 1's HTML that then requests files from a cache that version 2 has replaced.
Caching Strategies and the Cache API
Why strategies. Being able to intercept requests is only useful if you decide well what to answer. A cached stylesheet that never changes can always come from the cache. A list of books should be fresh, but showing yesterday's list is better than an error. A payment request must always go to the network. Different resources need different caching strategies.
The Cache API. The Cache API stores pairs of Request and Response objects. It is available in service workers and in pages (window.caches), and is separate from the browser's HTTP cache: nothing enters or leaves it unless your code puts it there or deletes it.
| Method | Purpose |
|---|---|
caches.open(name) | Open (or create) a named cache, returns a Cache |
caches.match(request) | Search all caches for a matching response |
caches.keys() / caches.delete(name) | List and delete named caches |
cache.add(url) / cache.addAll(urls) | Fetch URLs and store the responses (fails if any response is not OK) |
cache.put(request, response) | Store a response you already have |
cache.match(request, options) | Find a response; { ignoreSearch: true } ignores the query string |
cache.delete(request) / cache.keys() | Remove an entry, list requests |
A Response body can be read only once. When you want to both return a response to the page and store it, clone it: cache.put(request, response.clone()).
The five classic strategies.
Cache first Network first Stale-while-revalidate
page -> SW -> cache? page -> SW -> network page -> SW -> cache (answer now)
hit: return ok: store + return \-> network (in background)
miss: network, fail: cache -> update cache
store + return (used next time)
Network only Cache only
page -> SW -> network page -> SW -> cache
(never cached) (only precached assets)
| Strategy | Speed | Freshness | Offline | Good for |
|---|---|---|---|---|
| Cache first | Fastest | Stale until the cache changes | Yes | Versioned static assets (app.3f9a1c.js), fonts, icons |
| Network first | Network speed; slow on bad networks unless a timeout is used | Fresh | Fallback to cache | HTML pages, API data that must be current |
| Stale-while-revalidate | Fast | One request behind | Yes | Avatars, non-critical API data, article lists |
| Network only | Network speed | Fresh | No | Payments, analytics, non-GET requests |
| Cache only | Fastest | Only what was precached | Yes | The precached app shell |
Implementing them.
// sw.js: one function per strategy
const RUNTIME = 'runtime-v1';
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone()); // clone: the body can only be consumed once
}
return response;
}
async function networkFirst(request, timeoutMs = 3000) {
const cache = await caches.open(RUNTIME);
try {
// Race the network against a timeout so a slow connection falls back to the cache
const response = await Promise.race([
fetch(request),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
]);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
}
async function staleWhileRevalidate(event) {
const cache = await caches.open(RUNTIME);
const cached = await cache.match(event.request);
const network = fetch(event.request).then((response) => {
if (response.ok) cache.put(event.request, response.clone());
return response;
});
event.waitUntil(network.catch(() => {})); // keep the worker alive until the update finishes
return cached ?? network; // answer from cache if possible
}
// Routing: choose a strategy per request
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') return; // let the browser handle POST, PUT, DELETE
if (url.origin !== self.location.origin) return; // ignore third-party requests here
if (url.pathname.startsWith('/api/v1/books')) {
event.respondWith(networkFirst(request));
} else if (url.pathname.startsWith('/covers/')) {
event.respondWith(staleWhileRevalidate(event));
} else if (/\.(js|css|woff2|png|svg)$/.test(url.pathname)) {
event.respondWith(cacheFirst(request));
}
// Anything else: no respondWith -> the browser fetches normally
});
Precaching vs runtime caching. Precaching stores a known list of files during install (the app shell), so they are available offline before the user visits them. Runtime caching stores responses as they are requested. Both need limits: runtime caches grow without bound unless you delete old entries.
Storage limits. Origins get a share of disk space determined by the browser (often a large percentage of free disk, much less in some contexts). Check usage and ask for persistence so the browser does not evict data under storage pressure:
const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${(usage / 1e6).toFixed(1)} MB of ${(quota / 1e6).toFixed(0)} MB`);
if (navigator.storage.persist) {
const persisted = await navigator.storage.persist(); // may be granted automatically for installed apps
console.log('Persistent storage:', persisted);
}
Opaque responses. A no-cors request to another origin (for example an image from a CDN without CORS headers) returns an opaque response: status 0, unreadable body. You can cache it, but you cannot check whether it succeeded, and browsers count it as several megabytes against the quota. Prefer CORS-enabled resources.
Workbox. Writing strategies, expiration and precache manifests by hand is error-prone. Workbox, a set of libraries maintained by Google's Chrome team, provides them ready-made, and build plugins generate the precache list with content hashes. With Vite, vite-plugin-pwa integrates Workbox and generates the manifest and worker.
// sw.js with Workbox (bundled by a build tool)
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
precacheAndRoute(self.__WB_MANIFEST); // list of hashed build files, injected at build time
cleanupOutdatedCaches();
registerRoute(
({ url }) => url.pathname.startsWith('/api/v1/'),
new NetworkFirst({ cacheName: 'api', networkTimeoutSeconds: 3 })
);
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 })],
})
);
registerRoute(
({ request }) => request.destination === 'style' || request.destination === 'script',
new StaleWhileRevalidate({ cacheName: 'assets' })
);
Key Takeaways
- The Cache API stores
Request/Responsepairs under named caches, fully controlled by your code and separate from the HTTP cache. - Choose a strategy per resource: cache first for versioned static assets, network first for fresh data, stale-while-revalidate for data that may be slightly old, network only for non-GET and sensitive requests.
- Clone responses before caching them; use
event.waitUntilfor background work. - Precache the app shell during
install, limit runtime caches, and request persistent storage. - Workbox and
vite-plugin-pwaprovide tested strategies, expiration and precache manifests.
Practice
- (Beginner) Add a cache-first route for images to a service worker and verify in the Network panel that the second load of a page shows "(ServiceWorker)" as the source.
- (Intermediate) Implement a maximum-entries limit for the runtime cache: after each
put, delete the oldest entries so that at most 50 remain. Then compare your code with Workbox'sExpirationPlugin.- (Interview) "Which caching strategy would you use for an HTML page, a hashed JavaScript bundle, and a user's profile picture, and why?" Hint: consider for each how often it changes, what a stale version costs, and what should happen offline.
Offline-First Design and Background Sync
Why offline-first. Caching assets makes the app open offline, but the user also wants to work offline: read their reading list on a plane, mark a book as finished in the subway, and have the change reach the server later. "Offline" is also not just a binary state: a connection with 5 seconds of latency ("Lie-Fi") is worse than none, because the app waits instead of falling back.
Offline-first is a design approach: the app reads and writes local data first, shows it immediately, and synchronizes with the server in the background. The network becomes an enhancement rather than a requirement.
Network-first app Offline-first app
------------------ ------------------
UI --request--> server UI <--read/write--> local store (IndexedDB)
UI <--data----- server |
(spinner while waiting; | sync when possible
error if offline) v
server
An analogy: a network-first app is like calling a shop every time you want to know what is on your shopping list; an offline-first app keeps the list in your pocket and syncs it with your partner's copy when you are both home.
Building blocks.
| Need | Tool |
|---|---|
| Store structured data locally | IndexedDB (6.4), via a small wrapper such as idb |
| Remember writes made offline | An "outbox" queue in IndexedDB |
| Send the queue when back online | Background Sync API (Chromium), or online event and app start as fallback |
| Detect connectivity | navigator.onLine and the online/offline events (only a hint: "online" means a network interface is up, not that the server is reachable) |
| Resolve conflicts | Timestamps, version numbers, server-side merge rules |
Optimistic UI with an outbox. When the user marks a book as read, the app updates IndexedDB and the UI immediately, then stores the request in an outbox to send later.
// db.js: thin wrapper using the 'idb' library (promise-based IndexedDB)
import { openDB } from 'idb';
export const dbPromise = openDB('bookshelf', 1, {
upgrade(db) {
db.createObjectStore('books', { keyPath: 'id' });
db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
},
});
// Called by the UI
export async function markAsRead(bookId) {
const db = await dbPromise;
const tx = db.transaction(['books', 'outbox'], 'readwrite'); // one atomic transaction
const book = await tx.objectStore('books').get(bookId);
book.status = 'read';
book.updatedAt = Date.now();
await tx.objectStore('books').put(book); // local state updated first
await tx.objectStore('outbox').add({
method: 'PATCH',
url: `/api/v1/books/${bookId}`,
body: { status: 'read', updatedAt: book.updatedAt },
});
await tx.done;
await requestSync(); // ask to flush the outbox
}
async function requestSync() {
const reg = await navigator.serviceWorker.ready;
if ('sync' in reg) {
await reg.sync.register('flush-outbox'); // Background Sync (Chromium)
} else {
flushOutbox(); // fallback: try now (and on 'online')
}
}
window.addEventListener('online', () => flushOutbox());
Background Sync. The Background Sync API lets the service worker run a task when the browser believes connectivity is available, even if the user has closed the page. If the task's Promise rejects, the browser retries later with backoff (a limited number of times).
// sw.js
import { openDB } from 'idb';
self.addEventListener('sync', (event) => {
if (event.tag === 'flush-outbox') {
event.waitUntil(flushOutbox()); // rejection -> the browser retries later
}
});
async function flushOutbox() {
const db = await openDB('bookshelf', 1);
const entries = await db.getAll('outbox');
for (const entry of entries) {
const response = await fetch(entry.url, {
method: entry.method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry.body),
}); // a network error throws -> sync retried later
if (response.ok || response.status === 409) {
await db.delete('outbox', entry.id); // done (or a conflict the server resolved)
} else if (response.status >= 500) {
throw new Error(`Server error ${response.status}`); // retry later
}
}
}
Related APIs, all Chromium-only:
- Periodic Background Sync (
reg.periodicSync.register('refresh', { minInterval })): refresh content periodically for installed apps; the browser decides the actual frequency based on engagement. - Background Fetch: download large files (podcasts, videos) with a browser-managed progress UI, surviving page closure.
On Safari and Firefox, flush the outbox on app start, on the online event and on visibilitychange to visible.
Conflicts. If the user changes a book on the phone while offline and on the laptop while online, the two changes conflict. Common resolutions:
| Strategy | How | Trade-off |
|---|---|---|
| Last write wins | Compare updatedAt timestamps; the newest change wins | Simple; silently loses the other change; device clocks may differ |
| Version numbers | Client sends the version it edited; server rejects with 409 Conflict if it changed | Detects conflicts; the app must handle them (merge or ask the user) |
| Field-level merge | Merge non-overlapping field changes | More logic, fewer conflicts |
| CRDTs | Data types that merge automatically (Yjs, Automerge) | Ideal for collaborative editing; more complex |
Offline UX. Tell users what is happening without alarming them: a subtle "Offline: changes will sync later" indicator, a badge on items still in the outbox, and disabled actions that truly need the network (such as payments). Never lose user input.
Key Takeaways
- Offline-first apps read and write a local store first and sync with the server in the background; they also handle slow connections well.
- Use IndexedDB for data, an outbox queue for pending writes, and optimistic UI updates.
- Background Sync runs a service worker task when connectivity returns, with retries; it is Chromium-only, so add fallbacks (
online, app start, visibility). navigator.onLineis only a hint; the real test is whether a request succeeds.- Plan for conflicts: last write wins, version checks with
409, field merges or CRDTs.
Practice
- (Beginner) Add an offline indicator to the bookshelf client that appears on the
offlineevent and disappears ononline. Test it with DevTools' "Offline" network option.- (Intermediate) Implement the outbox pattern for adding and deleting books. Make changes offline, close the tab, go back online, and verify in the server logs that Background Sync sent the requests. Add the fallback for browsers without
SyncManager.- (Interview) "How would you handle two devices editing the same record while one was offline?" Hint: compare last-write-wins with optimistic concurrency (version numbers,
409 Conflict), and think about what the user should see.
Push Notifications
Why push. Chapter 6 (6.4) showed the Notifications API, which works only while the page is open. Re-engagement needs messages that arrive when the app is closed: "The book you reserved is available", "Your reading streak ends tonight". Web Push delivers a message from your server to a user's browser, which wakes the service worker even if no tab is open.
Two APIs, three parties.
- The Push API lets a service worker subscribe to a push service and receive
pushevents. - The Notifications API displays the message (
registration.showNotification). - The push service is run by the browser vendor (Google's FCM for Chrome, Mozilla's autopush for Firefox, Apple's service for Safari). Your server never connects to the device directly; it sends messages to the push service, which delivers them.
1. subscribe (with VAPID public key)
Browser/SW --------------------------------> Push service (FCM, Mozilla, Apple)
<-------------------------------- returns PushSubscription
{ endpoint: "https://fcm.googleapis.com/...",
keys: { p256dh, auth } }
2. send subscription
Page ----------------- POST /api/v1/push/subscriptions ------------------> Your server (stores it)
3. later: send a message (encrypted payload, signed with VAPID private key)
Your server ------------- POST {endpoint} ----------------> Push service
4. deliver (device online; queued if offline, up to the TTL)
Push service ------------------------------> Browser wakes SW -> 'push' event
-> showNotification()
5. user taps the notification -> 'notificationclick' -> open / focus the app
VAPID keys. Voluntary Application Server Identification identifies your server to the push service. You generate one key pair; the public key goes to the browser when subscribing, the private key stays on the server to sign messages. Payloads are encrypted end to end with the subscription's keys, so the push service cannot read them.
npx web-push generate-vapid-keys
# Public Key: BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U
# Private Key: (keep this secret, store it in an environment variable)
Step 1: subscribe in the page. Ask for permission only after a user action, and explain the benefit first. Browsers penalize or block sites that prompt on page load.
// main.js
const VAPID_PUBLIC_KEY = 'BEl62iUY...';
function urlBase64ToUint8Array(base64) {
// The key is URL-safe base64; PushManager wants raw bytes
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
document.querySelector('#notify-me').addEventListener('click', async () => {
if (!('PushManager' in window)) {
return showToast('Notifications are not supported here. On iPhone, add Bookshelf to your Home Screen first.');
}
const permission = await Notification.requestPermission(); // must follow a user gesture
if (permission !== 'granted') return;
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true, // promise: every push shows a notification
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch('/api/v1/push/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription), // PushSubscription has a toJSON() method
});
});
Step 2: send from the server. The web-push package for Node.js handles encryption and VAPID signing. In the Express API from Chapter 12:
// server/push.js
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:admin@bookshelf.example', // contact for the push service operator
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
export async function notifyUser(userId, payload) {
const subscriptions = await db.pushSubscriptions.find({ userId });
await Promise.all(subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(sub, JSON.stringify(payload), {
TTL: 60 * 60, // seconds the push service keeps it if the device is offline
urgency: 'normal', // very-low | low | normal | high (affects battery use)
topic: 'reservation', // a newer message with the same topic replaces an undelivered one
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 410) {
await db.pushSubscriptions.deleteOne({ endpoint: sub.endpoint }); // expired or unsubscribed
} else {
throw err;
}
}
}));
}
// Example: notifyUser(42, { title: 'Book available', body: '"Dune" is ready for pickup', url: '/reservations' });
Step 3: receive in the service worker.
// sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'Bookshelf', body: 'You have an update.' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png', // small monochrome icon for the Android status bar
tag: 'reservation', // replaces an earlier notification with the same tag
data: { url: data.url ?? '/' }, // arbitrary data available on click
actions: [{ action: 'open', title: 'View' }],
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = new URL(event.notification.data.url, self.location.origin).href;
event.waitUntil((async () => {
// Focus an existing window if the app is already open, otherwise open one
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const existing = windows.find((w) => w.url === target);
if (existing) return existing.focus();
return self.clients.openWindow(target);
})());
});
Rules and platform notes.
userVisibleOnly: trueis required by Chrome and Safari: every push must show a notification. Silent background pushes are not allowed on the web.- On iOS and iPadOS (16.4+), push works only for web apps added to the Home Screen, and permission must be requested from a user gesture inside the installed app. Safari 18.4 added Declarative Web Push, a JSON message format the system can display without running a service worker.
- Subscriptions expire or get revoked; delete them when the push service returns
404or410. - Respect users: send fewer, relevant notifications, let users choose topics, and make unsubscribing easy (
subscription.unsubscribe()).
Key Takeaways
- Web Push combines the Push API (receive messages in the service worker) and the Notifications API (display them), delivered through the browser vendor's push service.
- The server identifies itself with VAPID keys; payloads are encrypted end to end with the subscription's keys.
- Flow: request permission after a user gesture ->
pushManager.subscribe-> store the subscription on the server -> send with a library such asweb-push->pushevent ->showNotification. - Every push must show a notification; handle
notificationclickto open or focus the app. - iOS supports Web Push only for Home Screen web apps (16.4+); remove subscriptions that return
404/410.
Practice
- (Beginner) Generate VAPID keys, subscribe a page to push, and send a test message to it using the
web-pushcommand-line tool.- (Intermediate) Add push to the bookshelf API: a
POST /api/v1/push/subscriptionsroute, a subscription collection, and a notification when a reserved book becomes available. Clicking the notification must open the reservation page or focus it if already open.- (Interview) "Why can't a website send silent push messages to sync data in the background?" Hint: consider battery, privacy and abuse, and what
userVisibleOnlypromises; then mention which API is designed for background data sync instead.
Installability and the App Shell Model
Why installation matters. An icon on the home screen or in the dock is a constant reminder, launches without browser chrome, and gets its own entry in the app switcher. Installed PWAs also get privileges in some browsers: persistent storage, badges, push on iOS. But users rarely discover installation on their own, so it pays to understand when the browser offers it and how to prompt for it.
Install criteria (Chromium). Chrome and Edge offer installation (an icon in the address bar on desktop, an install banner or menu item on Android) when:
- The site is served over HTTPS (or
localhost). - It has a manifest linked from the page with
nameorshort_name,iconsincluding 192 px and 512 px,start_url, anddisplayset tofullscreen,standalone,minimal-uiorwindow-controls-overlay. prefer_related_applicationsis nottrue(that member tells the browser to promote a native app instead).- The user has interacted with the page (some engagement heuristics apply).
A service worker with a fetch handler was historically required; Chrome dropped that requirement in 2023, but you still need one for offline support and push.
Install flows by platform.
| Platform | How users install |
|---|---|
| Android (Chrome, Edge, Samsung Internet) | Install banner or "Install app" in the menu; creates a WebAPK, a real Android package generated by the browser |
| Desktop Chrome / Edge | Install icon in the address bar or menu; the app gets its own window, start menu or dock entry |
| iOS / iPadOS Safari (and other iOS browsers, 16.4+) | Share button -> "Add to Home Screen"; no programmatic prompt |
| macOS Safari (Sonoma and later) | File -> "Add to Dock" |
| Firefox | Android: "Install"; desktop: limited support |
A custom install button. Chromium fires beforeinstallprompt when the app is installable. You can prevent the default mini-infobar, save the event, and show your own install button at a good moment (for example after the user added their third book):
// main.js
let deferredPrompt = null;
const installButton = document.querySelector('#install');
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault(); // do not show the browser's default prompt now
deferredPrompt = event; // keep it to call prompt() later
installButton.hidden = false;
});
installButton.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt(); // must be called from a user gesture, once per event
const { outcome } = await deferredPrompt.userChoice; // 'accepted' or 'dismissed'
console.log('Install outcome:', outcome);
deferredPrompt = null;
installButton.hidden = true;
});
window.addEventListener('appinstalled', () => {
installButton.hidden = true;
analytics.track('pwa_installed');
});
// iOS has no beforeinstallprompt: show instructions instead
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS reports as Mac
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone;
if (isIOS && !isStandalone) showHint('Tap Share, then "Add to Home Screen" to install Bookshelf.');
The app shell model. Native apps feel fast because their UI frame (toolbar, navigation, empty layouts) is installed on the device; only data comes from the network. The app shell model copies this: separate the minimal HTML, CSS and JavaScript needed to draw the UI frame (the shell) from the content (data). The shell is precached by the service worker, so on repeat visits it appears instantly, even offline, and content is filled in as it arrives.
+--------------------------------------+
| [=] Bookshelf [search]| <- shell: header, navigation, layout
+--------------------------------------+ precached at install, loaded from cache
| +-------+ ################## |
| | | ######## | <- content: book data from /api/v1/books
| +-------+ | (network first, cached copy offline);
| +-------+ ################## | skeleton placeholders while loading
| | | ######## |
| +-------+ |
+--------------------------------------+
| Library Reading list Profile | <- shell: tab bar
+--------------------------------------+
Steps to apply it:
- Identify the shell: index HTML, core CSS, the application JavaScript bundle, logo and icons.
- Precache those files during
install(with hashed filenames, so updates are safe). - In the
fetchhandler, answer navigation requests with the cached shell (/index.html) for single-page apps, then let client-side routing render the right view. - Fetch content with network first or stale-while-revalidate, and show skeleton screens while it loads.
// sw.js: serve the cached shell for every in-app navigation (SPA)
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
caches.match('/index.html').then((shell) => shell ?? fetch(event.request))
);
}
});
App shell and server rendering. The app shell model fits client-rendered single-page apps (Chapter 14). With server-side rendering, a common alternative is to cache full HTML responses with network first (or stream a cached header and footer around network content), so the first visit benefits from server rendering and repeat visits still work offline.
Key Takeaways
- Chromium offers installation for HTTPS sites with a valid manifest (name, 192/512 icons,
start_url, app-likedisplay); a service worker is no longer strictly required but is needed for offline and push. beforeinstallprompt(Chromium only) lets you defer the prompt and show your own install button;appinstalledconfirms installation.- iOS installation is manual through "Add to Home Screen"; show instructions instead of a button.
- The app shell model precaches the UI frame and loads content separately, giving instant repeat loads and offline startup.
- For SPAs, answer navigation requests with the cached shell; show skeletons while content loads.
Practice
- (Beginner) Make the bookshelf client installable in Chrome. Use DevTools' Application > Manifest pane to fix any installability errors, install it, and open it as a standalone window.
- (Intermediate) Implement a custom install button that appears only after the user has added at least three books, and an iOS-specific hint for Safari users. Track install outcomes in the console.
- (Interview) "What is the app shell model and when is it not a good fit?" Hint: think about what is cached, what is fetched, the first-visit experience, and how it interacts with server-side rendering and SEO.
Auditing with Lighthouse
Why audit. A PWA can look fine on the developer's fast laptop and still load slowly on a mid-range phone, fail accessibility checks or have a broken manifest. Lighthouse is an open-source tool from Google that loads a page under controlled conditions (by default a simulated mid-range mobile device on a slow 4G network), runs a series of audits and produces a report with scores and concrete recommendations.
What Lighthouse checks.
| Category | Examples of audits |
|---|---|
| Performance | First Contentful Paint, Largest Contentful Paint, Total Blocking Time, Cumulative Layout Shift, Speed Index; insights such as render-blocking resources, unused JavaScript, image sizes |
| Accessibility | Color contrast, alt text, form labels, ARIA usage, tap target size |
| Best Practices | HTTPS, no console errors, no deprecated APIs, correct image aspect ratios |
| SEO | Title and meta description, crawlable links, valid robots.txt, viewport meta tag |
The PWA category was removed. Lighthouse used to have a "PWA" category with a badge for installability and offline support. It was removed in Lighthouse 12 (2024), because the criteria had diverged across browsers and the checks were not good predictors of PWA quality. Installability is now checked in Chrome DevTools: Application > Manifest lists installability errors, and Application > Service workers shows the worker's state. Offline behavior is tested by enabling "Offline" in the Network panel or the Service workers pane and reloading. Older tutorials still refer to the PWA category; do not expect it in current tools.
Lab metrics and the performance score. Lighthouse measures lab data, a single simulated run, unlike the field data (Core Web Vitals from real users) described in 15.1. Because no user interacts during a lab run, INP cannot be measured; Total Blocking Time (TBT), the time the main thread was blocked by long tasks during loading, is used as a proxy. The performance score (0 to 100) is a weighted average:
| Metric | Weight |
|---|---|
| Total Blocking Time | 30% |
| Largest Contentful Paint | 25% |
| Cumulative Layout Shift | 25% |
| First Contentful Paint | 10% |
| Speed Index | 10% |
Scores vary between runs (network, CPU load, browser extensions). Run audits several times, in an incognito window or from the command line, and compare medians rather than single numbers.
Ways to run Lighthouse.
- Chrome DevTools > Lighthouse panel: choose categories and mobile or desktop, then "Analyze page load". Also available as "Timespan" (audit an interaction) and "Snapshot" (audit the current state) modes.
- PageSpeed Insights (pagespeed.web.dev): runs Lighthouse on Google's servers and also shows field data from the Chrome UX Report when the site has enough traffic.
- Command line:
# Run against a local production build (not the dev server: dev builds are unminified)
npx lighthouse http://localhost:4173 --view # opens the HTML report
npx lighthouse http://localhost:4173 --only-categories=performance,accessibility \
--output=json --output-path=./reports/home.json # machine-readable result
npx lighthouse http://localhost:4173 --preset=desktop # desktop configuration
- Node API, for scripted audits:
// audit.mjs: audit a page and fail if performance drops below 90
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless=new'] });
const result = await lighthouse('http://localhost:4173', {
port: chrome.port, // Lighthouse drives Chrome through the DevTools protocol
onlyCategories: ['performance', 'accessibility'],
output: 'json',
});
await chrome.kill();
const { performance, accessibility } = result.lhr.categories;
console.log(`Performance ${performance.score * 100}, accessibility ${accessibility.score * 100}`);
if (performance.score < 0.9) process.exit(1);
Lighthouse CI. To catch regressions before they reach users, run Lighthouse on every pull request with Lighthouse CI (@lhci/cli). It runs several times, asserts against thresholds and can upload reports. The configuration lives in lighthouserc.json:
{
"ci": {
"collect": {
"startServerCommand": "npm run preview",
"url": ["http://localhost:4173/", "http://localhost:4173/books/1"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
# .github/workflows/lighthouse.yml (excerpt)
- run: npm ci && npm run build
- run: npx @lhci/cli autorun # collect, assert, upload according to lighthouserc.json
Reading the report. Treat the score as a summary, not a goal. The useful part is the list of opportunities and diagnostics: each names specific files and estimated savings. Fix the largest items first (usually JavaScript size, image sizes and render-blocking resources), then re-run. Pass/fail audits for accessibility are a starting point; automated tools find only a part of accessibility problems, so manual testing with a screen reader and keyboard is still needed.
Key Takeaways
- Lighthouse audits performance, accessibility, best practices and SEO under simulated mobile conditions and suggests concrete fixes.
- The PWA category was removed in Lighthouse 12; check installability in DevTools' Application panel and test offline mode manually.
- Lighthouse produces lab data (TBT replaces INP); real-user field data comes from the Chrome UX Report and your own monitoring.
- Run it from DevTools, PageSpeed Insights, the CLI or the Node API; audit production builds and compare several runs.
- Lighthouse CI asserts score and metric thresholds on every pull request to prevent regressions.
Practice
- (Beginner) Run Lighthouse in DevTools on the production build of the bookshelf client in mobile mode. Write down the three largest opportunities and fix one of them; re-run and compare.
- (Intermediate) Set up Lighthouse CI in a GitHub Actions workflow with assertions for performance, accessibility and CLS. Introduce a deliberate regression (a 2 MB unoptimized image) and confirm the workflow fails.
- (Interview) "Your Lighthouse performance score is 98, but users complain the site feels slow on their phones. How can both be true?" Hint: compare lab and field data, simulated devices with real ones, page load with interactions after load, and the pages Lighthouse tested with the pages users visit.
<a id="153-jquery-mobile"></a>
15.3 jQuery Mobile
jQuery Mobile was the most popular framework for mobile websites in the early 2010s. It is deprecated today, but it still runs in many older applications and Cordova projects, and its ideas (declarative widgets in HTML, page transitions, touch-friendly components, themes) shaped the frameworks that followed. This subchapter teaches enough of it to read and maintain existing code, and explains how to migrate away.
Framework Overview and Page Model
Why jQuery Mobile existed. Around 2010, smartphones were spreading quickly, but mobile browsers differed greatly (iOS Safari, the Android stock browser, BlackBerry, Windows Phone, Symbian), CSS flexbox and grid were not usable, and touch events behaved differently everywhere. Building a site that felt like an app (toolbars, sliding page transitions, big touch-friendly lists) meant writing a lot of fragile code. jQuery Mobile, released by the jQuery project in 2010 (1.0 in November 2011), offered a solution for web developers who already knew jQuery (Chapter 8): write semantic HTML with a few data- attributes, and the framework turns it into a touch-optimized, themed, app-like interface that works across many devices.
Core ideas.
- Progressive enhancement: the markup is plain HTML that works without JavaScript; jQuery Mobile enhances it into widgets.
- Declarative configuration: behavior is set with
data-*attributes (data-role="page",data-theme="b"), not with JavaScript calls. - Single-page navigation with AJAX: links load new pages with AJAX and animate them in, while keeping the browser's back button working through the URL hash and History API.
- Built on jQuery: it depends on jQuery (1.4.5, the last stable release, supports jQuery 1.8 to 1.11 and 2.1) and extends it with widgets built on the jQuery UI widget factory.
Setup.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bookshelf</title>
<link rel="stylesheet" href="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css">
<script src="https://code.jquery.com/jquery-1.11.3.min.js"></script>
<script>
// mobileinit fires before jQuery Mobile initializes: the place to change defaults.
// It must be bound AFTER jQuery and BEFORE jquery.mobile.js is loaded.
$(document).on('mobileinit', function () {
$.mobile.defaultPageTransition = 'slide';
});
</script>
<script src="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
</head>
<body>
<!-- pages go here -->
</body>
</html>
The page model. jQuery Mobile structures the UI as pages. A page is an element with data-role="page", usually containing a header toolbar, a content area and a footer toolbar. Only one page is visible at a time.
<div data-role="page" id="library">
<div data-role="header">
<h1>Library</h1>
</div>
<div role="main" class="ui-content"> <!-- 1.4 style; older code uses data-role="content" -->
<p>Welcome to your bookshelf.</p>
<a href="#reading" class="ui-btn ui-corner-all">Reading list</a>
</div>
<div data-role="footer" data-position="fixed"> <!-- stays at the bottom while scrolling -->
<h4>3 books</h4>
</div>
</div>
+----------------------------+
| Library | data-role="header" (toolbar, title centered)
+----------------------------+
| |
| Welcome to your bookshelf | role="main" class="ui-content"
| [ Reading list ] |
| |
+----------------------------+
| 3 books | data-role="footer" data-position="fixed"
+----------------------------+
Single-page and multi-page documents. One HTML file can contain several pages (a multi-page template); links to #id show the page with that ID. Alternatively, each page lives in its own file, and jQuery Mobile loads it with AJAX, inserts its first data-role="page" element into the DOM and transitions to it. In that case only the content of the first page in the loaded file is used, and scripts in its <head> are not executed, a common source of bugs.
<!-- One file, two pages -->
<div data-role="page" id="library">
<div data-role="header"><h1>Library</h1></div>
<div role="main" class="ui-content">
<a href="#reading">Go to reading list</a>
</div>
</div>
<div data-role="page" id="reading">
<div data-role="header">
<a href="#library" data-rel="back" class="ui-btn ui-icon-back ui-btn-icon-left">Back</a>
<h1>Reading list</h1>
</div>
<div role="main" class="ui-content"><p>Dune, Neuromancer</p></div>
</div>
Page events instead of $(document).ready. Because pages are loaded and shown dynamically, $(document).ready runs only once for the first document. Code that sets up a page must listen to page events:
| Event | Fires on | Use |
|---|---|---|
mobileinit | document, before initialization | Configure global defaults |
pagecreate | The page, once, after enhancement | Bind handlers, initialize plugins for that page |
pagecontainerbeforeshow | The page container, before each transition in | Refresh data shown on the page |
pagecontainershow | The page container, after the transition | Start animations, set focus |
pagecontainerbeforehide / pagecontainerhide | Before and after leaving a page | Stop timers, save state |
// Run once when the #reading page is first enhanced
$(document).on('pagecreate', '#reading', function () {
$(this).find('.refresh').on('click', loadReadingList);
});
// Run every time a page becomes visible (1.4 API: events on the page container)
$(document).on('pagecontainerbeforeshow', function (event, ui) {
if (ui.toPage.attr('id') === 'reading') loadReadingList();
});
Older code uses pageinit, pagebeforeshow and pageshow bound to individual pages; these were deprecated in 1.4 in favor of the pagecontainer events.
Key Takeaways
- jQuery Mobile (2010) turned semantic HTML with
data-*attributes into touch-friendly, themed mobile interfaces on top of jQuery. - A page is an element with
data-role="page"containing a header, a content area (role="main" class="ui-content") and a footer. - Pages can share one HTML file (multi-page template) or be loaded with AJAX from separate files; scripts in AJAX-loaded
<head>elements do not run. - Configure defaults in a
mobileinithandler bound before loading the library. - Use
pagecreateandpagecontainer*events instead of$(document).ready.
Practice
- (Beginner) Create a multi-page jQuery Mobile document with a "Library" page and a "Book details" page, each with a header and footer, and links between them.
- (Intermediate) Move the details page into its own file loaded through AJAX, put a script in its
<head>, and observe that it does not run. Fix the problem by moving the setup code into apagecreatehandler in the main file.- (Interview) "Why doesn't
$(document).readywork for page setup in jQuery Mobile?" Hint: think about how many times the document is loaded versus how many times pages are inserted and shown.
Data Attributes and Widget Markup
Why data attributes. jQuery Mobile's main promise was that web designers could build mobile UIs without writing JavaScript. HTML5's custom data-* attributes were the vehicle: they are valid HTML, ignored by browsers, and readable by scripts. During initialization, jQuery Mobile scans each page, finds elements with known roles and options, and enhances them: it adds classes, wraps elements in extra markup and attaches widget behavior.
How enhancement works.
Your markup After enhancement (simplified)
----------- ------------------------------
<ul data-role="listview" <ul class="ui-listview ui-listview-inset
data-inset="true"> ui-corner-all ui-shadow">
<li><a href="#b1">Dune</a></li> <li><a href="#b1" class="ui-btn ui-btn-icon-right
</ul> ui-icon-carat-r">Dune</a></li>
</ul>
The framework reads the role (which widget), then the options (data-inset, data-theme, data-icon and so on), and calls the corresponding widget plugin, for example $('ul').listview({ inset: true }).
Common roles and attributes.
| Attribute | Example | Effect |
|---|---|---|
data-role | page, header, footer, listview, navbar, panel, popup, collapsible, controlgroup, flipswitch, table | Which widget to create |
data-theme | a, b | Color swatch (15.3, Themes) |
data-position | fixed | Fixed toolbars |
data-inset | true | Rounded, inset list or collapsible |
data-filter | true | Adds a search box that filters list items |
data-icon / data-iconpos | star, left | Icons on widgets that still use attributes |
data-mini | true | Compact form element |
data-rel | back, popup, external | Link behavior |
data-transition | slide, pop, fade | Page transition |
data-ajax | false | Disable AJAX loading for a link or form |
data-enhance / data-role="none" | false, none | Prevent enhancement of a container or element |
Buttons in 1.4. Version 1.4 moved some widgets from data attributes to plain CSS classes for performance: a button is now just an anchor or <button> with classes. You will see both styles in existing code:
<!-- jQuery Mobile 1.4: CSS classes -->
<a href="#add" class="ui-btn ui-corner-all ui-shadow ui-icon-plus ui-btn-icon-left">Add book</a>
<a href="#add" class="ui-btn ui-btn-inline ui-mini">Small inline button</a>
<!-- Older (1.3 and earlier) style, deprecated in 1.4 -->
<a href="#add" data-role="button" data-icon="plus" data-iconpos="left">Add book</a>
<!-- Grouped buttons -->
<div data-role="controlgroup" data-type="horizontal">
<a href="#" class="ui-btn ui-corner-all">All</a>
<a href="#" class="ui-btn ui-corner-all ui-btn-active">Unread</a>
<a href="#" class="ui-btn ui-corner-all">Read</a>
</div>
Other widgets.
<!-- Navbar: up to 5 buttons spread evenly; often inside a header or footer -->
<div data-role="footer" data-position="fixed">
<div data-role="navbar">
<ul>
<li><a href="#library" class="ui-btn-active ui-state-persist" data-icon="home">Library</a></li>
<li><a href="#reading" data-icon="star">Reading</a></li>
<li><a href="#profile" data-icon="user">Profile</a></li>
</ul>
</div>
</div>
<!-- Collapsible: an accordion section -->
<div data-role="collapsible" data-collapsed="true">
<h3>Book details</h3>
<p>Published 1965. 412 pages.</p>
</div>
<!-- Panel: a slide-out drawer, a child of the page -->
<div data-role="panel" id="menu" data-display="overlay">
<ul data-role="listview"><li><a href="#settings">Settings</a></li></ul>
</div>
<a href="#menu" class="ui-btn ui-icon-bars ui-btn-icon-notext ui-corner-all">Menu</a>
Enhancing dynamic content. Markup inserted after initialization is not enhanced automatically. Either trigger creation for a container or refresh an existing widget:
// New markup containing widgets: enhance everything inside the container
$('#results').html(html).enhanceWithin(); // 1.4; older code used .trigger('create')
// Existing widget whose items changed: refresh it
$('#bookList').append('<li><a href="#b9">Hyperion</a></li>').listview('refresh');
// Programmatic widget calls follow the jQuery UI widget pattern
$('#menu').panel('open');
$('#darkMode').flipswitch('option', 'disabled', true);
Key Takeaways
- jQuery Mobile reads
data-roleand otherdata-*attributes and enhances matching elements into widgets by adding classes, markup and behavior. - Common roles include
page,header,footer,listview,navbar,panel,popup,collapsibleandcontrolgroup. - In 1.4 buttons use CSS classes (
ui-btn,ui-icon-*,ui-btn-icon-left);data-role="button"is deprecated. - Dynamically inserted markup must be enhanced with
.enhanceWithin(); changed widgets need theirrefreshmethod. - Widgets also have a jQuery UI-style JavaScript API (
.panel('open'),.listview('refresh')).
Practice
- (Beginner) Add a fixed footer navbar with three tabs and a slide-out panel menu to the bookshelf jQuery Mobile app.
- (Intermediate) Load the book list from
GET /api/v1/bookswith$.getJSON, build<li>elements, append them to adata-role="listview"and refresh it. Add a collapsible with details inside each item, and make sure it is enhanced.- (Interview) "What does 'enhancement' mean in jQuery Mobile, and what are its performance costs?" Hint: think about how much DOM is added and changed for each widget on a slow 2012 phone, and why 1.4 moved buttons to plain CSS classes.
Navigation, Transitions and Dialogs
Why a navigation system. In a traditional site, each link loads a new document and the screen goes white between pages, which feels nothing like a native app. jQuery Mobile's navigation system intercepts links and form submissions, fetches the target with AJAX, inserts the page into the DOM and animates the change, while updating the URL so the back button, bookmarks and reloads still work.
How navigation works.
user taps <a href="details.html">
|
v
jQuery Mobile intercepts the click (same-origin link, AJAX enabled)
|
v
$.ajax('details.html') -> extract first [data-role="page"] -> enhance -> insert into DOM
|
v
transition: current page slides out, new page slides in
|
v
URL updated with history.pushState (or #hash on old browsers)
-> the Back button triggers the reverse transition
Links and their options.
| Markup | Behavior |
|---|---|
<a href="#reading"> | Show the internal page with ID reading |
<a href="details.html"> | Load via AJAX and transition |
<a href="details.html" data-transition="flip"> | Use a specific transition |
<a href="#" data-rel="back"> | Go back in history (like the Back button) |
<a href="other.html" data-ajax="false"> or rel="external" | Normal full page load (needed for other multi-page documents, other origins, or file downloads) |
<a href="#library" data-direction="reverse"> | Play the transition backwards |
Transitions. Available values for data-transition include fade (the default in 1.4), slide, slideup, slidedown, slidefade, pop, flip, turn, flow and none. They use CSS 3D transforms where supported; on devices without them, jQuery Mobile falls back to fade (configurable with $.mobile.maxTransitionWidth and $.mobile.transitionFallbacks).
Programmatic navigation. In 1.4 you change pages through the pagecontainer widget:
// Go to an internal page with a slide transition
$(':mobile-pagecontainer').pagecontainer('change', '#reading', {
transition: 'slide',
reverse: false,
});
// Load an external page, passing data through the query string
$(':mobile-pagecontainer').pagecontainer('change', 'details.html?id=7', {
transition: 'pop',
});
// Older equivalent, deprecated in 1.4:
// $.mobile.changePage('#reading', { transition: 'slide' });
Passing data between pages is a recurring problem. Options are the query string (read it in pagecontainerbeforeshow from ui.toPage.data('url')), a shared JavaScript variable or module, or sessionStorage.
Dialogs and popups. jQuery Mobile has two ways to show content above the current page:
- A dialog is a full page styled as a modal window, reached by navigation. In 1.4 you mark the page with
data-dialog="true"(older code: link withdata-rel="dialog"). It gets a close button and takes part in history (Back closes it). - A popup is a small overlay inside the current page (
data-role="popup"), opened by a link withdata-rel="popup"or with.popup('open'). Use it for menus, tooltips, confirmations and images.
<!-- Dialog: a separate page -->
<div data-role="page" id="confirmDelete" data-dialog="true" data-close-btn="right">
<div data-role="header"><h1>Delete book?</h1></div>
<div role="main" class="ui-content">
<p>This cannot be undone.</p>
<a href="#" id="doDelete" class="ui-btn ui-corner-all ui-btn-b">Delete</a>
<a href="#" data-rel="back" class="ui-btn ui-corner-all">Cancel</a>
</div>
</div>
<!-- Popup: inside the current page -->
<a href="#sortMenu" data-rel="popup" data-transition="pop" class="ui-btn ui-btn-inline">Sort</a>
<div data-role="popup" id="sortMenu" data-theme="a">
<ul data-role="listview" data-inset="true" style="min-width: 200px">
<li data-role="list-divider">Sort by</li>
<li><a href="#" data-sort="title">Title</a></li>
<li><a href="#" data-sort="author">Author</a></li>
</ul>
</div>
$(document).on('click', '#sortMenu a[data-sort]', function () {
sortBooks($(this).data('sort'));
$('#sortMenu').popup('close');
});
Pitfalls. Because AJAX-loaded pages stay in the DOM (cached) or are removed (by default, only the first page is kept), IDs can be duplicated if the same file is loaded twice; scope selectors to the active page ($.mobile.activePage or $(':mobile-pagecontainer').pagecontainer('getActivePage')). Pages that keep server state should set data-dom-cache="false" (the default) to get fresh markup.
Key Takeaways
- jQuery Mobile intercepts links, loads pages with AJAX, animates transitions and keeps history and URLs working.
data-transition,data-rel="back",data-direction="reverse"anddata-ajax="false"control link behavior.- Navigate in code with
pagecontainer('change', target, options);$.mobile.changePageis deprecated. - Dialogs are pages shown modally (
data-dialog="true"); popups are overlays within the current page (data-role="popup"). - Scope selectors to the active page, because several pages can be in the DOM at once.
Practice
- (Beginner) Add a "Delete book?" dialog and a "Sort by" popup to the bookshelf app, with transitions, and verify that the Back button closes the dialog.
- (Intermediate) Implement a list-to-details flow: tapping a book in the list navigates to a single
detailspage withpagecontainer('change'), passing the book ID; the details page loads the book from the API inpagecontainerbeforeshow.- (Interview) "How does jQuery Mobile keep the back button working while loading pages with AJAX?" Hint: think about the History API, hash fragments as a fallback, and what happens when the user reloads on a deep URL.
Mobile Form Controls and List Views
Why special controls. Native form controls in 2011 mobile browsers were small, inconsistent and hard to tap. jQuery Mobile replaced their appearance with large, finger-friendly, themed versions while keeping the real <input> and <select> elements underneath. Forms therefore still submit normally and degrade to native controls without JavaScript. Lists were the other workhorse of mobile UI (contacts, settings, messages), and jQuery Mobile's list view became its most recognizable widget.
Form controls. Most controls are enhanced automatically from standard HTML:
<form id="bookForm" action="/books" method="post" data-ajax="false">
<!-- Text input with a clear button -->
<label for="title">Title</label>
<input type="text" name="title" id="title" data-clear-btn="true" required>
<!-- Search input gets a search icon and clear button -->
<label for="q">Search</label>
<input type="search" name="q" id="q">
<!-- Slider from a range input -->
<label for="rating">Rating</label>
<input type="range" name="rating" id="rating" min="0" max="5" step="1" value="3" data-highlight="true">
<!-- Flip switch (1.4): an on/off toggle from a checkbox -->
<label for="finished">Finished</label>
<input type="checkbox" data-role="flipswitch" name="finished" id="finished"
data-on-text="Yes" data-off-text="No">
<!-- Radio buttons grouped as a segmented control -->
<fieldset data-role="controlgroup" data-type="horizontal">
<legend>Format</legend>
<input type="radio" name="format" id="f-paper" value="paper" checked>
<label for="f-paper">Paper</label>
<input type="radio" name="format" id="f-ebook" value="ebook">
<label for="f-ebook">E-book</label>
</fieldset>
<!-- Select: native picker by default; data-native-menu="false" uses a custom themed menu -->
<label for="genre">Genre</label>
<select name="genre" id="genre" data-native-menu="false">
<option value="sf">Science fiction</option>
<option value="fantasy">Fantasy</option>
<option value="history">History</option>
</select>
<input type="submit" value="Save" data-theme="b">
</form>
Labels and layout. Every control needs a <label>; jQuery Mobile styles it and uses it as the tappable surface for checkboxes and radios. To place labels beside fields on wider screens, wrap each pair in <div class="ui-field-contain"> (called data-role="fieldcontain" before 1.4). Add data-mini="true" for compact controls.
Changing values in code. Enhanced controls show a custom UI, so after changing the underlying value in JavaScript, call the widget's refresh method:
$('#rating').val(5).slider('refresh');
$('#finished').prop('checked', true).flipswitch('refresh');
$('#genre').val('fantasy').selectmenu('refresh');
$('input[name=format][value=ebook]').prop('checked', true);
$('input[name=format]').checkboxradio('refresh');
AJAX form submission. By default, jQuery Mobile submits forms with AJAX and displays the response as a new page. For APIs that return JSON, disable that with data-ajax="false" and handle submission yourself:
$(document).on('submit', '#bookForm', function (event) {
event.preventDefault();
$.mobile.loading('show', { text: 'Saving...', textVisible: true }); // built-in spinner
$.ajax({
url: '/api/v1/books',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(Object.fromEntries(new FormData(this))),
})
.done(() => $(':mobile-pagecontainer').pagecontainer('change', '#library'))
.fail((xhr) => alert(`Could not save: ${xhr.status}`))
.always(() => $.mobile.loading('hide'));
});
List views. A <ul> or <ol> with data-role="listview" becomes a touch-friendly list. Its features are added through markup:
<ul data-role="listview" data-filter="true" data-filter-placeholder="Search books..."
data-inset="true" data-autodividers="true" id="bookList">
<!-- data-autodividers: dividers generated from the first letter of each item -->
<li>
<a href="#book-1">
<img src="covers/dune-80.jpg" alt=""> <!-- thumbnail on the left -->
<h2>Dune</h2>
<p>Frank Herbert</p>
<span class="ui-li-count">412</span> <!-- count bubble on the right -->
</a>
</li>
<li>
<!-- Split button: the main link opens details, the second link is a separate action -->
<a href="#book-2"><h2>Neuromancer</h2><p>William Gibson</p></a>
<a href="#confirmDelete" data-rel="popup" data-icon="delete">Delete</a>
</li>
<li data-role="list-divider">Wishlist</li> <!-- manual divider -->
<li><a href="#book-3">Hyperion</a></li>
<li data-filtertext="Le Guin earthsea fantasy">A Wizard of Earthsea</li> <!-- extra search terms -->
</ul>
+--------------------------------------+
| [ Search books... ] | data-filter="true"
+--------------------------------------+
| D | auto divider
| [img] Dune (412) | thumbnail, heading, count bubble
| Frank Herbert > |
| N |
| Neuromancer | (x) | split button
| William Gibson | |
+--------------------------------------+
The search filter is the separate filterable widget in 1.4; data-filter="true" on a list view still creates it for compatibility.
Responsive tables and grids. For tabular data, data-role="table" with data-mode="reflow" stacks cells vertically on narrow screens, and data-mode="columntoggle" lets the user hide columns. Simple column layouts use the grid classes ui-grid-a (2 columns) to ui-grid-d (5 columns) with ui-block-a, ui-block-b and so on.
Key Takeaways
- jQuery Mobile restyles native form controls into large, themed widgets while keeping real form elements underneath.
- Special controls include sliders (
type="range"), flip switches (data-role="flipswitch"), segmented control groups and custom select menus (data-native-menu="false"). - After changing a control's value in code, call its widget
refreshmethod. - Use
data-ajax="false"and custom handlers for JSON APIs;$.mobile.loadingshows a spinner. - List views support thumbnails, count bubbles, split buttons, dividers and built-in filtering; tables can reflow or toggle columns.
Practice
- (Beginner) Build an "Add book" form with a text input, genre select, rating slider and "finished" flip switch, laid out with
ui-field-contain.- (Intermediate) Make the book list filterable, with thumbnails, auto-dividers and a split "Delete" button that opens a confirmation popup and removes the item through
DELETE /api/v1/books/:id, refreshing the list afterwards.- (Interview) "Why does jQuery Mobile require calling
refreshafter changing a value programmatically?" Hint: consider the relationship between the hidden native element and the visible enhanced markup, and which events fire when code (rather than the user) changes a value.
Themes and ThemeRoller
Why a theme system. Mobile apps need a consistent look: toolbars, buttons, lists and forms that share colors, borders and shadows. In 2011, before CSS custom properties, changing the look of a widget library meant editing many CSS rules. jQuery Mobile separated structure CSS (layout and sizing) from theme CSS (colors, gradients, fonts) and organized colors into swatches that can be applied to any widget with one attribute.
Swatches. A swatch is a named color scheme identified by a letter. Version 1.4 ships with two:
| Swatch | Look | Typical use |
|---|---|---|
a | Light: light grey bars, white content, dark text | Default for pages and content |
b | Dark: dark bars and backgrounds, light text | Toolbars, emphasis, dark sections |
Versions up to 1.3 shipped five swatches, a to e, with glossy gradients. Custom themes can define up to 26 swatches (a to z).
Applying swatches.
<div data-role="page" data-theme="a"> <!-- page and its content -->
<div data-role="header" data-theme="b"> <!-- dark toolbar -->
<h1>Bookshelf</h1>
</div>
<div role="main" class="ui-content">
<a href="#" class="ui-btn ui-btn-b ui-corner-all">Primary action</a> <!-- 1.4 class form -->
<ul data-role="listview" data-theme="a" data-divider-theme="b" data-count-theme="b">
<li data-role="list-divider">Reading</li>
<li><a href="#">Dune <span class="ui-li-count">2</span></a></li>
</ul>
</div>
</div>
Swatches are inherited: a widget without data-theme takes the swatch of its container. In CSS, swatches appear as classes such as .ui-page-theme-a, .ui-bar-b, .ui-body-a and .ui-btn-b.
ThemeRoller. ThemeRoller for jQuery Mobile (themeroller.jquerymobile.com) is a web-based visual editor: you pick colors for bars, bodies and buttons of each swatch, drag colors from the palette (including Adobe Kuler palettes at the time), preview the result live, and download a CSS file. The download contains the theme CSS; you then load the structure-only CSS from jQuery Mobile and your theme after it:
<!-- Custom theme: structure CSS + your generated theme, instead of the default combined file -->
<link rel="stylesheet" href="themes/bookshelf.min.css"> <!-- from ThemeRoller -->
<link rel="stylesheet" href="themes/jquery.mobile.icons.min.css"> <!-- icons (1.4) -->
<link rel="stylesheet" href="https://code.jquery.com/mobile/1.4.5/jquery.mobile.structure-1.4.5.min.css">
Overriding styles. For small changes, override theme classes in your own stylesheet loaded after the framework's. Because the framework applies its classes during enhancement, target those classes rather than your original markup:
/* Brand color for swatch b buttons and toolbars */
.ui-page-theme-a .ui-btn.ui-btn-b,
.ui-bar-b {
background-color: #2e5d4b;
border-color: #234a3c;
color: #fff;
text-shadow: none;
}
/* Allow long titles in headers instead of cutting them with "..." */
.ui-header .ui-title {
white-space: normal;
margin: 0 3em;
}
Icons. Version 1.4 uses SVG icons with PNG fallback, applied through classes such as ui-icon-star, ui-icon-delete, ui-icon-carat-r and position classes ui-btn-icon-left, ui-btn-icon-notext. Custom icons are defined with CSS by setting a background image on a new ui-icon-* class.
Key Takeaways
- jQuery Mobile separates structure CSS from theme CSS; colors are grouped into lettered swatches.
- Version 1.4 ships swatches
a(light) andb(dark); earlier versions hadatoe; custom themes can have up to 26. - Apply swatches with
data-themeor classes likeui-btn-b; widgets inherit their container's swatch. - ThemeRoller generates a custom theme CSS file, used together with the structure-only CSS.
- Override styles by targeting the framework's enhanced classes in a stylesheet loaded afterwards.
Practice
- (Beginner) Apply swatch
bto the header and footer of the bookshelf app and swatchato the content; add one dark primary button.- (Intermediate) Create a custom theme with ThemeRoller (or by hand) with a third swatch
cin the bookshelf brand color, load it with the structure-only CSS, and use it for list dividers and the primary button.- (Interview) "How would you implement theming for a component library today, compared with jQuery Mobile's swatch approach?" Hint: think about CSS custom properties,
prefers-color-scheme, and switching themes at runtime without loading new stylesheets.
Touch Events and Responsive Behavior
Why custom events. In jQuery Mobile's era, touch support was inconsistent: some devices fired touch events, some only mouse events, Windows Phone used Microsoft's pointer events, and click was delayed by about 300 ms. jQuery Mobile normalized this with its own set of events, so the same code worked everywhere.
Touch and gesture events.
| Event | Fires when |
|---|---|
tap | A quick touch and release (no 300 ms delay) |
taphold | A touch held for about 750 ms ($.event.special.tap.tapholdThreshold) |
swipe | A horizontal drag of at least about 30 px within one second, with limited vertical movement |
swipeleft / swiperight | A swipe in that direction |
scrollstart / scrollstop | Scrolling begins or ends |
orientationchange | The device rotates; event.orientation is portrait or landscape |
throttledresize | A throttled version of resize |
vclick, vmousedown, vmouseup, vmousemove, vmouseover, vmouseout, vmousecancel | Virtual mouse events: unified mouse and touch events |
// Swipe left on a book to reveal actions; long-press to open a context menu
$(document).on('swipeleft', '#bookList li', function () {
$(this).addClass('show-actions');
});
$(document).on('swiperight', '#bookList li', function () {
$(this).removeClass('show-actions');
});
$(document).on('taphold', '#bookList li', function (event) {
event.preventDefault();
$('#bookMenu').data('bookId', $(this).data('id')).popup('open', { positionTo: this });
});
// Swipe between pages, a common jQuery Mobile pattern
$(document).on('swipeleft', '.ui-page', function () {
const next = $(this).jqmData('next'); // jqmData reads data-* with the jQM namespace
if (next) $(':mobile-pagecontainer').pagecontainer('change', next, { transition: 'slide' });
});
// Tune thresholds globally (before use)
$.event.special.swipe.horizontalDistanceThreshold = 60; // require a longer swipe
Responsive behavior. jQuery Mobile was designed mostly for phones, but 1.3 added tools for larger screens:
- Responsive grids (
ui-grid-aand so on) with CSS you can make collapse into one column under a breakpoint. - Reflow and column-toggle tables (previous topic).
- Panels with
data-display="reveal",pushoroverlay, plus CSS to keep a panel permanently open on wide screens. - Fixed toolbars that stay put while content scrolls.
- Orientation classes: the
<html>element receivesportraitorlandscapeclasses.
/* Show the panel permanently on wide screens, as a sidebar */
@media (min-width: 55em) {
#menu.ui-panel-closed { visibility: visible; transform: none; width: 17em; }
.ui-panel-page-content-open, .ui-page .ui-content { margin-left: 17em; }
}
Lessons for today. Most of what these events solved is now built into the platform:
| jQuery Mobile | Modern platform equivalent |
|---|---|
tap, vclick (no delay) | click with width=device-width viewport, touch-action: manipulation |
vmouse* events | Pointer events (pointerdown, pointermove, pointerup) |
swipe, taphold | Pointer events with small gesture code, or libraries such as Hammer.js successors, @use-gesture |
orientationchange | screen.orientation.addEventListener('change', ...) or (orientation: landscape) media query |
throttledresize | ResizeObserver |
scrollstart/scrollstop | scroll with passive listeners and scrollend |
| Responsive grids | CSS grid, flexbox and container queries |
Key Takeaways
- jQuery Mobile normalized touch input with events such as
tap,taphold,swipe,swipeleft,swiperightand virtual mouse events (vclick,vmousedown). - Thresholds for gestures can be tuned through
$.event.specialsettings. - Responsive features include grids, reflow tables, panels, fixed toolbars and orientation classes.
- Modern browsers make these events unnecessary: use pointer events,
touch-action,ResizeObserver,screen.orientationand CSS layout.
Practice
- (Beginner) Add swipe-left and swipe-right handlers to move between the Library and Reading list pages with a slide transition.
- (Intermediate) Rewrite the
tapholdcontext menu and the swipe-to-reveal actions without jQuery Mobile, using pointer events and a timer, and compare the amount of code and the behavior on a touch device.- (Interview) "Why did libraries like jQuery Mobile and FastClick introduce a
tapevent, and why is it no longer needed?" Hint: recall double-tap-to-zoom, the 300 ms delay, and what browsers changed for pages with a mobile viewport.
Legacy Status and Modern Alternatives
Why this topic. Choosing a framework includes asking whether it will be maintained. jQuery Mobile is a cautionary example: hugely popular for a few years, then overtaken by platform improvements and newer frameworks. Developers still meet it in old intranets, kiosk apps and Cordova projects, and they need to know the risks and the migration options.
Timeline.
| Year | Event |
|---|---|
| 2010 | Project announced; alpha releases |
| 2011 | 1.0 released (November) |
| 2013 | 1.3: responsive tables, panels |
| 2014 | 1.4.x: performance work, CSS-class buttons, flat design, SVG icons; 1.4.5 is the last stable release |
| 2017 | 1.5.0-alpha.1, the last release of any kind |
| 2021 | Officially deprecated by the jQuery team (October); repository archived |
Why it declined.
- Performance: heavy DOM enhancement and the AJAX page system were slow on the low-end devices it targeted.
- "Looks like jQuery Mobile": apps had a recognizable generic style rather than a native look.
- Platform progress: flexbox, grid, media queries, pointer events, the History API and the disappearance of the 300 ms delay removed most reasons to use it.
- Architecture shift: component frameworks (React, Angular, Vue) and mobile-specific UI kits (Ionic) offered better ways to structure large apps.
- Dependency age: it requires jQuery 1.x or 2.x; the supported versions of jQuery (3.x and 4.x) are not officially compatible, and old jQuery versions have known security vulnerabilities.
Risks of keeping it. No security patches, incompatibility with current jQuery, breaking changes in browsers that no one will fix, and fewer developers who know it. For an internal tool that works and is rarely changed, keeping it temporarily may be acceptable; for anything public or actively developed, plan a migration.
Modern alternatives.
| Need | Alternative |
|---|---|
| Mobile-friendly responsive website | Plain modern CSS (grid, flexbox, container queries), or a CSS framework such as Bootstrap or Tailwind CSS |
| App-like mobile UI with native look | Ionic Framework (15.5) with Angular, React, Vue or plain JavaScript |
| Lightweight mobile UI components | Framework7, Onsen UI, Konsta UI |
| Installable, offline-capable web app | PWA techniques (15.2) |
| Store app from web code | Capacitor (15.4) with any front-end framework |
| Page transitions | View Transitions API (document.startViewTransition), framework routers |
Migrating. A big-bang rewrite is risky. An incremental approach:
- Inventory the pages, widgets, page events and
data-*options in use. - Separate logic from markup: move business logic and API calls out of page event handlers into plain modules that do not depend on jQuery Mobile.
- Pick the target (for example Ionic with Vue, or plain HTML with modern CSS and a router) and build a mapping:
listviewtoion-list,navbartoion-tab-bar,popuptoion-popoveror<dialog>. - Migrate page by page, if the app can be split by URL, with
data-ajax="false"links between old and new pages. - Remove jQuery Mobile, then decide whether jQuery itself is still needed (Chapter 8, 8.3).
The native <dialog> element and the View Transitions API replace two of jQuery Mobile's signature features with a few lines:
// Page-like transition between two views with the View Transitions API
function showView(id) {
const update = () => {
document.querySelectorAll('.view').forEach((v) => (v.hidden = v.id !== id));
};
if (!document.startViewTransition) return update(); // fallback: no animation
document.startViewTransition(update); // browser animates old -> new snapshot
}
<!-- Native modal dialog instead of a jQuery Mobile dialog page -->
<dialog id="confirmDelete">
<form method="dialog">
<p>Delete this book?</p>
<button value="cancel">Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
<script>
const dlg = document.querySelector('#confirmDelete');
dlg.addEventListener('close', () => { if (dlg.returnValue === 'delete') deleteBook(); });
// dlg.showModal() opens it with focus trapping and Escape-to-close built in
</script>
Key Takeaways
- jQuery Mobile's last stable release is 1.4.5 (2014); the project was officially deprecated in 2021.
- It declined because of performance problems, a generic look, platform improvements that made it unnecessary, and the rise of component frameworks.
- Keeping it means no security fixes and dependence on old jQuery versions; plan migrations for active or public apps.
- Alternatives include modern CSS, Ionic, Framework7, PWA techniques and Capacitor;
<dialog>and View Transitions replace dialogs and page transitions. - Migrate incrementally: inventory, separate logic, map widgets, migrate page by page, then remove the library.
Practice
- (Beginner) List five jQuery Mobile features used in this subchapter and name the modern platform feature or library that replaces each.
- (Intermediate) Migrate the jQuery Mobile bookshelf list page to plain HTML with modern CSS, a native
<dialog>for deletion and the View Transitions API between list and details. Keep the same API calls.- (Interview) "You inherit a working jQuery Mobile app used by 200 employees. Would you rewrite it?" Hint: weigh security exposure (internal or public), change frequency, the cost and risk of a rewrite, and incremental options; there is no single right answer, but you must justify yours.
<a id="154-apache-cordova"></a>
15.4 Apache Cordova
Apache Cordova packages a web application as a native iOS or Android app and gives its JavaScript access to device features through plugins. It defined the hybrid app model that later tools followed. This subchapter covers its architecture, command-line workflow, configuration, plugin system, common device APIs, permissions, building and debugging, store deployment, and Capacitor, its modern successor.
Cordova Architecture and the WebView Bridge
Why Cordova. In 2008, building an iPhone app required Objective-C, and an Android app required Java; web developers could not use their skills for store apps. At an iPhoneDevCamp hackathon, developers from the Canadian company Nitobi created PhoneGap: a native app containing a full-screen WebView plus a JavaScript library that exposed phone features. Adobe acquired Nitobi in 2011 and donated the core code to the Apache Software Foundation, where it became Apache Cordova (PhoneGap became Adobe's distribution of Cordova, with a cloud build service). Adobe ended PhoneGap and PhoneGap Build in 2020; Cordova continues as an Apache project.
The architecture. A Cordova app has three layers:
+------------------------------------------------------------------+
| Native app (APK/AAB or IPA) |
| |
| +---------------------------+ +--------------------------+ |
| | WebView | | Native plugins | |
| | www/index.html | | Camera (Java/Kotlin, | |
| | www/js/app.js | | Objective-C/Swift) | |
| | cordova.js <------------+-----+-> Geolocation | |
| | plugin JS interfaces | | File, Device, ... | |
| | navigator.camera. | | | |
| | getPicture(...) | | | |
| +---------------------------+ +--------------------------+ |
| ^ bridge (cordova.exec) ^ |
| +------------------------------------------+ |
| |
| Native shell: Activity (Android) / ViewController (iOS), |
| loads config.xml, starts the WebView, registers plugins |
+------------------------------------------------------------------+
- Web application (
www/): your HTML, CSS and JavaScript, any framework or none. - WebView: Android System WebView or iOS
WKWebView(15.1), rendering the app.cordova.js, injected per platform, implements the bridge on the JavaScript side. - Plugins: each plugin has a JavaScript interface and native implementations for each platform. All device access goes through plugins, including core features like the camera.
The bridge: cordova.exec. Every plugin call ends in one function:
cordova.exec(successCallback, errorCallback, 'ServiceName', 'action', [arg1, arg2]);
Cordova assigns a callback ID, serializes the call, and passes it to the native side (on Android through a @JavascriptInterface object, on iOS through a WKScriptMessageHandler). The native plugin performs the action, possibly on a background thread, and sends back a PluginResult with the callback ID. Cordova finds the stored callbacks and calls the right one. Plugins can also keep a callback alive to send several results (for example a stream of location updates).
deviceready: the most important event. The bridge and plugins load asynchronously after the page. Calling a plugin before they are ready fails. Cordova fires deviceready on document when everything is available; it is the entry point of every Cordova app.
// www/js/index.js
document.addEventListener('deviceready', onDeviceReady, false);
function onDeviceReady() {
// Safe to use plugins from here on
console.log(`Running cordova-${cordova.platformId}@${cordova.version}`);
console.log(`Device: ${device.manufacturer} ${device.model}, OS ${device.version}`); // cordova-plugin-device
// App lifecycle events, available after deviceready
document.addEventListener('pause', () => saveState(), false); // app goes to background
document.addEventListener('resume', () => refreshData(), false); // app returns to foreground
document.addEventListener('backbutton', onBackButton, false); // Android hardware/gesture back
}
function onBackButton(event) {
event.preventDefault(); // registering a handler overrides the default (exit)
if (history.length > 1) history.back();
else navigator.app.exitApp(); // Android only
}
If deviceready has already fired when a listener is added, Cordova calls the listener immediately, so late registration is safe.
What the WebView loads. Assets are copied into the app package. Modern platform versions serve them from a local HTTPS-like origin instead of file://: https://localhost on Android (since cordova-android 10, through WebViewAssetLoader) and, when configured with the scheme and hostname preferences, a custom scheme such as app://localhost on iOS. This makes fetch, cookies and CORS behave like a normal website (15.1).
Key Takeaways
- Cordova (originally PhoneGap, 2008) wraps a web app in a native shell with a WebView and exposes device features through plugins.
- The layers are the web app (
www/), the WebView withcordova.js, and plugins with JavaScript and native parts. - Every plugin call goes through
cordova.exec(success, error, service, action, args), an asynchronous bridge with callback IDs. - Wait for
devicereadybefore using any plugin; handlepause,resumeandbackbuttonfor lifecycle and navigation. - Modern Cordova serves assets from a local
https-like origin rather thanfile://.
Practice
- (Beginner) Draw the path of a
navigator.camera.getPicture()call from JavaScript to the native camera and back, labelingcordova.js, the bridge, the plugin and the callback.- (Intermediate) Write a small
whenReady()function that returns a Promise resolved ondevicereadyin Cordova and immediately in a normal browser, so the same app code can run in both.- (Interview) "Why must a Cordova app wait for
deviceready, and what happens if it uses a plugin earlier?" Hint: think about the order in which the WebView loads the page,cordova.js, plugin modules and the native plugin registry.
CLI, Project Structure and Platforms
Why a CLI. A Cordova app is really several projects in one: the shared web code, an Android Gradle project and an iOS Xcode project, plus plugins that must be installed into each. Keeping these consistent by hand would be painful. The Cordova CLI manages it: it creates projects, adds platforms, installs plugins and copies the web code into each native project before building.
Prerequisites. Node.js and npm, plus each platform's native toolchain:
| Platform | Requirements |
|---|---|
| Android | JDK (version required by cordova-android), Android Studio or the Android SDK command-line tools, Gradle, environment variables ANDROID_HOME and JAVA_HOME |
| iOS | A Mac with Xcode, Xcode command-line tools, CocoaPods (for some plugins), an Apple Developer account for device builds and distribution |
cordova requirements checks the toolchain for each added platform.
Typical workflow.
npm install -g cordova # install the CLI
cordova create bookshelf com.example.bookshelf Bookshelf
# folder reverse-domain app ID display name
cd bookshelf
cordova platform add android # adds platforms/android (Gradle project)
cordova platform add ios # macOS only: adds platforms/ios (Xcode project)
cordova platform ls # installed and available platforms
cordova plugin add cordova-plugin-camera # installs the plugin into every platform
cordova plugin ls
cordova requirements # check JDK, SDK, Gradle, Xcode
cordova prepare # copy www/ and config into platforms/
cordova build android # prepare + compile (debug build by default)
cordova emulate android # build and launch in an emulator
cordova run android --device # build and install on a connected device
cordova run ios --target="iPhone-16" # a specific simulator
The same commands work through npx cordova with a project-local CLI, which keeps CLI versions consistent across a team.
Project structure.
bookshelf/
+-- config.xml # app metadata and configuration (next topic)
+-- package.json # npm dependencies, including platforms and plugins;
| # the "cordova" section records which ones are installed
+-- www/ # YOUR web app: the only source code you edit for the UI
| +-- index.html
| +-- css/
| +-- js/index.js
+-- res/ # icons and splash screens (convention, referenced from config.xml)
+-- platforms/ # GENERATED native projects: android/, ios/
+-- plugins/ # GENERATED: downloaded plugin sources
+-- hooks/ # optional scripts run at CLI steps (legacy; prefer package.json hooks)
+-- node_modules/
Generated vs source. platforms/ and plugins/ are build artifacts, regenerated from package.json and config.xml with cordova prepare (which restores missing platforms and plugins). Do not commit them, and do not edit files inside them: cordova prepare overwrites platforms/*/www and many native files. Native changes belong in plugins, config.xml (<edit-config>, <config-file>, <resource-file>) or hooks.
{
"name": "com.example.bookshelf",
"version": "1.0.0",
"devDependencies": {
"cordova-android": "^14.0.0",
"cordova-ios": "^7.1.0",
"cordova-plugin-camera": "^8.0.0"
},
"cordova": {
"platforms": ["android", "ios"],
"plugins": {
"cordova-plugin-camera": {}
}
}
}
(Version numbers above are illustrative; check each package's current release and its supported platform versions.)
Using a modern front-end build. Cordova only knows www/. To use a framework with a bundler (Vite with React or Vue), build into www/ and run Cordova afterwards:
{
"scripts": {
"build:web": "vite build --outDir www --emptyOutDir",
"android": "npm run build:web && cordova run android",
"ios": "npm run build:web && cordova run ios"
}
}
Use relative asset paths (base: './' in Vite) and include cordova.js with a <script src="cordova.js"> tag in index.html: the file does not exist in www/ but is added to each platform during prepare.
Platforms and their versions. Each platform is an npm package (cordova-android, cordova-ios, plus cordova-electron and cordova-browser) with its own release cycle. A platform version determines the minimum and target OS versions, the WebView behavior and plugin compatibility. App stores require recent target SDK levels (Google Play requires targeting an Android API level within about a year of the latest release), so platform upgrades are regular maintenance work: cordova platform rm android && cordova platform add android@latest.
Key Takeaways
- The Cordova CLI creates projects, adds platforms, installs plugins, prepares and builds; native toolchains (Android SDK, Xcode) are still required.
www/holds your web app;config.xmlandpackage.jsondescribe the app, its platforms and plugins.platforms/andplugins/are generated and should not be edited or committed;cordova preparerestores them.- With a bundler, build into
www/before running Cordova, use relative paths and referencecordova.js. - Platform packages determine OS targets and must be upgraded regularly to meet store requirements.
Practice
- (Beginner) Create a Cordova project for the bookshelf client, add the Android platform, run
cordova requirements, and launch the default app in an emulator.- (Intermediate) Replace the default
www/with a Vite build of the React bookshelf front end from Chapter 14. Add npm scripts that build and run on Android, and confirm that a fresh clone withoutplatforms/andplugins/builds aftercordova prepare.- (Interview) "A teammate fixed a bug by editing a Java file under
platforms/android. Why is that a problem and what should they do instead?" Hint: think about whatcordova prepareand fresh clones do, and the proper places for native changes (plugins,config.xml, hooks).
Application Configuration
Why config.xml. One file describes the app independently of platforms: identity, version, start page, which URLs the app may load, platform-specific preferences, icons and splash screens. During cordova prepare, Cordova translates it into each platform's native configuration (AndroidManifest.xml, res/xml/config.xml, Info.plist, Xcode settings). Its format comes from the W3C Widgets specification, hence the root element <widget>.
A complete example.
<?xml version="1.0" encoding="utf-8"?>
<widget id="com.example.bookshelf"
version="1.2.0"
android-versionCode="10200"
ios-CFBundleVersion="1.2.0.1"
xmlns="http://www.w3.org/ns/widgets"
xmlns:android="http://schemas.android.com/apk/res/android">
<!-- id: app identifier (Android package name / iOS bundle ID); never change after publishing
version: user-visible version; versionCode/CFBundleVersion: build numbers the stores compare -->
<name>Bookshelf</name>
<description>Track the books you own and read.</description>
<author email="dev@bookshelf.example" href="https://bookshelf.example">Bookshelf Team</author>
<content src="index.html" /> <!-- start page inside www/ -->
<!-- Network access control -->
<access origin="https://api.bookshelf.example" /> <!-- network requests (XHR, fetch, images) -->
<allow-navigation href="https://bookshelf.example/help/*" /> <!-- URLs the WebView itself may navigate to -->
<allow-intent href="https://*/*" /> <!-- URLs the OS may open in other apps -->
<allow-intent href="mailto:*" />
<allow-intent href="tel:*" />
<!-- Global preferences -->
<preference name="Orientation" value="portrait" />
<preference name="BackgroundColor" value="0xff2e5d4b" />
<preference name="DisallowOverscroll" value="true" /> <!-- no rubber-band bounce on iOS -->
<platform name="android">
<preference name="android-minSdkVersion" value="24" />
<preference name="android-targetSdkVersion" value="35" />
<preference name="AndroidWindowSplashScreenAnimatedIcon" value="res/android/splash-icon.png" />
<preference name="AndroidWindowSplashScreenBackground" value="#2e5d4b" />
<icon density="mdpi" src="res/android/icon-48.png" />
<icon density="xxxhdpi" src="res/android/icon-192.png" />
<!-- Adaptive icon: separate foreground and background layers -->
<icon foreground="res/android/fg.png" background="res/android/bg.png" density="xxxhdpi" />
<!-- Add an entry to AndroidManifest.xml -->
<config-file target="AndroidManifest.xml" parent="/manifest">
<uses-permission android:name="android.permission.VIBRATE" />
</config-file>
</platform>
<platform name="ios">
<preference name="deployment-target" value="14.0" />
<preference name="scheme" value="app" /> <!-- serve assets from app://localhost -->
<preference name="hostname" value="localhost" />
<icon src="res/ios/icon-1024.png" width="1024" height="1024" />
<!-- Merge a usage description into Info.plist (required for camera access) -->
<edit-config target="NSCameraUsageDescription" file="*-Info.plist" mode="merge">
<string>Bookshelf uses the camera to scan book barcodes.</string>
</edit-config>
</platform>
</widget>
Network access: three different lists. This trips up many developers:
| Element | Controls | Example consequence if missing |
|---|---|---|
<access origin> | Which servers the app may make network requests to | fetch('https://api.bookshelf.example/...') blocked (on Android; iOS relies more on CSP and ATS) |
<allow-navigation> | Which URLs the WebView itself may load as the top-level page or iframes | Navigating to the help site does nothing |
<allow-intent> | Which URLs the app may ask the OS to open (browser, mail, phone, maps) | tel: links do nothing |
Keep each list as narrow as possible: <access origin="*"> and <allow-navigation href="*"> let any injected script load arbitrary content into a WebView with plugin access.
Content Security Policy. The CSP in index.html complements config.xml and is the main defense against injected scripts:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
connect-src 'self' https://api.bookshelf.example;
img-src 'self' data: https://covers.bookshelf.example;
style-src 'self' 'unsafe-inline';
script-src 'self'">
Preferences. Preferences configure the shell. Common ones include Orientation, Fullscreen, BackgroundColor, DisallowOverscroll, StatusBarOverlaysWebView (status bar plugin), AndroidPersistentFileLocation and iosPersistentFileLocation (file plugin), and the Android splash screen preferences. The exact list depends on the platform and plugin versions; check their documentation.
Icons and splash screens. Each platform needs many icon sizes and densities. Declare them with <icon> and <splash> (iOS uses storyboard launch screens) elements, or generate them from one source image with tools such as cordova-res. Since cordova-android 11, Android uses the system SplashScreen API configured through preferences.
Key Takeaways
config.xmldescribes the app (ID, version, name, start page, preferences, icons) and is translated into native configuration duringprepare.- The
idis the store identity and must never change after publishing; increaseversionand the build numbers for every release. <access>,<allow-navigation>and<allow-intent>control network requests, WebView navigation and external apps respectively; keep them narrow.- Platform-specific settings go inside
<platform>;<config-file>and<edit-config>modifyAndroidManifest.xmlandInfo.plistwithout editing generated files. - A strict Content Security Policy in
index.htmlcomplements the allow lists.
Practice
- (Beginner) Configure the bookshelf app's
config.xmlwith name, description, version, portrait orientation and an<access>entry for the API origin only. Verify that afetchto another origin is blocked on Android.- (Intermediate) Add a
tel:support link, an allowed help-site navigation, a camera usage description for iOS and a matching CSP. Explain for each entry which attack or failure it prevents.- (Interview) "What is the difference between
<access>,<allow-navigation>and<allow-intent>?" Hint: separate the three cases: data requested by scripts, pages loaded inside the WebView, and URLs handed to the operating system.
The Plugin System and Native APIs
Why plugins. The WebView only offers standard web APIs. Everything else, from the barcode scanner to in-app purchases, is provided by plugins. A plugin is a package with a JavaScript interface and native code for each platform, registered in the native shell so cordova.exec can find it. The core team maintains plugins for common features, and thousands of community plugins exist on npm (search for the ecosystem:cordova keyword).
Core and popular plugins.
| Plugin | Provides |
|---|---|
cordova-plugin-camera | navigator.camera.getPicture(): take photos or choose from the library |
cordova-plugin-geolocation | navigator.geolocation backed by native location services (mainly for permission handling) |
cordova-plugin-file | Read and write files in app directories (cordova.file.dataDirectory, etc.) |
cordova-plugin-device | device.platform, device.model, device.version, device.uuid |
cordova-plugin-network-information | navigator.connection.type (wifi, 4g, none...) |
cordova-plugin-inappbrowser | cordova.InAppBrowser.open(url, target): show external pages safely, outside the main WebView |
cordova-plugin-statusbar | Status bar color and overlay |
cordova-plugin-dialogs, cordova-plugin-vibration, cordova-plugin-media, cordova-plugin-media-capture | Native dialogs, vibration, audio playback and recording, audio/video capture |
Some former core plugins were deprecated because the web platform now covers them: cordova-plugin-device-motion and cordova-plugin-device-orientation (use DeviceMotionEvent), cordova-plugin-battery-status, cordova-plugin-whitelist (built into platforms since cordova-android 10), and cordova-plugin-splashscreen on Android (built into cordova-android 11).
Anatomy of a plugin. A minimal custom plugin that returns a greeting from native code:
cordova-plugin-greeter/
+-- package.json
+-- plugin.xml # manifest: JS modules, native sources, config changes
+-- www/greeter.js # JavaScript interface exposed to the app
+-- src/android/Greeter.java # Android implementation
+-- src/ios/CDVGreeter.swift # iOS implementation
<!-- plugin.xml -->
<plugin xmlns="http://apache.org/cordova/ns/plugins/1.0"
xmlns:android="http://schemas.android.com/apk/res/android"
id="cordova-plugin-greeter" version="1.0.0">
<name>Greeter</name>
<!-- Load www/greeter.js and expose its exports as window.Greeter -->
<js-module src="www/greeter.js" name="Greeter">
<clobbers target="Greeter" />
</js-module>
<platform name="android">
<!-- Register the service name "Greeter" -> Java class -->
<config-file target="res/xml/config.xml" parent="/*">
<feature name="Greeter">
<param name="android-package" value="com.example.greeter.Greeter" />
</feature>
</config-file>
<source-file src="src/android/Greeter.java" target-dir="src/com/example/greeter" />
</platform>
<platform name="ios">
<config-file target="config.xml" parent="/*">
<feature name="Greeter">
<param name="ios-package" value="CDVGreeter" />
</feature>
</config-file>
<source-file src="src/ios/CDVGreeter.swift" />
</platform>
</plugin>
// www/greeter.js: the JavaScript interface
const exec = require('cordova/exec');
exports.greet = function (name, success, error) {
exec(success, error, 'Greeter', 'greet', [name]); // service, action, arguments
};
// Optional Promise wrapper for modern code
exports.greetAsync = (name) =>
new Promise((resolve, reject) => exports.greet(name, resolve, reject));
// src/android/Greeter.java
package com.example.greeter;
import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaPlugin;
import org.json.JSONArray;
import org.json.JSONException;
public class Greeter extends CordovaPlugin {
@Override
public boolean execute(String action, JSONArray args, CallbackContext callbackContext)
throws JSONException {
if ("greet".equals(action)) {
String name = args.getString(0);
if (name.isEmpty()) {
callbackContext.error("Name must not be empty");
return true;
}
// Long work must not block the WebView thread: use cordova.getThreadPool()
cordova.getThreadPool().execute(() ->
callbackContext.success("Hello, " + name + " from Android"));
return true; // action recognized
}
return false; // unknown action -> "invalid action" error in JS
}
}
// src/ios/CDVGreeter.swift
@objc(CDVGreeter) class CDVGreeter: CDVPlugin {
@objc(greet:)
func greet(_ command: CDVInvokedUrlCommand) {
let name = command.arguments.first as? String ?? ""
let result: CDVPluginResult
if name.isEmpty {
result = CDVPluginResult(status: CDVCommandStatus_ERROR, messageAs: "Name must not be empty")
} else {
result = CDVPluginResult(status: CDVCommandStatus_OK, messageAs: "Hello, \(name) from iOS")
}
commandDelegate.send(result, callbackId: command.callbackId) // route back to the JS callback
}
}
// In the app, after deviceready
const message = await Greeter.greetAsync('Ada');
console.log(message); // "Hello, Ada from Android"
Swift plugin code also needs the Cordova headers through a bridging header; older projects used the cordova-plugin-add-swift-support plugin to set this up, and plugins written in Objective-C avoid the question entirely.
Install a local plugin with cordova plugin add ../cordova-plugin-greeter. For development of the plugin itself, plugman and --link help avoid reinstalling after each change.
Choosing third-party plugins. Check the last release date, open issues, support for the current cordova-android and cordova-ios versions, and whether it targets the API levels stores require. An unmaintained plugin can block a platform upgrade and thus a store release. Wrapping plugin calls in your own small service module makes replacing a plugin easier.
Key Takeaways
- Plugins provide all native functionality; each has a JavaScript interface, native code per platform and a
plugin.xmlmanifest. plugin.xmldeclares JS modules (<js-module>,<clobbers>), native sources, service registrations (<feature>) and manifest changes.- On Android, a plugin extends
CordovaPluginand implementsexecute; on iOS, it subclassesCDVPluginand sends aCDVPluginResultwith the callback ID. - Several former core plugins are deprecated because web APIs or the platforms now cover them.
- Evaluate plugin maintenance carefully and wrap plugin calls behind your own interfaces.
Practice
- (Beginner) Install
cordova-plugin-deviceandcordova-plugin-network-information, and show the device model, OS version and connection type on the bookshelf app's About page.- (Intermediate) Write the Greeter plugin for Android, install it locally, and add a Promise wrapper. Add an action
getFreeDiskSpacethat returns the number of free bytes usingStatFs.- (Interview) "How does a Cordova plugin return results asynchronously, and how would it send multiple results over time?" Hint: look at callback IDs,
CallbackContext/commandDelegate, and thekeepCallbackflag ofPluginResult.
Camera, Storage, Geolocation and Sensors
Why these four. They are the device features most hybrid apps need: photos (profile pictures, document scans, book covers), local storage (offline data, settings, files), location (nearby places, check-ins) and motion (shake gestures, orientation). Each has both a web API and a Cordova plugin, and knowing which one to use on which platform is part of the job.
Camera.
// cordova-plugin-camera: photograph a book cover
function takeCoverPhoto() {
return new Promise((resolve, reject) => {
navigator.camera.getPicture(resolve, reject, {
quality: 70, // JPEG quality 0-100
destinationType: Camera.DestinationType.FILE_URI, // a file path; DATA_URL returns base64 (memory heavy)
sourceType: Camera.PictureSourceType.CAMERA, // or PHOTOLIBRARY
encodingType: Camera.EncodingType.JPEG,
targetWidth: 800, // scale down: full-size photos are huge
targetHeight: 1200,
correctOrientation: true, // rotate according to EXIF
saveToPhotoAlbum: false,
});
});
}
async function onAddCover(bookId) {
try {
const fileUri = await takeCoverPhoto();
// The page's origin is https://localhost or app://localhost, so a file:// URI usually
// cannot be shown directly: read the file (a File is a Blob) and use an object URL
const file = await fileUriToFile(fileUri);
document.querySelector('#cover').src = URL.createObjectURL(file);
await uploadCover(bookId, file); // e.g. FormData + fetch
} catch (err) {
if (err !== 'No Image Selected') showToast(`Camera error: ${err}`); // user cancel is not an error
}
}
// cordova-plugin-file: native path -> File object
function fileUriToFile(uri) {
return new Promise((resolve, reject) => {
window.resolveLocalFileSystemURL(uri, (entry) => entry.file(resolve, reject), reject);
});
}
Avoid DATA_URL for large images: a base64 string of a 12-megapixel photo can crash the app. Use FILE_URI, scale the image with targetWidth/targetHeight, and read the file only when needed.
In many cases a plain <input type="file" accept="image/*" capture="environment"> is enough even inside Cordova and needs no plugin.
Storage options.
| Option | Kind | Size | Persistence | Use for |
|---|---|---|---|---|
localStorage | Synchronous key-value strings | ~5 MB | Can be cleared by the OS under storage pressure (especially iOS) | Small settings only |
| IndexedDB | Asynchronous structured data | Large (quota) | Same WebView storage caveats | Offline data in the WebView |
cordova-plugin-file | Files in app directories | Device storage | Persistent app storage | Photos, downloads, exports |
SQLite plugins (cordova-sqlite-storage) | SQL database in native storage | Large | Persistent | Large structured data, complex queries |
| Secure storage plugins (Keychain, Android Keystore) | Encrypted key-value | Small | Persistent | Tokens, credentials |
WebView storage lives in the app's private data and is generally kept, but iOS may purge WebView data in low-storage situations, so important data belongs in native storage (files, SQLite) or on the server. Never store tokens in localStorage; use secure storage.
// cordova-plugin-file: save the reading list as a JSON file in the app's private data directory
function writeJson(fileName, data) {
return new Promise((resolve, reject) => {
window.resolveLocalFileSystemURL(cordova.file.dataDirectory, (dir) => {
dir.getFile(fileName, { create: true }, (fileEntry) => {
fileEntry.createWriter((writer) => {
writer.onwriteend = resolve;
writer.onerror = reject;
writer.write(new Blob([JSON.stringify(data)], { type: 'application/json' }));
}, reject);
}, reject);
}, reject);
});
}
function readJson(fileName) {
return new Promise((resolve, reject) => {
window.resolveLocalFileSystemURL(cordova.file.dataDirectory + fileName, (fileEntry) => {
fileEntry.file((file) => {
const reader = new FileReader();
reader.onloadend = () => resolve(JSON.parse(reader.result));
reader.readAsText(file);
}, reject);
}, reject);
});
}
The callback-heavy API shows the plugin's age; wrap it in Promises once and use async/await elsewhere.
Geolocation. cordova-plugin-geolocation implements the same W3C API you already know (6.4). On iOS it mostly adds the required usage description; on Android it handles the runtime permission.
// Track position while the user is on the "nearby bookshops" screen
let watchId = null;
function startTracking() {
watchId = navigator.geolocation.watchPosition(
({ coords }) => updateMap(coords.latitude, coords.longitude, coords.accuracy),
(err) => showToast(`Location error (${err.code}): ${err.message}`), // 1 denied, 2 unavailable, 3 timeout
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 15_000 } // low accuracy saves battery
);
}
function stopTracking() {
if (watchId !== null) navigator.geolocation.clearWatch(watchId);
watchId = null;
}
document.addEventListener('pause', stopTracking); // stop GPS when the app goes to background
document.addEventListener('resume', startTracking);
Background location (tracking while the app is not visible) is not possible from the WebView alone; it needs specialized native plugins and extra store review.
Sensors. Device motion and orientation use the standard DeviceMotionEvent and DeviceOrientationEvent (15.1) inside the WebView, with the same iOS permission call. Specialized sensors (barcode scanning, NFC, Bluetooth LE, biometrics) need dedicated community plugins, for example a barcode scanner plugin based on Google ML Kit or Apple's Vision framework.
Key Takeaways
- Use
cordova-plugin-camerawithFILE_URIand scaled dimensions; avoid base64DATA_URLfor large photos;<input capture>may be enough. - Choose storage by need:
localStoragefor small settings, IndexedDB for WebView data, files or SQLite for large or important data, secure storage for secrets. cordova-plugin-filereads and writes files in app directories such ascordova.file.dataDirectory; wrap its callbacks in Promises.- Geolocation uses the W3C API; stop watching on
pauseto save battery. - Motion sensors use web events; specialized hardware needs dedicated plugins.
Practice
- (Beginner) Add a "Take cover photo" button to the bookshelf book details screen that uses the camera plugin and displays the photo.
- (Intermediate) Store the offline reading list both in IndexedDB and as a JSON backup file in
cordova.file.dataDirectory. On startup, restore from the file if IndexedDB is empty.- (Interview) "Where would you store an authentication token in a hybrid app, and why not in
localStorage?" Hint: consider XSS, OS-level encryption, what Keychain and Keystore provide, and how WebView storage can be purged.
Permissions and Platform Differences
Why permissions are tricky in hybrid apps. Native platforms protect sensitive features with permissions, and their rules differ: what must be declared in which file, when the user is asked, what happens after a denial, and how the store reviews your justification. A hybrid app must satisfy two systems at once (the OS and, for web APIs, the WebView) on two platforms.
Android permissions.
- Declared in
AndroidManifest.xml(<uses-permission>). Plugins add their own entries throughplugin.xml; you add others through<config-file>inconfig.xml. - Normal permissions (internet, vibration) are granted at install.
- Dangerous permissions (camera, location, microphone, contacts) must also be requested at runtime since Android 6 (API 23). Plugins usually request them automatically when you call them.
- Newer Android versions split permissions further: precise vs approximate location, separate background location, photo picker instead of broad storage access,
POST_NOTIFICATIONSfor notifications (Android 13).
Plugins request permissions through Cordova's API, and cordova-plugin-android-permissions exposes it to JavaScript when you need explicit control:
// Explicitly check and request the camera permission on Android before showing a scanner UI
function ensureCameraPermission() {
const perms = cordova.plugins.permissions;
return new Promise((resolve) => {
perms.checkPermission(perms.CAMERA, (status) => {
if (status.hasPermission) return resolve(true);
perms.requestPermission(perms.CAMERA, (s) => resolve(s.hasPermission), () => resolve(false));
});
});
}
iOS permissions.
- Every protected resource needs a usage description string in
Info.plist(for exampleNSCameraUsageDescription,NSLocationWhenInUseUsageDescription,NSPhotoLibraryUsageDescription,NSMicrophoneUsageDescription). Without it, the app crashes when it accesses the resource, and App Store review rejects it. - The system shows the prompt the first time the feature is used, with your description. After a denial, the app cannot ask again; the user must change it in Settings.
<!-- config.xml: iOS usage descriptions -->
<platform name="ios">
<edit-config target="NSLocationWhenInUseUsageDescription" file="*-Info.plist" mode="merge">
<string>Bookshelf shows bookshops near you.</string>
</edit-config>
<edit-config target="NSPhotoLibraryUsageDescription" file="*-Info.plist" mode="merge">
<string>Bookshelf lets you choose a photo as a book cover.</string>
</edit-config>
</platform>
Many plugins accept these strings as install-time variables: cordova plugin add cordova-plugin-geolocation --variable GEOLOCATION_USAGE_DESCRIPTION="...".
Platform differences beyond permissions.
| Area | Android | iOS |
|---|---|---|
| WebView engine | Chromium, auto-updated | WebKit, tied to iOS version |
| Back navigation | System back button/gesture fires backbutton; app must handle it | No system back button; the app must provide back UI |
| Status bar and notch | Status bar color via plugin; edge-to-edge since Android 15 targets | Safe areas; viewport-fit=cover and env(safe-area-inset-*) |
| Keyboard | Usually resizes the WebView | Overlays content; may scroll the page; plugins help control it |
| File system paths | cordova.file.externalDataDirectory etc. | Different directory set; sandboxed paths |
| Push notifications | Firebase Cloud Messaging | Apple Push Notification service (APNs) |
| Store review | Faster, mostly automated | Stricter human review; rejects apps that are "just a website" |
| UI conventions | Material Design | Human Interface Guidelines |
Detect the platform at runtime when behavior must differ:
const platform = cordova.platformId; // 'android' | 'ios' | 'browser' | 'electron'
document.documentElement.classList.add(`platform-${platform}`); // style per platform in CSS
if (platform === 'ios') {
document.querySelector('#backButton').hidden = false; // iOS: show an in-app back button
}
Good practice. Request permissions just in time with an explanation; degrade gracefully when denied (for example manual entry instead of barcode scanning); test on both platforms and on old and new OS versions; and document why each permission is needed, because store reviewers ask.
Key Takeaways
- Android requires permissions in
AndroidManifest.xmland runtime requests for dangerous permissions; newer versions split permissions further. - iOS requires a usage description in
Info.plistfor every protected resource; missing ones cause crashes and rejection. - Add manifest entries and descriptions through
plugin.xml,config.xml(<config-file>,<edit-config>) or plugin variables, never by editing generated files. - Platforms differ in WebView engine, back navigation, keyboard, safe areas, push services and store review.
- Use
cordova.platformIdfor platform-specific behavior and request permissions in context with graceful fallbacks.
Practice
- (Beginner) Add the iOS usage descriptions needed for the camera, photo library and location features of the bookshelf app, and the corresponding Android permissions if any plugin does not add them.
- (Intermediate) Implement the Android back button for the bookshelf app: close open dialogs first, then navigate back in the router, and exit only from the home screen after a "press back again to exit" toast.
- (Interview) "A hybrid app works on Android but crashes on iOS the moment the user taps 'Scan'. What is your first suspicion?" Hint: think about what iOS requires before an app may access the camera, and where that is configured in a Cordova project.
Building, Emulating and Debugging
Why a special workflow. A hybrid app runs inside a native process on an emulator or device, so the usual "save and refresh the browser" loop does not apply directly. You need to build, deploy, inspect the WebView, and read native logs when a plugin fails.
Build types and targets.
cordova build android # debug APK: platforms/android/app/build/outputs/apk/debug/
cordova build android --release # release build (needs signing, next topic)
cordova build ios --emulator # build for the iOS Simulator
cordova build ios --device # build for a physical iPhone (needs signing)
cordova emulate android # start the default AVD (Android Virtual Device) and install
cordova run android --list # list available devices and emulators
cordova run android --target=Pixel_8_API_35
cordova run android --device # USB-connected phone with USB debugging enabled
Emulators are fast for UI work; real devices are essential for performance, camera, sensors, push notifications and battery behavior.
A faster development loop. Most UI work does not need native features. Develop in a desktop browser with a dev server (Vite's hot reload), and make the app tolerate missing plugins:
// services/camera.js: one interface, different implementations
export async function pickCoverImage() {
if (window.cordova && navigator.camera) {
return takeCoverPhoto(); // native plugin (previous topic)
}
// Browser fallback for development and web builds
return new Promise((resolve) => {
const input = Object.assign(document.createElement('input'), { type: 'file', accept: 'image/*' });
input.onchange = () => resolve(URL.createObjectURL(input.files[0]));
input.click();
});
}
The browser platform (cordova platform add browser, cordova run browser) runs the app in a desktop browser with browser implementations of some plugins, which helps for quick checks.
Debugging the WebView.
| Platform | Tool | How |
|---|---|---|
| Android | Chrome DevTools | Open chrome://inspect/#devices in desktop Chrome with the device connected or the emulator running; click "inspect" under the app's WebView. Works for debug builds (WebView debugging is enabled automatically). |
| iOS | Safari Web Inspector | Enable Web Inspector on the device (Settings > Safari > Advanced), then in desktop Safari open the Develop menu > device > app page. Debug builds are inspectable; release builds need isInspectable (iOS 16.4+). |
You get the full DevTools experience: console, DOM, network requests, breakpoints, performance profiles.
Native logs and debugging. When a plugin crashes or fails silently, the error is in native logs:
adb devices # list connected Android devices
adb logcat | grep -iE "chromium|cordova|bookshelf|AndroidRuntime" # WebView console + crashes
adb logcat *:E # errors only
For deeper problems, open the native project (platforms/android in Android Studio, platforms/ios/Bookshelf.xcworkspace in Xcode) and use the native debugger with breakpoints in plugin code. On iOS, the Xcode console shows native logs and WebView console output.
Common problems.
| Symptom | Likely cause |
|---|---|
Plugin object is undefined | Called before deviceready, plugin not installed for this platform, or cordova.js not loaded |
| White screen on start | JavaScript error during startup (check the WebView console), wrong <content src>, absolute asset paths with a bundler |
| Network requests fail on device but work in browser | Missing <access> entry, CSP connect-src, HTTP instead of HTTPS (blocked by default by Android and iOS App Transport Security), CORS with the local origin |
| Build fails after an update | Plugin incompatible with the platform version, wrong JDK or Gradle version |
| Works on Android, broken on iOS | Missing usage description, WebKit feature differences, custom-scheme file URLs |
CORS with a local origin. The app's origin is https://localhost (Android) or app://localhost (iOS), so your API must allow those origins in its CORS configuration, as in Chapter 12:
// Express API: allow the hybrid app's origins
app.use(cors({
origin: ['https://bookshelf.example', 'https://localhost', 'app://localhost', 'capacitor://localhost'],
credentials: true,
}));
Key Takeaways
cordova build,emulateandrunbuild and deploy to emulators and devices; test on real devices for hardware features and performance.- Develop most UI in a desktop browser with fallbacks for missing plugins, and deploy to devices for native features.
- Debug the WebView with
chrome://inspect(Android) and Safari Web Inspector (iOS). - Native errors appear in
adb logcatand the Xcode console; native debuggers work on the generated projects. - Allow the app's local origins (
https://localhost,app://localhost) in your API's CORS settings.
Practice
- (Beginner) Run the bookshelf app on an Android emulator, open it in
chrome://inspect, set a breakpoint in the book list loading code and inspect a network request.- (Intermediate) Create a
services/layer for camera, storage and device info with Cordova and browser implementations, so the whole app runs withvite devin a browser and withcordova runon a device without code changes.- (Interview) "A hybrid app shows a white screen on a device but works in the desktop browser. How do you debug it?" Hint: list where you would look (remote WebView console, native logs), and the usual suspects (asset paths, CSP,
deviceready, startup errors).
Packaging and Store Deployment
Why this step needs care. Publishing is where many hybrid projects stall: signing keys, store accounts, review guidelines and metadata. Mistakes can be costly; losing the Android signing key used to mean you could never update your app again (Play App Signing now protects against that).
Android: signing and Android App Bundles. Google Play requires apps to be uploaded as Android App Bundles (.aab) for new apps since August 2021; Play then generates optimized APKs for each device. Every upload must be signed with your upload key; with Play App Signing, Google holds the final app signing key and can reset a lost upload key.
# 1. Create an upload keystore once (keep it and its passwords safe, outside the repo)
keytool -genkeypair -v -keystore bookshelf-upload.keystore -alias upload \
-keyalg RSA -keysize 2048 -validity 10000
# 2. Build a signed release bundle
cordova build android --release -- \
--keystore=../keys/bookshelf-upload.keystore --alias=upload \
--storePassword="$KEYSTORE_PASSWORD" --password="$KEY_PASSWORD" \
--packageType=bundle
# Output: platforms/android/app/build/outputs/bundle/release/app-release.aab
The same options can live in a build.json file (cordova build android --release --buildConfig=build.json). Keep that file and the keystore out of version control and inject secrets in CI.
iOS: certificates, provisioning and archives. iOS distribution requires an Apple Developer Program membership. You need a distribution certificate, an App ID matching config.xml's id, and a provisioning profile. Xcode's automatic signing manages most of this.
{
"ios": {
"release": {
"developmentTeam": "ABCDE12345",
"codeSignIdentity": "Apple Distribution",
"packageType": "app-store",
"automaticProvisioning": true
}
}
}
cordova build ios --release --device --buildConfig=build.json
# Or: open platforms/ios/Bookshelf.xcworkspace in Xcode -> Product > Archive -> Distribute App
Upload the archive to App Store Connect (from Xcode's Organizer or Apple's Transporter app), test with TestFlight, then submit for review.
Store listings. Both stores need metadata: name, description, screenshots for several device sizes, an icon, a privacy policy URL, age rating, and data-collection disclosures (Google Play's Data safety section, Apple's privacy "nutrition labels"). Use semantic versioning for version and increase the build numbers (android-versionCode, ios-CFBundleVersion) for every upload; stores reject duplicates.
Review pitfalls for hybrid apps.
- Minimum functionality (Apple guideline 4.2): an app that just shows a website in a WebView is rejected. Provide offline content, native integrations and an app-like experience.
- Code updates: you may update web assets over the air (for example with Capacitor live update services) only if the update does not change the app's primary purpose; Apple's guideline 3.3.2 (formerly 2.5.2) restricts downloading executable code.
- Payments: digital goods sold in the app must use in-app purchase on both stores, with regional exceptions.
- Permissions: every permission must be justified and used.
- Target API level: Google Play requires a recent
targetSdkVersionfor new apps and updates.
Automating releases. Repeated manual builds are error-prone. fastlane automates screenshots, signing, building and uploading to both stores; CI services (GitHub Actions with macOS runners, Bitrise, Codemagic) run it on every tag.
# .github/workflows/android-release.yml (excerpt)
- run: npm ci && npm run build:web
- run: echo "$UPLOAD_KEYSTORE_B64" | base64 -d > upload.keystore
- run: >
npx cordova build android --release -- --keystore=upload.keystore --alias=upload
--storePassword="$KEYSTORE_PASSWORD" --password="$KEY_PASSWORD" --packageType=bundle
- uses: r0adkll/upload-google-play@v1 # or fastlane supply
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: com.example.bookshelf
releaseFiles: platforms/android/app/build/outputs/bundle/release/app-release.aab
track: internal
Key Takeaways
- Google Play requires signed Android App Bundles; use Play App Signing and keep the upload keystore safe and out of the repository.
- iOS needs an Apple Developer membership, certificates and provisioning profiles; archive with Xcode or
cordova build ios --release, then distribute through App Store Connect and TestFlight. - Increase build numbers for every upload and prepare store metadata, privacy disclosures and screenshots.
- Hybrid apps must offer real app value (not a wrapped website), follow code-update and payment rules, and justify permissions.
- Automate builds and uploads with fastlane and CI, storing signing secrets in the CI's secret store.
Practice
- (Beginner) Generate an upload keystore and build a signed release
.aabof the bookshelf app. Inspect it withbundletoolor Android Studio's APK Analyzer.- (Intermediate) Write a
build.jsonand a CI workflow that builds signed Android bundles on every Git tag, reading the keystore and passwords from secrets. Upload to an internal testing track.- (Interview) "What can get a hybrid app rejected from the App Store that would not affect a native app?" Hint: think about minimum functionality, downloading code after review, and how much the app does beyond the website.
Capacitor as a Successor
Why Capacitor. By the late 2010s, Cordova showed its age: callback-based APIs, generated native projects that could not be edited safely, slow plugin maintenance and complicated configuration hooks. The Ionic team, the largest Cordova user, built Capacitor (1.0 in 2019) as a "spiritual successor": the same idea (web app + WebView + native plugins) with a modern developer experience. Capacitor is now the default runtime for Ionic apps (15.5) but works with any web framework.
Key differences from Cordova.
| Aspect | Cordova | Capacitor |
|---|---|---|
| Native projects | Generated in platforms/, overwritten by prepare, not committed | android/ and ios/ are source artifacts: committed, edited freely in Android Studio and Xcode |
| Configuration | config.xml | capacitor.config.ts (or .json) for Capacitor; native settings edited directly in native files |
| JavaScript API | Global objects, callbacks, deviceready | ES module imports, Promises, available immediately (no deviceready) |
| Plugins | plugin.xml, cordova.exec | npm packages with native code; Cordova plugins mostly still work |
| Web build | Expects www/ | Any webDir (dist, build) from any bundler |
| Web support | Separate browser platform | Plugins ship web implementations, so the same code runs as a PWA |
| iOS dependencies | CocoaPods | Swift Package Manager (supported since Capacitor 6) or CocoaPods |
| Asset origin | https://localhost (Android), configurable (iOS) | https://localhost (Android), capacitor://localhost (iOS) |
Workflow.
npm install @capacitor/core
npm install -D @capacitor/cli
npx cap init Bookshelf com.example.bookshelf --web-dir=dist # creates capacitor.config.ts
npm install @capacitor/android @capacitor/ios
npx cap add android # creates android/ (commit it)
npx cap add ios # creates ios/ (commit it)
npm run build # build the web app into dist/
npx cap sync # copy dist/ into native projects + update native plugins
npx cap run android # build and run on a device or emulator
npx cap open ios # open the Xcode project for native work, signing, archiving
Run npx cap sync after every web build or plugin install; npx cap copy copies only web assets (faster when plugins have not changed).
// capacitor.config.ts
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.example.bookshelf',
appName: 'Bookshelf',
webDir: 'dist',
server: {
androidScheme: 'https', // default in recent versions
// url: 'http://192.168.1.20:5173', // live reload during development (do not ship)
// cleartext: true,
},
plugins: {
SplashScreen: { launchShowDuration: 1000, backgroundColor: '#2e5d4b' },
},
};
export default config;
Plugins with Promises. Official plugins live under the @capacitor/ scope: camera, geolocation, filesystem, preferences, push-notifications, local-notifications, share, haptics, network, app, status-bar, splash-screen, keyboard, device, and more. Community plugins are collected by the Capacitor Community organization and by Capawesome.
// Camera, preferences and geolocation in Capacitor: modern, typed, Promise-based
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { Preferences } from '@capacitor/preferences';
import { Geolocation } from '@capacitor/geolocation';
import { Capacitor } from '@capacitor/core';
export async function takeCoverPhoto(): Promise<string | undefined> {
const photo = await Camera.getPhoto({
quality: 70,
width: 800,
resultType: CameraResultType.Uri, // a file URI; webPath is directly usable in <img>
source: CameraSource.Prompt, // ask: camera or photo library
});
return photo.webPath; // works on Android, iOS and the web implementation
}
export async function saveLastSearch(query: string) {
await Preferences.set({ key: 'lastSearch', value: query }); // native key-value storage
}
export async function currentPosition() {
const perm = await Geolocation.checkPermissions();
if (perm.location !== 'granted') {
const req = await Geolocation.requestPermissions();
if (req.location !== 'granted') throw new Error('Location permission denied');
}
const { coords } = await Geolocation.getCurrentPosition({ enableHighAccuracy: false });
return coords;
}
console.log(Capacitor.getPlatform()); // 'ios' | 'android' | 'web'
console.log(Capacitor.isNativePlatform()); // false in the browser
Writing a Capacitor plugin. The structure mirrors Cordova but with less ceremony. The JavaScript side registers a plugin by name with TypeScript types:
// src/plugins/greeter.ts
import { registerPlugin } from '@capacitor/core';
export interface GreeterPlugin {
greet(options: { name: string }): Promise<{ message: string }>;
}
export const Greeter = registerPlugin<GreeterPlugin>('Greeter', {
web: () => ({ greet: async ({ name }) => ({ message: `Hello, ${name} from the web` }) }),
});
// android/app/src/main/java/com/example/bookshelf/GreeterPlugin.java
@CapacitorPlugin(name = "Greeter")
public class GreeterPlugin extends Plugin {
@PluginMethod
public void greet(PluginCall call) {
String name = call.getString("name", "");
if (name.isEmpty()) { call.reject("Name must not be empty"); return; }
JSObject ret = new JSObject();
ret.put("message", "Hello, " + name + " from Android");
call.resolve(ret); // resolves the JavaScript Promise
}
}
// Register in MainActivity.onCreate: registerPlugin(GreeterPlugin.class) before super.onCreate
// ios/App/App/GreeterPlugin.swift
import Capacitor
@objc(GreeterPlugin)
public class GreeterPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "GreeterPlugin"
public let jsName = "Greeter"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "greet", returnType: CAPPluginReturnPromise)
]
@objc func greet(_ call: CAPPluginCall) {
let name = call.getString("name") ?? ""
guard !name.isEmpty else { call.reject("Name must not be empty"); return }
call.resolve(["message": "Hello, \(name) from iOS"])
}
}
Migrating from Cordova. Capacitor supports most Cordova plugins directly (npm install cordova-plugin-x && npx cap sync), which makes incremental migration possible:
- Add Capacitor to the existing project (
npx cap initwithwebDirset towww), add platforms. - Move icons, splash screens, permissions and preferences from
config.xmlinto the native projects andcapacitor.config.ts. - Keep Cordova plugins that work; replace those with official Capacitor equivalents over time.
- Remove
devicereadywaits where code only uses Capacitor plugins (Cordova plugins in Capacitor still firedeviceready). - Delete
config.xml,platforms/,plugins/and the Cordova CLI when nothing depends on them.
Key Takeaways
- Capacitor, created by the Ionic team in 2019, follows Cordova's hybrid model with modern tooling and is the default for Ionic apps.
- Native projects (
android/,ios/) are committed source code you edit in Android Studio and Xcode; configuration lives incapacitor.config.ts. - Plugins are npm packages with typed, Promise-based APIs and web implementations; no
devicereadyis needed. - Core commands:
npx cap add,npx cap sync(after each build or plugin change),npx cap run,npx cap open. - Most Cordova plugins work in Capacitor, enabling incremental migration.
Practice
- (Beginner) Add Capacitor to the Vite bookshelf front end, add the Android platform, and run it on an emulator. Use
@capacitor/preferencesto remember the last search.- (Intermediate) Migrate the Cordova bookshelf app to Capacitor: keep one Cordova plugin, replace camera and geolocation with
@capacitor/cameraand@capacitor/geolocation, move permissions into the native projects, and removeconfig.xml.- (Interview) "Why does Capacitor commit native projects to source control while Cordova regenerates them?" Hint: weigh the cost of editing native code through configuration and hooks against the cost of merging native project changes during upgrades.
<a id="155-ionic-framework"></a>
15.5 Ionic Framework
Capacitor and Cordova put a web app on a phone, but they say nothing about how it looks: a plain web UI inside a store app feels out of place next to native apps. Ionic is a UI toolkit of mobile components that look and behave like native iOS and Android controls, built as Web Components and usable with Angular, React, Vue or plain JavaScript. This subchapter covers its architecture, components, framework integrations, navigation, native plugins, theming and the build-and-release workflow.
Ionic Architecture and Web Components
Why Ionic. Building a polished mobile UI from scratch means implementing toolbars that follow each platform's conventions, swipeable list items, pull-to-refresh, modals that slide up as sheets, page transitions that match iOS or Android, safe-area handling, and accessibility for all of them. Ionic Framework provides these as ready-made components. Combined with Capacitor (15.4), one codebase can ship to the App Store, Google Play and the web as a PWA.
History in brief.
| Version | Year | Architecture |
|---|---|---|
| Ionic 1 | 2013-2015 | AngularJS directives, Cordova |
| Ionic 2 and 3 | 2016-2017 | Angular (2+) components |
| Ionic 4 | 2019 | Rebuilt as Web Components with Stencil; framework-agnostic; React and Vue support followed |
| Ionic 5 to 8 | 2020-2024 | Refinements: new design updates, Capacitor as default runtime, standalone Angular components, improved theming (dark and high-contrast palettes) |
The company behind Ionic was acquired by OutSystems in 2022; the framework remains open source under the MIT license.
Web Components as the foundation. Web Components are browser standards for creating custom HTML elements: Custom Elements (define a new tag such as <ion-button> with its own behavior), Shadow DOM (encapsulated markup and styles), and HTML templates and slots (where child content goes). Because they are native to the browser, they work in any framework or none. Ionic writes its components once in Stencil, a compiler that outputs standard Web Components, and generates thin framework wrappers.
+-------------------------------------------------------------------+
| Your app (Angular / React / Vue / plain JS) |
+-------------------------------------------------------------------+
| @ionic/angular | @ionic/react | @ionic/vue | (no wrapper needed) | framework bindings:
| (directives, | (React | (Vue | | forms, routing,
| NavController)| components, | components| | lifecycle hooks
| | router) | router) | |
+-------------------------------------------------------------------+
| @ionic/core: ~100 Web Components (<ion-button>, <ion-list>, |
| <ion-modal>...), built with Stencil, Shadow DOM, CSS vars |
+-------------------------------------------------------------------+
| Browser / WebView (Capacitor, Cordova) / PWA |
+-------------------------------------------------------------------+
| Capacitor plugins (camera, push, filesystem...) optional |
+-------------------------------------------------------------------+
Using Ionic without a framework. Because the components are standard custom elements, one script tag is enough to try them:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css">
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar color="primary">
<ion-title>Bookshelf</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-list id="books"></ion-list>
<ion-button expand="block" id="add">Add book</ion-button>
</ion-content>
</ion-app>
<script type="module">
const list = document.querySelector('#books');
const books = await (await fetch('/api/v1/books')).json();
list.innerHTML = books.data
.map((b) => `<ion-item><ion-label><h2>${b.title}</h2><p>${b.author}</p></ion-label></ion-item>`)
.join(''); // real code must escape titles to avoid XSS (Chapter 17)
document.querySelector('#add').addEventListener('click', async () => {
const alert = document.createElement('ion-alert'); // overlays are elements too
alert.header = 'Add book';
alert.inputs = [{ name: 'title', placeholder: 'Title' }];
alert.buttons = ['Cancel', { text: 'Add', handler: (data) => console.log(data.title) }];
document.body.appendChild(alert);
await alert.present();
});
</script>
</body>
</html>
Properties, attributes and events. Ionic components are configured through attributes (strings) or JavaScript properties (any type, such as the inputs array above), and emit custom events prefixed with ion, such as ionChange, ionInput, ionFocus and ionRefresh. Framework wrappers map these to the framework's idioms ((ionChange) in Angular, onIonChange in React, @ionChange in Vue).
Shadow DOM implications. Most Ionic components render their internals in a shadow root. Your global CSS cannot reach inside, which protects the components from accidental breakage, but it means you customize them through CSS custom properties and shadow parts instead of descendant selectors (15.5, Theming).
Key Takeaways
- Ionic Framework is a library of mobile UI components that mimic native iOS and Android controls, usually deployed with Capacitor.
- Since Ionic 4, components are standard Web Components built with Stencil (
@ionic/core), with thin wrappers for Angular, React and Vue. - Components work with no framework at all through a script tag; they use attributes, properties and
ion-prefixed events. - Shadow DOM encapsulates component internals; customization uses CSS custom properties and shadow parts.
- Ionic is open source (MIT); Capacitor provides native access.
Practice
- (Beginner) Create a plain HTML page using Ionic from a CDN with a toolbar, a list of three books and a button that opens an
ion-toast.- (Intermediate) Write a tiny custom element
<book-badge status="read">with Shadow DOM and a CSS custom property for its color. Compare how you style it from outside with how you styleion-badge.- (Interview) "Why did Ionic move from Angular components to Web Components in version 4?" Hint: think about framework lock-in, the size of the potential user base, maintenance of one component set versus several, and browser standards.
UI Components and Platform-Adaptive Styling
Why platform-adaptive styling. iOS and Android users have different expectations: on iOS, titles are centered, back buttons show a chevron with text, and modals slide up as cards; on Android (Material Design), titles are left-aligned, buttons have ripple effects, and toolbars have different heights and shadows. An app that looks like iOS on Android (or the reverse) feels foreign. Ionic solves this with modes: every component has an ios and an md (Material Design) style, and Ionic picks the right one for the platform automatically.
Modes.
| Platform | Default mode |
|---|---|
| iPhone, iPad | ios |
| Android | md |
| Desktop and other browsers | md |
ios mode md mode
+--------------------------------+ +--------------------------------+
| < Library Dune Edit| | <- Dune : |
+--------------------------------+ +================================+ (shadow)
| Frank Herbert | | Frank Herbert |
| ----------------------------- | | |
| Status Read > | | Status Read |
+--------------------------------+ +--------------------------------+
centered title, text back button, left-aligned title, arrow icon,
thin separators, chevrons shadow, ripple on tap
You can force a mode for the whole app (useful for a consistent brand look or for testing) or for single components:
<ion-button mode="ios">Always iOS style</ion-button>
// React: global configuration before rendering
import { setupIonicReact } from '@ionic/react';
setupIonicReact({ mode: 'md' }); // the whole app uses Material Design styles
// Detect the platform in code
import { isPlatform } from '@ionic/react'; // also available in @ionic/core, Angular's Platform service
if (isPlatform('ios')) { /* ... */ }
if (isPlatform('capacitor')) { /* running in a native Capacitor app */ }
if (isPlatform('mobileweb')) { /* mobile browser, not native */ }
During development, append ?ionic:mode=ios to the URL to preview the other mode in a desktop browser.
Component overview. Ionic has around 100 components. The most used:
| Group | Components |
|---|---|
| App structure | ion-app, ion-header, ion-toolbar, ion-title, ion-buttons, ion-back-button, ion-content, ion-footer, ion-split-pane, ion-menu |
| Navigation | ion-router-outlet, ion-tabs, ion-tab-bar, ion-tab-button, ion-nav, ion-breadcrumbs |
| Lists | ion-list, ion-item, ion-label, ion-note, ion-item-sliding, ion-item-options, ion-reorder-group, ion-avatar, ion-thumbnail |
| Forms | ion-input, ion-textarea, ion-select, ion-checkbox, ion-toggle, ion-radio-group, ion-range, ion-datetime, ion-searchbar, ion-segment |
| Buttons and indicators | ion-button, ion-fab, ion-icon, ion-badge, ion-chip, ion-spinner, ion-progress-bar, ion-skeleton-text |
| Overlays | ion-modal (including sheet modals), ion-popover, ion-alert, ion-action-sheet, ion-toast, ion-loading |
| Scrolling | ion-refresher (pull to refresh), ion-infinite-scroll |
| Layout | ion-grid, ion-row, ion-col, ion-card |
A typical screen.
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-menu-button></ion-menu-button> <!-- opens the side menu -->
</ion-buttons>
<ion-title>Library</ion-title>
</ion-toolbar>
<ion-toolbar>
<ion-searchbar debounce="300" placeholder="Search books"></ion-searchbar>
</ion-toolbar>
</ion-header>
<ion-content>
<!-- Pull to refresh: emits ionRefresh; call event.detail.complete() when done -->
<ion-refresher slot="fixed">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<ion-list>
<ion-item-sliding> <!-- swipe left to reveal options -->
<ion-item button detail="true"> <!-- button: tappable; detail: chevron (ios) -->
<ion-thumbnail slot="start"><img src="/covers/dune.jpg" alt=""></ion-thumbnail>
<ion-label>
<h2>Dune</h2>
<p>Frank Herbert</p>
</ion-label>
<ion-badge slot="end" color="success">Read</ion-badge>
</ion-item>
<ion-item-options side="end">
<ion-item-option color="danger" expandable>Delete</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-infinite-scroll threshold="100px"> <!-- emits ionInfinite near the bottom -->
<ion-infinite-scroll-content loading-text="Loading more books..."></ion-infinite-scroll-content>
</ion-infinite-scroll>
<ion-fab slot="fixed" vertical="bottom" horizontal="end">
<ion-fab-button aria-label="Add book"><ion-icon name="add"></ion-icon></ion-fab-button>
</ion-fab>
</ion-content>
Slots. Many components position children through slots: slot="start" and slot="end" place content at the leading and trailing edge (and flip automatically for right-to-left languages), slot="fixed" keeps elements outside the scrolling area of ion-content.
Icons. Ionic uses Ionicons, an open-source icon set with iOS and Material variants (name="add", name="trash-outline", name="book-sharp"). In bundled apps, icons are imported individually so unused ones are not shipped (next topic).
Overlays in code. Overlays can be declared inline (for example <ion-modal trigger="open-filters">) or created with controllers or hooks:
// React: action sheet and toast with hooks
import { useIonActionSheet, useIonToast } from '@ionic/react';
function BookActions({ book, onDelete }: { book: Book; onDelete: (id: number) => void }) {
const [presentSheet] = useIonActionSheet();
const [presentToast] = useIonToast();
const openActions = () =>
presentSheet({
header: book.title,
buttons: [
{ text: 'Delete', role: 'destructive', handler: () => {
onDelete(book.id);
presentToast({ message: 'Book deleted', duration: 2000, position: 'bottom' });
} },
{ text: 'Cancel', role: 'cancel' },
],
});
return <IonButton fill="clear" onClick={openActions}>More</IonButton>;
}
Key Takeaways
- Ionic components have two modes,
iosandmd, chosen automatically by platform; force them globally or per component withmode. - Use
isPlatform()(or Angular'sPlatform) to detectios,android,capacitor,mobileweband more. - Core building blocks:
ion-header/ion-toolbar/ion-contentfor screens,ion-list/ion-itemfor lists, overlays such asion-modal,ion-alert,ion-toastandion-action-sheet. - Slots (
start,end,fixed) position content and adapt to right-to-left layouts. - Built-in mobile patterns include sliding items, pull to refresh, infinite scroll and floating action buttons.
Practice
- (Beginner) Build the bookshelf "Library" screen with a toolbar, searchbar, list of books with thumbnails and badges, and a floating "Add" button. Preview it in both modes using
?ionic:mode=ios.- (Intermediate) Add pull to refresh that reloads
GET /api/v1/books, infinite scroll that loads the next page with?page=andlimit=, and sliding items with a "Delete" option that asks for confirmation withion-alert.- (Interview) "Should an app use platform-specific styling or one consistent brand style on both platforms?" Hint: consider user expectations, brand identity, design and QA effort, and which components (navigation, dialogs) matter most for platform conventions.
Ionic with Angular, React and Vue
Why framework integrations. Web Components alone do not fit perfectly into frameworks: React historically passed only string attributes to custom elements, frameworks need two-way binding with form controls, routers must cooperate with Ionic's animated page stack, and pages need lifecycle hooks that fire when they are shown or hidden from the stack. The @ionic/angular, @ionic/react and @ionic/vue packages provide this glue.
Creating a project. The Ionic CLI scaffolds apps with Capacitor already configured:
npm install -g @ionic/cli
ionic start bookshelf tabs --type=react --capacitor # starters: blank, tabs, sidemenu, list
ionic start bookshelf tabs --type=vue
ionic start bookshelf tabs --type=angular-standalone # Angular with standalone components
cd bookshelf
ionic serve # dev server with live reload in the browser
The same page in three frameworks. A book list with a search filter shows the differences.
React (@ionic/react): Ionic components are React components prefixed with Ion, and events use onIon... props. Every routed page must be wrapped in IonPage so the router can animate it.
// src/pages/Library.tsx
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonSearchbar,
IonList, IonItem, IonLabel, useIonViewWillEnter } from '@ionic/react';
import { useState } from 'react';
export default function Library() {
const [books, setBooks] = useState<Book[]>([]);
const [query, setQuery] = useState('');
// Ionic lifecycle: runs every time the page is about to be shown (also when navigating back)
useIonViewWillEnter(() => {
fetch('/api/v1/books').then((r) => r.json()).then((res) => setBooks(res.data));
});
const visible = books.filter((b) => b.title.toLowerCase().includes(query.toLowerCase()));
return (
<IonPage>
<IonHeader>
<IonToolbar><IonTitle>Library</IonTitle></IonToolbar>
</IonHeader>
<IonContent>
<IonSearchbar value={query} debounce={250}
onIonInput={(e) => setQuery(e.detail.value ?? '')} />
<IonList>
{visible.map((b) => (
<IonItem key={b.id} routerLink={`/books/${b.id}`} detail>
<IonLabel><h2>{b.title}</h2><p>{b.author}</p></IonLabel>
</IonItem>
))}
</IonList>
</IonContent>
</IonPage>
);
}
Vue (@ionic/vue): components are imported from @ionic/vue and used in templates; v-model works on Ionic inputs.
<!-- src/views/LibraryPage.vue -->
<script setup lang="ts">
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonSearchbar,
IonList, IonItem, IonLabel, onIonViewWillEnter } from '@ionic/vue';
import { ref, computed } from 'vue';
const books = ref<Book[]>([]);
const query = ref('');
onIonViewWillEnter(async () => {
books.value = (await (await fetch('/api/v1/books')).json()).data;
});
const visible = computed(() =>
books.value.filter((b) => b.title.toLowerCase().includes(query.value.toLowerCase()))
);
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar><ion-title>Library</ion-title></ion-toolbar>
</ion-header>
<ion-content>
<ion-searchbar v-model="query" :debounce="250" />
<ion-list>
<ion-item v-for="b in visible" :key="b.id" :router-link="`/books/${b.id}`" detail>
<ion-label><h2>{{ b.title }}</h2><p>{{ b.author }}</p></ion-label>
</ion-item>
</ion-list>
</ion-content>
</ion-page>
</template>
Angular (@ionic/angular/standalone): components are standalone Angular components; ngModel and reactive forms work through value accessors; Ionic's lifecycle methods are class methods.
// src/app/library/library.page.ts
import { Component, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { IonHeader, IonToolbar, IonTitle, IonContent, IonSearchbar,
IonList, IonItem, IonLabel } from '@ionic/angular/standalone';
@Component({
selector: 'app-library',
standalone: true,
imports: [FormsModule, RouterLink, IonHeader, IonToolbar, IonTitle, IonContent,
IonSearchbar, IonList, IonItem, IonLabel],
template: `
<ion-header><ion-toolbar><ion-title>Library</ion-title></ion-toolbar></ion-header>
<ion-content>
<ion-searchbar [ngModel]="query()" (ngModelChange)="query.set($event)" [debounce]="250" />
<ion-list>
@for (b of visible(); track b.id) {
<ion-item [routerLink]="['/books', b.id]" detail>
<ion-label><h2>{{ b.title }}</h2><p>{{ b.author }}</p></ion-label>
</ion-item>
}
</ion-list>
</ion-content>
`,
})
export class LibraryPage {
private http = inject(HttpClient);
books = signal<Book[]>([]);
query = signal('');
visible = computed(() =>
this.books().filter((b) => b.title.toLowerCase().includes(this.query().toLowerCase())));
ionViewWillEnter() { // Ionic lifecycle hook, called on every show
this.http.get<{ data: Book[] }>('/api/v1/books').subscribe((res) => this.books.set(res.data));
}
}
Bootstrapping. Each integration needs a setup step and the Ionic CSS:
| Framework | Setup |
|---|---|
| React | setupIonicReact() before rendering; import @ionic/react/css/core.css and optional utility CSS |
| Vue | createApp(App).use(IonicVue).use(router); import @ionic/vue/css/core.css |
| Angular (standalone) | provideIonicAngular({}) in bootstrapApplication providers; global styles include @ionic/angular/css/core.css |
Icons in bundled apps. To keep bundles small, icons are imported explicitly. In React and Vue pass the imported icon data: <IonIcon icon={add} /> with import { add } from 'ionicons/icons'. In Angular standalone, register them once with addIcons({ add, trashOutline }) from ionicons, then use <ion-icon name="add">.
Page lifecycle hooks. Ionic keeps previous pages in the DOM (hidden) in the navigation stack, so a framework's mount and unmount hooks do not run when the user navigates back. Ionic adds four hooks that do:
| Hook (Angular / React / Vue) | Fires |
|---|---|
ionViewWillEnter / useIonViewWillEnter / onIonViewWillEnter | Before the page animates in (every time) |
ionViewDidEnter / useIonViewDidEnter / onIonViewDidEnter | After the transition finishes |
ionViewWillLeave / useIonViewWillLeave / onIonViewWillLeave | Before the page animates out |
ionViewDidLeave / useIonViewDidLeave / onIonViewDidLeave | After it is hidden |
Use them to refresh data when returning to a page and to pause expensive work on hidden pages.
Key Takeaways
@ionic/angular,@ionic/reactand@ionic/vuewrap the Web Components with framework-native bindings, form integration, routing and lifecycle hooks.ionic start <name> <template> --type=<framework> --capacitorscaffolds a project;ionic serveruns it in the browser.- React uses
Ion*components withonIon*events and requiresIonPagefor routed pages; Vue supportsv-model; Angular supportsngModeland reactive forms with standalone imports. - Import icons explicitly to keep bundles small.
- Pages stay in the DOM when navigating; use Ionic's
ionViewWillEnter-style hooks instead of mount hooks for "on every show" logic.
Practice
- (Beginner) Scaffold an Ionic app with the tabs template in the framework you know best, and replace the first tab with the bookshelf Library page.
- (Intermediate) Build the "Add book" form (title, author, genre select, rating range, "read" toggle) with validation using your framework's form tooling (React Hook Form, Vue
v-modelwith a validation library, or Angular reactive forms) and Ionic inputs that show error text.- (Interview) "Why does Ionic provide its own page lifecycle hooks instead of relying on React's
useEffector Angular'sngOnInit?" Hint: think about what happens to the previous page's component when a new page is pushed onto the stack and when the user navigates back.
Navigation and Routing in Ionic
Why mobile navigation is different. Web routing replaces one view with another. Mobile apps instead maintain stacks: tapping a book pushes a details page on top of the list, which slides in from the right; going back pops it, revealing the list exactly as it was, scroll position included. Tab-based apps keep a separate stack per tab, so switching tabs and back returns to where you were in that tab. Ionic's router outlet combines URL-based routing (so deep links, the browser back button and PWAs work) with this stack behavior and native-style transitions.
Tab "Library" stack Tab "Reading" stack
+-------------------+ +-------------------+
| /library/books/7 | <- top | /reading |
+-------------------+ +-------------------+
| /library |
+-------------------+
Switching to "Reading" and back keeps /library/books/7 on screen;
the back button pops to /library with its scroll position preserved.
ion-router-outlet. Each framework's router renders pages into ion-router-outlet (instead of the plain framework outlet). It keeps previous pages in the DOM, applies push and pop animations, and supports the iOS swipe-back gesture.
React with @ionic/react-router. Ionic React's router integration has historically been built on React Router v5; check which React Router version your Ionic release supports.
// src/App.tsx
import { IonApp, IonRouterOutlet, IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel,
setupIonicReact } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import { Route, Redirect } from 'react-router-dom';
import { library, bookmark, person } from 'ionicons/icons';
setupIonicReact();
export default function App() {
return (
<IonApp>
<IonReactRouter>
<IonTabs>
<IonRouterOutlet>
<Route exact path="/library" component={Library} />
<Route exact path="/library/books/:id" component={BookDetails} /> {/* nested in Library tab */}
<Route exact path="/reading" component={ReadingList} />
<Route exact path="/profile" component={Profile} />
<Route exact path="/"><Redirect to="/library" /></Route>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="library" href="/library">
<IonIcon icon={library} /><IonLabel>Library</IonLabel>
</IonTabButton>
<IonTabButton tab="reading" href="/reading">
<IonIcon icon={bookmark} /><IonLabel>Reading</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile">
<IonIcon icon={person} /><IonLabel>Profile</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
</IonReactRouter>
</IonApp>
);
}
// src/pages/BookDetails.tsx: back button and programmatic navigation
import { IonPage, IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent,
IonButton, useIonRouter } from '@ionic/react';
import { useParams } from 'react-router-dom';
export default function BookDetails() {
const { id } = useParams<{ id: string }>();
const router = useIonRouter();
async function remove() {
await fetch(`/api/v1/books/${id}`, { method: 'DELETE' });
router.goBack(); // pop with the back animation
// router.push('/library', 'root', 'replace') would reset the stack instead
}
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
{/* defaultHref: where to go if the page was opened directly (deep link, no history) */}
<IonBackButton defaultHref="/library" />
</IonButtons>
<IonTitle>Book {id}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonButton color="danger" onClick={remove}>Delete</IonButton>
</IonContent>
</IonPage>
);
}
Vue with @ionic/vue-router. It wraps Vue Router 4; routes are defined as usual, but created with Ionic's createRouter:
// src/router/index.ts
import { createRouter, createWebHistory } from '@ionic/vue-router';
import TabsPage from '@/views/TabsPage.vue';
const routes = [
{ path: '/', redirect: '/tabs/library' },
{
path: '/tabs/',
component: TabsPage, // contains ion-tabs, ion-router-outlet, ion-tab-bar
children: [
{ path: 'library', component: () => import('@/views/LibraryPage.vue') },
{ path: 'library/books/:id', component: () => import('@/views/BookDetailsPage.vue') },
{ path: 'reading', component: () => import('@/views/ReadingPage.vue') },
],
},
];
export default createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes });
In Vue components, useIonRouter() provides push, back, canGoBack and navigate with explicit animation direction.
Angular. Angular's router is used directly with <ion-router-outlet> in the root template (IonRouterOutlet in standalone imports). Ionic adds NavController for navigation with an explicit direction, and a routerDirection attribute for links:
import { NavController } from '@ionic/angular/standalone';
export class BookDetailsPage {
private nav = inject(NavController);
saved() { this.nav.navigateBack('/tabs/library'); } // back animation
openAuthor(id: number) { this.nav.navigateForward(['/authors', id]); }
logout() { this.nav.navigateRoot('/login'); } // clear the stack
}
<ion-item [routerLink]="['/books', book.id]" routerDirection="forward">...</ion-item>
Navigation rules to remember.
| Rule | Reason |
|---|---|
Wrap every routed page in IonPage (React/Vue) | The outlet needs it to animate and manage the page |
Navigate between tabs with the tab bar, not push across tabs | Each tab owns its stack; pushing a page of another tab corrupts the history |
Use ion-back-button with defaultHref | Deep-linked pages have no history to go back to |
Use root navigation for login/logout | Prevents navigating "back" into protected pages |
| Use modals, not routes, for short tasks | Modals (like "Filter" or "Add book") do not belong in the stack |
Use ion-nav for navigation inside a modal | It provides its own push/pop stack without changing the URL |
Without a framework, Ionic provides ion-router with ion-route elements and ion-nav for the same stack navigation in plain JavaScript or Stencil apps.
Key Takeaways
- Mobile navigation uses stacks: push slides a page in, back pops it; tab apps keep one stack per tab.
ion-router-outletcombines URL routing with stack behavior, transitions, preserved state and swipe-back.- React uses
IonReactRouter, Vue uses@ionic/vue-router, Angular uses its own router plusNavController;useIonRoutercontrols direction in React and Vue. ion-back-buttonwithdefaultHrefhandles deep links; root navigation resets the stack for login and logout.- Keep each tab's pages within its own route prefix, and use modals (with
ion-navif needed) for short tasks.
Practice
- (Beginner) Set up a three-tab bookshelf app (Library, Reading, Profile) with a book details page inside the Library tab. Verify that switching tabs preserves the details page.
- (Intermediate) Add a login page outside the tabs with a route guard (React: a wrapper component; Vue:
beforeEach; Angular: acanActivatefunction) and use root navigation on login and logout so the back button never returns to protected pages.- (Interview) "Why does Ionic keep previous pages in the DOM instead of unmounting them like a typical web router?" Hint: consider back-navigation speed, preserved scroll position and form state, swipe-back gestures, and the memory cost of deep stacks.
Ionic Native and Capacitor Plugins
Why this topic. Ionic gives the app a native look; plugins give it native abilities. The way Ionic apps access plugins has changed over time, and existing projects use different approaches. Knowing the history explains the imports you will see in older code.
From Ionic Native to Awesome Cordova Plugins. When Ionic apps ran on Cordova, the Ionic team maintained Ionic Native: TypeScript wrappers that turned callback-based Cordova plugins into Promises and Observables and made them injectable in Angular. In 2021 the project was renamed Awesome Cordova Plugins and handed to the community, because Ionic's focus had moved to Capacitor.
// Older Ionic + Angular code (Ionic Native / Awesome Cordova Plugins wrapper + Cordova plugin)
import { BarcodeScanner } from '@awesome-cordova-plugins/barcode-scanner/ngx'; // was @ionic-native/...
@Injectable({ providedIn: 'root' })
export class ScanService {
constructor(private scanner: BarcodeScanner) {} // wrapper injected through Angular DI
async scanIsbn() {
const result = await this.scanner.scan(); // Promise instead of callbacks
return result.cancelled ? null : result.text;
}
}
Today: Capacitor plugins. New Ionic projects use Capacitor plugins directly. They are already Promise-based and typed, need no wrapper, and work the same in every framework.
| Source | Examples |
|---|---|
Official @capacitor/* | app, camera, filesystem, geolocation, haptics, keyboard, local-notifications, network, preferences, push-notifications, share, splash-screen, status-bar, device, browser, clipboard, toast, screen-orientation |
Capacitor Community (@capacitor-community/*) | SQLite, Bluetooth LE, AdMob, keep-awake, barcode scanning and others |
Capawesome (@capawesome/*, @capawesome-team/*) | ML Kit barcode scanning, file picker, badges, NFC, app updates and others |
| Cordova plugins | Mostly compatible with Capacitor (npm install cordova-plugin-x && npx cap sync) |
Wrapping plugins in services. Keep plugin calls out of components. A service module hides platform checks, permissions and fallbacks, and makes components testable:
// src/services/device-features.ts
import { Capacitor } from '@capacitor/core';
import { Haptics, ImpactStyle } from '@capacitor/haptics';
import { Share } from '@capacitor/share';
import { Network } from '@capacitor/network';
import { App } from '@capacitor/app';
export async function confirmTap() {
if (Capacitor.isNativePlatform()) {
await Haptics.impact({ style: ImpactStyle.Light }); // small vibration feedback on native
}
}
export async function shareBook(book: Book) {
const { value: canShare } = await Share.canShare();
if (!canShare) return navigator.clipboard.writeText(`https://bookshelf.example/books/${book.id}`);
await Share.share({
title: book.title,
text: `I'm reading "${book.title}" by ${book.author}`,
url: `https://bookshelf.example/books/${book.id}`,
});
}
export function onConnectivityChange(callback: (online: boolean) => void) {
return Network.addListener('networkStatusChange', (s) => callback(s.connected)); // returns a handle
}
// Android back button: let Ionic's router handle back, exit only from the root
export function setupBackButton(canGoBack: () => boolean, goBack: () => void) {
App.addListener('backButton', () => (canGoBack() ? goBack() : App.exitApp()));
}
// In a React component: listeners must be removed on unmount
useEffect(() => {
const handle = onConnectivityChange(setOnline);
return () => { handle.then((h) => h.remove()); }; // addListener returns a Promise of a handle
}, []);
Ionic's router already handles the Android back button for navigation (with priorities so overlays close first); custom back logic should use Ionic's ionBackButton event or useIonRouter().canGoBack() to cooperate with it.
Push notifications in a native Ionic app. Native apps receive push through Firebase Cloud Messaging (Android) and APNs (iOS), not Web Push (15.2). @capacitor/push-notifications registers the device and returns a token that your server uses with FCM or APNs:
import { Capacitor } from '@capacitor/core';
import { PushNotifications } from '@capacitor/push-notifications';
// router: the app's navigation object, e.g. from useIonRouter() or NavController
export async function registerPush(userId: number, router: { push(url: string): void }) {
let perm = await PushNotifications.checkPermissions();
if (perm.receive === 'prompt') perm = await PushNotifications.requestPermissions();
if (perm.receive !== 'granted') return;
await PushNotifications.addListener('registration', async ({ value: token }) => {
await fetch('/api/v1/push/devices', { // server stores the FCM/APNs token
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, token, platform: Capacitor.getPlatform() }),
});
});
await PushNotifications.addListener('pushNotificationActionPerformed', ({ notification }) => {
const url = notification.data?.url;
if (url) router.push(url); // deep link into the app
});
await PushNotifications.register(); // triggers 'registration'
}
Android requires a Firebase project and its google-services.json; iOS requires enabling the Push Notifications capability in Xcode and an APNs key.
Key Takeaways
- Ionic Native wrapped Cordova plugins in Promises for Ionic apps; it was renamed Awesome Cordova Plugins in 2021 and is community maintained.
- New Ionic apps use Capacitor plugins (
@capacitor/*, Capacitor Community, Capawesome) directly; most Cordova plugins still work. - Wrap plugin calls in service modules that handle platform checks, permissions and fallbacks, and remove listeners on unmount.
- Ionic's router handles the Android back button; integrate custom logic through
ionBackButtonorcanGoBack(). - Native push uses FCM and APNs tokens through
@capacitor/push-notifications, not Web Push.
Practice
- (Beginner) Add haptic feedback when the user marks a book as read and a native share button on the details page, with a clipboard fallback on the web.
- (Intermediate) Add barcode scanning of ISBNs using a Capacitor barcode plugin. Wrap it in a service with a permission check and a manual-entry fallback, then look up the book details from your API and pre-fill the "Add book" form.
- (Interview) "How do push notifications differ between an Ionic app running as a PWA and the same app running natively with Capacitor?" Hint: compare the delivery services, the subscription object versus device token, platform requirements (especially iOS), and the server-side libraries involved.
Theming and Custom Styling
Why Ionic's theming works the way it does. Ionic components use Shadow DOM (15.5, Architecture), so an ordinary rule like ion-button .button-inner { ... } cannot reach their internals. Instead, Ionic exposes CSS custom properties (variables) at two levels: global theme variables that affect every component, and component-specific variables. Where variables are not enough, shadow parts (::part()) expose selected internal elements for styling.
The color system. Ionic defines nine named colors: primary, secondary, tertiary, success, warning, danger, light, medium and dark. Most components accept a color attribute:
<ion-button color="primary">Save</ion-button>
<ion-badge color="success">Read</ion-badge>
<ion-toolbar color="dark">...</ion-toolbar>
Each color is defined by six variables, because components need more than one shade (text on top of it, pressed states, transparent backgrounds):
/* src/theme/variables.css: brand primary color */
:root {
--ion-color-primary: #2e5d4b; /* base */
--ion-color-primary-rgb: 46, 93, 75; /* same color as r, g, b for rgba() transparency */
--ion-color-primary-contrast: #ffffff; /* text/icons on top of the base */
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #29523f; /* darker: pressed/active state (~12% darker) */
--ion-color-primary-tint: #436d5d; /* lighter: hover state (~10% lighter) */
}
The Ionic documentation includes a color generator that computes the shade, tint and RGB values from one base color.
Custom colors. To add a new named color (for example favorite), define its variables and a class that maps them to the --ion-color-base family that components read:
:root {
--ion-color-favorite: #c2185b;
--ion-color-favorite-rgb: 194, 24, 91;
--ion-color-favorite-contrast: #ffffff;
--ion-color-favorite-contrast-rgb: 255, 255, 255;
--ion-color-favorite-shade: #ab1550;
--ion-color-favorite-tint: #c82f6b;
}
.ion-color-favorite { /* Ionic adds this class when color="favorite" */
--ion-color-base: var(--ion-color-favorite);
--ion-color-base-rgb: var(--ion-color-favorite-rgb);
--ion-color-contrast: var(--ion-color-favorite-contrast);
--ion-color-contrast-rgb: var(--ion-color-favorite-contrast-rgb);
--ion-color-shade: var(--ion-color-favorite-shade);
--ion-color-tint: var(--ion-color-favorite-tint);
}
<ion-chip color="favorite">Favorite</ion-chip>
Application-wide variables. Beyond colors, global variables set the base look:
| Variable | Affects |
|---|---|
--ion-background-color, --ion-background-color-rgb | Page and content backgrounds |
--ion-text-color, --ion-text-color-rgb | Default text |
--ion-font-family | Font of all components |
--ion-toolbar-background, --ion-toolbar-color | Toolbars |
--ion-item-background, --ion-border-color | List items and separators |
--ion-color-step-50 ... --ion-color-step-950 (or the newer text/background step variables) | Neutral grey steps derived from background and text colors |
Variables can be scoped to a mode or platform with the classes Ionic sets on <html>:
:root { --ion-font-family: 'Inter', system-ui, sans-serif; }
.ios { --ion-toolbar-background: #f7f7f7; } /* only in ios mode */
.md { --ion-toolbar-background: var(--ion-color-primary); --ion-toolbar-color: #fff; }
Component variables and shadow parts. Each component documents its own variables (in the "CSS Custom Properties" section of its API page) and its shadow parts:
/* Rounded, taller buttons via component variables */
ion-button {
--border-radius: 999px;
--padding-start: 1.5rem;
--padding-end: 1.5rem;
--box-shadow: none;
}
/* A list item with a custom background and no inner border */
ion-item.book-item {
--background: var(--ion-color-light);
--inner-border-width: 0;
--min-height: 72px;
}
/* Shadow parts: style an internal element directly when no variable exists */
ion-searchbar::part(icon) { color: var(--ion-color-primary); }
ion-segment-button::part(indicator-background) { height: 3px; }
Dark mode and high contrast. Ionic 8 ships palette stylesheets. Import one variant:
// main.tsx (React) or main.ts (Vue/Angular global styles)
import '@ionic/react/css/palettes/dark.system.css'; // follows the OS setting (prefers-color-scheme)
// import '@ionic/react/css/palettes/dark.always.css'; // always dark
// import '@ionic/react/css/palettes/dark.class.css'; // dark when <html> has .ion-palette-dark
// High-contrast palettes are available in the same folder (high-contrast.*.css, high-contrast-dark.*.css)
The class variant enables a user-controlled toggle:
// Toggle dark mode from a settings screen and remember the choice
import { Preferences } from '@capacitor/preferences';
export async function setDarkMode(enabled: boolean) {
document.documentElement.classList.toggle('ion-palette-dark', enabled);
await Preferences.set({ key: 'darkMode', value: String(enabled) });
}
Utility classes and global CSS. Ionic ships optional CSS utilities (imported in the starter templates): ion-padding, ion-margin, ion-text-center, ion-hide-md-up (responsive display), ion-justify-content-between and others. App-specific global styles go in a global stylesheet; component-scoped styles use your framework's mechanism (CSS modules, Vue scoped styles, Angular component styles), remembering that only variables and parts reach inside Ionic's shadow roots.
Key Takeaways
- Ionic components are styled through CSS custom properties and shadow parts, because Shadow DOM blocks ordinary descendant selectors.
- Nine named colors, each defined by base, RGB, contrast, shade and tint variables; custom colors need the variables plus an
.ion-color-<name>class. - Global variables (
--ion-background-color,--ion-text-color,--ion-font-family, toolbar and item variables) set the overall look, optionally per mode with.ios/.md. - Component variables (
--background,--border-radius) and::part()customize individual components. - Ionic 8 palettes provide system, always-on and class-based dark mode plus high-contrast themes.
Practice
- (Beginner) Apply the bookshelf brand color as
primaryusing the color generator values, and set a custom font for the whole app.- (Intermediate) Add a custom
favoritecolor used by chips and badges, a dark mode toggle in the Profile tab using the class-based palette with the choice persisted through@capacitor/preferences, and verify contrast in both modes.- (Interview) "Why can't you style the inner button of
ion-buttonwith a normal CSS selector, and what are the alternatives?" Hint: explain Shadow DOM encapsulation, then compare CSS custom properties (designed by the component author) and shadow parts (exposed elements).
Live Reload, Building and Distribution
Why a dedicated workflow. An Ionic app ends up in three places: the browser (development and PWA), Android and iOS. The development loop should stay as fast as web development, even when testing native features on a phone, and releasing should be repeatable for all targets.
Development in the browser. Most work happens with the web dev server:
ionic serve # Vite dev server with hot module replacement, opens the browser
ionic serve --external # listen on all interfaces so a phone on the same Wi-Fi can open it
Use the browser's device emulation for layout and ?ionic:mode=ios for the other platform's styles. Capacitor plugins run their web implementations (or throw "not implemented" for native-only ones, which your services should handle).
Live reload on a device. When you need native plugins, run the app on a device or emulator but load the web code from your dev server, so changes appear instantly without rebuilding the native app:
ionic cap run android -l --external # -l: live reload; --external: use the machine's LAN IP
ionic cap run ios -l --external
Under the hood, this temporarily sets server.url in the Capacitor configuration to the dev server's address (http://192.168.x.x:8100). The phone and computer must be on the same network, and the dev server must not be blocked by a firewall. Never ship a build with server.url set: the app would try to load your laptop's address.
Development with live reload Production build
+--------------------+ +--------------------+
| Phone (native app) | http://192.168.1.20 | Phone (native app) |
| WebView ----------+----------> dev server | WebView ----------+--> bundled assets
| native plugins | (Vite, HMR) | native plugins | (https://localhost,
+--------------------+ +--------------------+ capacitor://localhost)
Building for each target.
# Web / PWA
ionic build # production build into dist/ (or www/ for Angular)
# deploy dist/ to any static host or CDN; add a manifest and service worker (vite-plugin-pwa,
# or @angular/pwa for Angular) to make it a PWA
# Native
ionic build && npx cap sync # equivalently: ionic cap sync (builds and syncs)
npx cap open android # Android Studio: Build > Generate Signed App Bundle
npx cap open ios # Xcode: Product > Archive > Distribute App
npx cap run android --target=<device-id>
Signing, store listings and review rules are the same as in 15.4 (Packaging and Store Deployment): Android App Bundles signed with an upload key for Google Play, archives signed with a distribution certificate for App Store Connect and TestFlight.
Icons and splash screens. Generate all sizes from one source image with @capacitor/assets:
# Place assets/icon-only.png (1024x1024), assets/icon-foreground.png, assets/icon-background.png,
# assets/splash.png and assets/splash-dark.png, then:
npx @capacitor/assets generate --iconBackgroundColor '#2e5d4b' --splashBackgroundColor '#2e5d4b'
# Writes Android adaptive icons, iOS app icons, splash screens and PWA icons
Environment configuration. Use build-time environment variables for API URLs, and make sure native builds point to the production API:
// src/config.ts (Vite)
export const API_BASE = import.meta.env.VITE_API_BASE ?? 'https://api.bookshelf.example';
// .env.development: VITE_API_BASE=http://192.168.1.20:3000 (reachable from the phone)
// .env.production: VITE_API_BASE=https://api.bookshelf.example
Live updates. Native store releases take time (review can take a day or more). Live update services deliver new web assets (HTML, CSS, JavaScript) to installed apps without a store release, which is valuable for urgent fixes. Store rules allow this only for changes that do not alter the app's primary purpose, and native code changes (new plugins, permissions) still require a store release. Ionic's commercial Appflow service offered live updates and cloud builds; Ionic announced in 2025 that it is winding down its commercial products, including Appflow, so new projects typically use alternatives such as Capawesome Cloud, self-hosted update plugins, or CI pipelines with fastlane for builds.
A release pipeline.
git tag v1.4.0
|
v
CI (GitHub Actions: Linux runner for web + Android, macOS runner for iOS)
|-- npm ci, lint, unit tests, ionic build
|-- web: deploy dist/ to static hosting (PWA)
|-- android: npx cap sync android -> Gradle bundleRelease (signed) -> Play internal track
|-- ios: npx cap sync ios -> fastlane (match for signing, gym to build, pilot to TestFlight)
v
Manual promotion: internal testing -> beta -> production in each store
Versioning: keep the web app version, Android versionName/versionCode (in android/app/build.gradle) and iOS MARKETING_VERSION/CURRENT_PROJECT_VERSION (in Xcode) in sync, ideally with a script run by CI.
Key Takeaways
- Develop in the browser with
ionic serve; useionic cap run <platform> -l --externalfor live reload on devices with native plugins. - Live reload works by pointing
server.urlat the dev server; never ship a build with it set. ionic buildproduces the web bundle for PWA hosting;ionic cap syncand the native IDEs produce signed store builds.- Generate icons and splash screens with
@capacitor/assets, and use environment variables for API URLs per build. - Live updates can ship web-only fixes within store rules; automate builds and store uploads with CI and fastlane.
Practice
- (Beginner) Run the bookshelf Ionic app on an Android device with live reload, change a label in the Library page, and observe the update without rebuilding.
- (Intermediate) Configure separate development and production API URLs, generate icons and splash screens from one source image, and produce a signed Android App Bundle and a PWA build from the same commit.
- (Interview) "What can and cannot be delivered through a live update to an installed hybrid app?" Hint: separate web assets from native code and configuration (plugins, permissions,
Info.plist, manifest), then consider what app store guidelines allow.
<a id="16-cross-platform-web-based-native-applications"></a>