diff --git a/src/assets/javascripts/device/viewport.ts b/src/assets/javascripts/device/viewport.ts
index bc7a78e221bf73be55cab01d55f9115faa515c81..dee0f361fecfc3d3aeb12fe09dc9ac3e01ab02ba 100644
--- a/src/assets/javascripts/device/viewport.ts
+++ b/src/assets/javascripts/device/viewport.ts
@@ -24,9 +24,12 @@ import { BehaviorSubject } from "rxjs/BehaviorSubject"
 import { Observable } from "rxjs/Observable"
 
 import "rxjs/add/observable/fromEvent"
+import "rxjs/add/operator/distinctUntilChanged"
 import "rxjs/add/operator/map"
 import "rxjs/add/operator/merge"
 
+import { isEqual, isUndefined } from "lodash"
+
 /* ----------------------------------------------------------------------------
  * Types
  * ------------------------------------------------------------------------- */
@@ -52,14 +55,18 @@ export interface ViewportSize {
  * ------------------------------------------------------------------------- */
 
 /**
- * Observable for window scroll events
+ * Create an observable for window scroll events
+ *
+ * @return Observable
  */
-export const scroll$ = Observable.fromEvent<UIEvent>(window, "scroll")
+const scroll = () => Observable.fromEvent<UIEvent>(window, "scroll")
 
 /**
- * Observable for window resize events
+ * Create an observable for window resize events
+ *
+ * @return Observable
  */
-export const resize$ = Observable.fromEvent<UIEvent>(window, "resize")
+const resize = () => Observable.fromEvent<UIEvent>(window, "resize")
 
 /* ----------------------------------------------------------------------------
  * Functions
@@ -79,10 +86,7 @@ export const resize$ = Observable.fromEvent<UIEvent>(window, "resize")
  * leading to a duplicate emission. At the time of writing, this can be
  * observed in Chrome and Firefox, while Safari seems to handle it correctly.
  *
- * Browser environments can be pretty unpredictable, so it's best to always
- * create a fresh observable when necessary, e.g. when a breakpoint is hit,
- * and to filter for duplicate values (or just accept that it could happen).
- * For decision, context is necessary, so it must be done by the caller.
+ * For this reason we only emit consecutive distinct values.
  *
  * @return Subject
  */
@@ -91,14 +95,15 @@ export function offset() {
     x: window.pageXOffset,
     y: window.pageYOffset
   })
-  scroll$
-    .merge(resize$)
+  scroll()
+    .merge(resize())
     .map<UIEvent, ViewportOffset>(() => ({
       x: window.pageXOffset,
       y: window.pageYOffset
     }))
     .subscribe(subject$)
   return subject$
+    .distinctUntilChanged((x, y) => !isUndefined(y) && isEqual(x, y))
 }
 
 /**
@@ -113,11 +118,12 @@ export function size() {
     width: window.innerWidth,
     height: window.innerHeight
   })
-  resize$
+  resize()
     .map<UIEvent, ViewportSize>(() => ({
       width: window.innerWidth,
       height: window.innerHeight
     }))
     .subscribe(subject$)
   return subject$
+    .distinctUntilChanged((x, y) => !isUndefined(y) && isEqual(x, y))
 }
diff --git a/tests/karma.conf.ts b/tests/karma.conf.ts
index e807988505f1dd10222ca3a4ad2dcf9953704755..bf4fea8f9e273020b9598e71d05b1979f0bdb7f7 100644
--- a/tests/karma.conf.ts
+++ b/tests/karma.conf.ts
@@ -127,7 +127,8 @@ export default (config: KarmaConfig & KarmaConfigOptions) => {
 
     /* Configuration for spec reporter */
     specReporter: {
-      suppressSkipped: true
+      suppressErrorSummary: true,
+      suppressSkipped: !config.singleRun
     },
 
     /* Configuration for coverage reporter */
diff --git a/tests/unit/device/breakpoint.spec.ts b/tests/unit/device/breakpoint.spec.ts
index 23dac9dd042fa0d29e5fdae4c90c053cdc8f81a0..ed0a7bfe29abae3460761185f7c0ca51114e20bc 100644
--- a/tests/unit/device/breakpoint.spec.ts
+++ b/tests/unit/device/breakpoint.spec.ts
@@ -20,7 +20,7 @@
  * IN THE SOFTWARE.
  */
 
-import { BehaviorSubject } from "rxjs/BehaviorSubject"
+import { Subject } from "rxjs/Subject"
 
 import "rxjs/add/operator/take"
 import "rxjs/add/operator/toArray"
@@ -37,9 +37,12 @@ describe("device", () => {
   /* Breakpoint observable factories */
   describe("breakpoint", () => {
 
-    /* Reset viewport after each test */
-    beforeEach(() => {
-      viewport.set(400)
+    /* Set viewport in next animation frame to force rendering */
+    beforeEach(done => {
+      requestAnimationFrame(() => {
+        viewport.set(480, 320)
+        done()
+      })
     })
 
     /* Reset viewport after each test */
@@ -51,12 +54,12 @@ describe("device", () => {
     describe(".query", () => {
 
       /* Media query for testing purposes */
-      const media = window.matchMedia("(min-width: 400px)")
+      const media = window.matchMedia("(min-width: 480px)")
 
       /* Test: it should return a behavior subject */
       it("should return a behavior subject", () => {
         const media$ = query(media)
-        expect(media$).toEqual(jasmine.any(BehaviorSubject))
+        expect(media$).toEqual(jasmine.any(Subject))
       })
 
       /* Test: it should emit on subscription immediately */
@@ -64,8 +67,8 @@ describe("device", () => {
         const media$ = query(media)
         media$
           .take(1)
-          .subscribe(active => {
-            expect(active).toBeTruthy()
+          .subscribe(initial => {
+            expect(initial).toEqual(true)
             done()
           }, done.fail)
       })
@@ -73,15 +76,17 @@ describe("device", () => {
       /* Test: it should emit on media query status change */
       it("should emit on media query status change", done => {
         const media$ = query(media)
-        viewport.set(399)
         media$
           .take(2)
           .toArray()
           .subscribe(([initial, changed]) => {
-            expect(initial).toBeTruthy()
-            expect(changed).toBeFalsy()
+            expect(initial).toEqual(true)
+            expect(changed).toEqual(false)
             done()
           }, done.fail)
+        requestAnimationFrame(() => {
+          viewport.set(479)
+        })
       })
     })
 
@@ -90,67 +95,72 @@ describe("device", () => {
 
       /* Test: it should return a behavior subject */
       it("should return a behavior subject", () => {
-        const media$ = from(400)
-        expect(media$).toEqual(jasmine.any(BehaviorSubject))
+        const media$ = from(480)
+        expect(media$).toEqual(jasmine.any(Subject))
       })
 
       /* Test: it should emit on subscription immediately */
       it("should emit on subscription immediately", done => {
-        const media$ = from(400)
+        const media$ = from(480)
         media$
           .take(1)
-          .subscribe(active => {
-            expect(active).toBeTruthy()
+          .subscribe(initial => {
+            expect(initial).toEqual(true)
             done()
           }, done.fail)
       })
 
       /* Test: it should emit on media query status change */
       it("should emit on media query status change", done => {
-        const media$ = from(400)
-        viewport.set(399)
+        const media$ = from(480)
         media$
           .take(2)
           .toArray()
           .subscribe(([initial, changed]) => {
-            expect(initial).toBeTruthy()
-            expect(changed).toBeFalsy()
+            expect(initial).toEqual(true)
+            expect(changed).toEqual(false)
             done()
           }, done.fail)
+        requestAnimationFrame(() => {
+          viewport.set(479)
+        })
       })
     })
 
-    /* Create a subject for a minimum media query */
+    /* Create a subject for a maximum media query */
     describe(".to", () => {
 
       /* Test: it should return a behavior subject */
       it("should return a behavior subject", () => {
-        const media$ = to(399)
-        expect(media$).toEqual(jasmine.any(BehaviorSubject))
+        const media$ = to(479)
+        expect(media$).toEqual(jasmine.any(Subject))
       })
 
       /* Test: it should emit on subscription immediately */
       it("should emit on subscription immediately", done => {
-        const media$ = to(399)
+        const media$ = to(479)
         media$
-          .take(1).subscribe(active => {
-            expect(active).toBeFalsy()
+          .take(1)
+          .subscribe(initial => {
+            expect(initial).toEqual(false)
             done()
           }, done.fail)
       })
 
       /* Test: it should emit on media query status change */
       it("should emit on media query status change", done => {
-        const media$ = to(399)
-        viewport.set(399)
+        const media$ = to(479)
         media$
           .take(2)
           .toArray()
           .subscribe(([initial, changed]) => {
-            expect(initial).toBeFalsy()
-            expect(changed).toBeTruthy()
+            expect(initial).toEqual(false)
+            expect(changed).toEqual(true)
             done()
           }, done.fail)
+        requestAnimationFrame(() => {
+          viewport.set(479)
+        })
       })
     })
   })
diff --git a/tests/unit/device/viewport.spec.ts b/tests/unit/device/viewport.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..49fdcc1cad3801540ffae9cfc6f2a4ad286aec98
--- /dev/null
+++ b/tests/unit/device/viewport.spec.ts
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2017 Martin Donath <martin.donath@squidfunk.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ * sell copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ * IN THE SOFTWARE.
+ */
+
+import { Subject } from "rxjs/Subject"
+
+import "rxjs/add/operator/take"
+import "rxjs/add/operator/toArray"
+
+import { offset, size } from "~/device/viewport"
+
+/* ----------------------------------------------------------------------------
+ * Tests
+ * ------------------------------------------------------------------------- */
+
+/* Device utilities */
+describe("device", () => {
+
+  /* Viewport observable factories */
+  describe("viewport", () => {
+
+    /* Set viewport in next animation frame to force rendering */
+    beforeEach(done => {
+      requestAnimationFrame(() => {
+        document.body.style.minHeight = `${window.innerHeight * 2}px`
+        viewport.set(480, 320)
+        done()
+      })
+    })
+
+    /* Reset viewport after each test */
+    afterEach(() => {
+      document.body.style.minHeight = undefined
+      viewport.reset()
+    })
+
+    /* Create a subject emitting changes in viewport offset */
+    describe(".offset", () => {
+
+      /* Test: it should return a behavior subject */
+      it("should return a behavior subject", () => {
+        const offset$ = offset()
+        expect(offset$).toEqual(jasmine.any(Subject))
+      })
+
+      /* Test: it should emit on subscription immediately */
+      it("should emit on subscription immediately", done => {
+        const offset$ = offset()
+        offset$
+          .take(1)
+          .subscribe(initial => {
+            expect(initial).toEqual({
+              x: window.pageXOffset,
+              y: window.pageYOffset
+            })
+            done()
+          }, done.fail)
+      })
+
+      /* Test: it should emit on viewport offset change */
+      it("should emit on viewport offset change", done => {
+        const offset$ = offset()
+        offset$
+          .take(2)
+          .toArray()
+          .subscribe(([initial, changed]) => {
+            expect(initial).toEqual({
+              x: 0,
+              y: 0
+            })
+            expect(changed).toEqual({
+              x: 0,
+              y: 1
+            })
+            done()
+          }, done.fail)
+        window.scrollTo(0, 1)
+      })
+    })
+
+    /* Create a subject emitting changes in viewport size */
+    describe(".size", () => {
+
+      /* Test: it should return a behavior subject */
+      it("should return a behavior subject", () => {
+        const size$ = size()
+        expect(size$).toEqual(jasmine.any(Subject))
+      })
+
+      /* Test: it should emit on subscription immediately */
+      it("should emit on subscription immediately", done => {
+        const size$ = size()
+        size$
+          .take(1)
+          .subscribe(initial => {
+            expect(initial).toEqual({
+              width: window.innerWidth,
+              height: window.innerHeight
+            })
+            done()
+          }, done.fail)
+      })
+
+      /* Test: it should emit on viewport size change */
+      it("should emit on viewport size change", done => {
+        const size$ = size()
+        size$
+          .take(2)
+          .toArray()
+          .subscribe(([initial, changed]) => {
+            expect(initial).toEqual({
+              width: 480,
+              height: 320
+            })
+            expect(changed).toEqual({
+              width: 479,
+              height: 319
+            })
+            done()
+          }, done.fail)
+        viewport.set(479, 319)
+      })
+    })
+  })
+})
diff --git a/typings/globals.d.ts b/typings/globals.d.ts
index 45c4fcf0f3f57a418d481fff688d78ade407d7d6..f81b695ab85c214692e34d032bc0e842772377ae 100644
--- a/typings/globals.d.ts
+++ b/typings/globals.d.ts
@@ -22,7 +22,7 @@
 
 declare module "*.json" {
   const value: any
-  export default value
+  export = value
 }
 
 declare const viewport: any            /* karma-viewport */
diff --git a/typings/karma.d.ts b/typings/karma.d.ts
index 36ccdc26ad47b105d75a0034643d206cf8470a6b..5ef7db3eb2b82ca301c1fd890041ef0be0c712ff 100644
--- a/typings/karma.d.ts
+++ b/typings/karma.d.ts
@@ -30,6 +30,7 @@ declare module "karma" {
   interface ConfigOptions {
     webpack?: WebpackConfig            /* karma-webpack */
     specReporter?: {                   /* karma-spec-reporter */
+      suppressErrorSummary: boolean
       suppressSkipped: boolean
     }
     coverageIstanbulReporter?: {       /* karma-coverage */