diff --git a/src/assets/javascripts/application.js b/src/assets/javascripts/application.js
index e9cae4b5c9ca71ab55f1365509bedd4eeb653f79..63621d3342a7a6722ccca1e5c90743c255ca522d 100644
--- a/src/assets/javascripts/application.js
+++ b/src/assets/javascripts/application.js
@@ -257,9 +257,9 @@ function initialize(config) { // eslint-disable-line func-style
         "[data-md-component=header]")))
 
   /* Component: link blurring for table of contents */
-  new Material.Event.MatchMedia("(min-width: 960px)",
-    new Material.Event.Listener(window, "scroll",
-      new Material.Nav.Blur("[data-md-component=toc] [href]")))
+  // new Material.Event.MatchMedia("(min-width: 960px)",
+  //   new Material.Event.Listener(window, "scroll",
+  //     new Material.Nav.Blur("[data-md-component=toc] [href]")))
 
   /* Component: collapsible elements for navigation */
   const collapsibles =
diff --git a/src/assets/javascripts/base/component.ts b/src/assets/javascripts/base/component.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b5d2b001ed8c95f34e892083cc90e19db3125319
--- /dev/null
+++ b/src/assets/javascripts/base/component.ts
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2016-2018 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 { Observable } from "rxjs/Observable"
+
+import "rxjs/add/observable/empty"
+import "rxjs/add/observable/of"
+import "rxjs/add/operator/scan"
+import "rxjs/add/operator/switchMap"
+
+import { merge } from "lodash"
+
+import { Toggle } from "./toggle"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Component state
+ */
+export interface ComponentState {
+  active: boolean                      /* Whether the component is active */
+}
+
+/**
+ * Component lifecycle hooks
+ */
+export interface Component<
+  T extends ComponentState,            /* Component state */
+  V                                    /* Component value */
+> {
+  initial: T                           /* Initial state */
+  create: (state: T) => T              /* Creation hook */
+  destroy: (state: T) => T             /* Destruction hook */
+  update: (state: T) => Observable<
+    Readonly<V>
+  >                                    /* Update hook */
+}
+
+/* ----------------------------------------------------------------------------
+ * Lifecycle hooks
+ * ------------------------------------------------------------------------- */
+
+/**
+ * No-op creation lifecycle hook
+ *
+ * @param state - Component state
+ *
+ * @return Altered state
+ */
+export function create<T extends ComponentState>(state: T) {
+  return state
+}
+
+/**
+ * No-op destruction lifecycle hook
+ *
+ * @param state - Component state
+ *
+ * @return Altered state
+ */
+export function destroy<T extends ComponentState>(state: T) {
+  return state
+}
+
+/**
+ * No-op update lifecycle hook
+ *
+ * @param state - Component state
+ *
+ * @return Altered state
+ */
+export function update<T extends ComponentState, V>(state: T) {
+  return Observable.empty<V>()
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Bind a component's lifecycle to a toggle
+ *
+ * @param component - Component
+ * @param toggle$ - Toggle observable
+ *
+ * @return Component observable
+ */
+export function bind<T extends ComponentState, V>(
+  component: Component<T, V>, toggle$?: Observable<Toggle>
+) {
+  const { initial, create: c, destroy: d, update: u } = component
+  return (toggle$ || Observable.of({ active: true }))
+
+    /* Initialize component and handle state change */
+    .scan<Toggle, T>((state, { active }) => {
+      return (active ? c : d)(merge(state, { active }))
+    }, initial)
+
+    /* Return update component lifecycle hook */
+    .switchMap(state => {
+      return state.active ? u(state) : Observable.empty<V>()
+    })
+}
diff --git a/src/assets/javascripts/base/toggle.ts b/src/assets/javascripts/base/toggle.ts
new file mode 100644
index 0000000000000000000000000000000000000000..43d8ea6334afb387f05225bf9ff99d0d704a2777
--- /dev/null
+++ b/src/assets/javascripts/base/toggle.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2016-2018 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.
+ */
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Toggle
+ */
+export interface Toggle {
+  active: boolean                      /* Whether the toggle is active */
+}
diff --git a/src/assets/javascripts/components/nav-blur.ts b/src/assets/javascripts/components/nav-blur.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5164c3f850f80bb0bd7ffc5791c957a20fc533f7
--- /dev/null
+++ b/src/assets/javascripts/components/nav-blur.ts
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2016-2018 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 { Observable } from "rxjs/Observable"
+
+import "rxjs/add/observable/empty"
+import "rxjs/add/observable/of"
+import "rxjs/add/operator/switchMap"
+
+import {
+  Component,
+  ComponentState,
+  create,
+  destroy
+} from "../base/component"
+import * as viewport from "../device/viewport"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Component state
+ */
+export interface NavBlurState extends ComponentState {
+  anchors: HTMLAnchorElement[]         /* Anchor elements */
+  targets: HTMLElement[]               /* Target elements */
+  index: number                        /* Current index */
+  offset: number                       /* Current offset */
+  direction: boolean                   /* Current scroll direction */
+}
+
+/**
+ * Component value
+ */
+export interface NavBlur {
+  anchor: HTMLAnchorElement            /* Active anchor */
+  target: HTMLElement                  /* Active target */
+}
+
+/* ----------------------------------------------------------------------------
+ * Values
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Initial values for component state
+ */
+export const initial: NavBlurState = {
+  active: false,                       /* Whether the component is active */
+  anchors: [],                         /* Anchor elements */
+  targets: [],                         /* Target elements */
+  index: 0,                            /* Current index */
+  offset: 0,                           /* Current offset */
+  direction: false                     /* Current scroll direction */
+}
+
+/* ----------------------------------------------------------------------------
+ * Lifecycle hooks
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Blur links within the table of contents above current page y-offset
+ *
+ * @param state - Component state
+ *
+ * @return Altered state
+ */
+export function update(state: NavBlurState) {
+  const { anchors, targets } = state
+  return viewport.offset()
+    .switchMap(({ y }) => {
+      const direction = (state.offset - y < 0)
+      const index = state.index
+
+      /* Exit when there are no anchors/targets */
+      if (targets.length === 0)
+        return Observable.empty<NavBlur>()
+
+      /* Hack: reset index if direction changed to catch very fast scrolling,
+         because otherwise we would have to register a timer and that sucks */
+      if (state.direction !== direction)
+        state.index = direction
+          ? state.index = 0
+          : state.index = anchors.length - 1
+
+      /* Scroll direction is down */
+      if (state.offset <= y) {
+        for (let i = state.index + 1; i < anchors.length; i++) {
+          if (targets[i].offsetTop - (56 + 24) <= y) {                          // TODO: Refactor value determination
+            if (i > 0)
+              anchors[i - 1].dataset.mdState = "blur"
+            state.index = i
+          } else {
+            break
+          }
+        }
+
+      /* Scroll direction is up */
+      } else {
+        for (let i = state.index; i >= 0; i--) {
+          if (targets[i].offsetTop - (56 + 24) > y) {
+            if (i > 0)
+              anchors[i - 1].dataset.mdState = ""
+          } else {
+            state.index = i
+            break
+          }
+        }
+      }
+
+      /* Remember current offset and direction for next iteration */
+      state.offset = y
+      state.direction = direction
+
+      /* Emit current anchor and target if index changed */
+      return state.index !== index
+        ? Observable.of<NavBlur>({
+          anchor: anchors[state.index],
+          target: targets[state.index]
+        })
+        : Observable.empty<NavBlur>()
+    })
+}
+
+/**
+ * Component factory method
+ *
+ * @param els - Anchor elements
+ *
+ * @return Component
+ */
+export function factory(
+  els: ArrayLike<HTMLAnchorElement>
+): Component<NavBlurState, NavBlur> {
+  const anchors = Array.from(els)
+  const targets: HTMLElement[] = anchors.reduce((result, anchor) => [
+    ...result, document.getElementById(anchor.hash.substring(1)) || []
+  ], [])
+  return {
+    initial: { ...initial, anchors, targets }, create, destroy, update
+  }
+}
diff --git a/src/assets/javascripts/device/breakpoint.ts b/src/assets/javascripts/device/breakpoint.ts
index 780ca8d053a1258cc18e0ebe0839ee32e0939cc9..041da8295c7f7b5eaafae1e2aad122422c6d4ac1 100644
--- a/src/assets/javascripts/device/breakpoint.ts
+++ b/src/assets/javascripts/device/breakpoint.ts
@@ -25,6 +25,8 @@ import { Observable } from "rxjs/Observable"
 
 import "rxjs/add/observable/fromEventPattern"
 
+import { Toggle } from "../base/toggle"
+
 /* ----------------------------------------------------------------------------
  * Functions
  * ------------------------------------------------------------------------- */
@@ -35,10 +37,10 @@ import "rxjs/add/observable/fromEventPattern"
  * @param media - Media query
  */
 export function query(media: MediaQueryList) {
-  const media$ = new BehaviorSubject<boolean>(media.matches)
-  Observable.fromEventPattern<boolean>(next =>
+  const media$ = new BehaviorSubject<Toggle>({ active: media.matches })
+  Observable.fromEventPattern<Toggle>(next =>
     media.addListener(
-      (mq: MediaQueryList) => next(mq.matches)
+      (mq: MediaQueryList) => next({ active: mq.matches })
     ))
     .subscribe(media$)
   return media$
diff --git a/tests/unit/device/breakpoint.spec.ts b/tests/unit/device/breakpoint.spec.ts
index ed0a7bfe29abae3460761185f7c0ca51114e20bc..bcaf6909fcc6cda3e8e901c3479e864458d18e8e 100644
--- a/tests/unit/device/breakpoint.spec.ts
+++ b/tests/unit/device/breakpoint.spec.ts
@@ -56,8 +56,8 @@ describe("device", () => {
       /* Media query for testing purposes */
       const media = window.matchMedia("(min-width: 480px)")
 
-      /* Test: it should return a behavior subject */
-      it("should return a behavior subject", () => {
+      /* Test: it should return a subject */
+      it("should return a subject", () => {
         const media$ = query(media)
         expect(media$).toEqual(jasmine.any(Subject))
       })
@@ -67,8 +67,8 @@ describe("device", () => {
         const media$ = query(media)
         media$
           .take(1)
-          .subscribe(initial => {
-            expect(initial).toEqual(true)
+          .subscribe(({ active }) => {
+            expect(active).toEqual(true)
             done()
           }, done.fail)
       })
@@ -80,8 +80,8 @@ describe("device", () => {
           .take(2)
           .toArray()
           .subscribe(([initial, changed]) => {
-            expect(initial).toEqual(true)
-            expect(changed).toEqual(false)
+            expect(initial.active).toEqual(true)
+            expect(changed.active).toEqual(false)
             done()
           }, done.fail)
         requestAnimationFrame(() => {
@@ -93,8 +93,8 @@ describe("device", () => {
     /* Create a subject for a minimum media query */
     describe(".from", () => {
 
-      /* Test: it should return a behavior subject */
-      it("should return a behavior subject", () => {
+      /* Test: it should return a subject */
+      it("should return a subject", () => {
         const media$ = from(480)
         expect(media$).toEqual(jasmine.any(Subject))
       })
@@ -104,8 +104,8 @@ describe("device", () => {
         const media$ = from(480)
         media$
           .take(1)
-          .subscribe(initial => {
-            expect(initial).toEqual(true)
+          .subscribe(({ active }) => {
+            expect(active).toEqual(true)
             done()
           }, done.fail)
       })
@@ -117,8 +117,8 @@ describe("device", () => {
           .take(2)
           .toArray()
           .subscribe(([initial, changed]) => {
-            expect(initial).toEqual(true)
-            expect(changed).toEqual(false)
+            expect(initial.active).toEqual(true)
+            expect(changed.active).toEqual(false)
             done()
           }, done.fail)
         requestAnimationFrame(() => {
@@ -130,8 +130,8 @@ describe("device", () => {
     /* Create a subject for a maximum media query */
     describe(".to", () => {
 
-      /* Test: it should return a behavior subject */
-      it("should return a behavior subject", () => {
+      /* Test: it should return a subject */
+      it("should return a subject", () => {
         const media$ = to(479)
         expect(media$).toEqual(jasmine.any(Subject))
       })
@@ -141,8 +141,8 @@ describe("device", () => {
         const media$ = to(479)
         media$
           .take(1)
-          .subscribe(initial => {
-            expect(initial).toEqual(false)
+          .subscribe(({ active }) => {
+            expect(active).toEqual(false)
             done()
           }, done.fail)
       })
@@ -154,8 +154,8 @@ describe("device", () => {
           .take(2)
           .toArray()
           .subscribe(([initial, changed]) => {
-            expect(initial).toEqual(false)
-            expect(changed).toEqual(true)
+            expect(initial.active).toEqual(false)
+            expect(changed.active).toEqual(true)
             done()
           }, done.fail)
         requestAnimationFrame(() => {
diff --git a/tests/unit/device/viewport.spec.ts b/tests/unit/device/viewport.spec.ts
index 49fdcc1cad3801540ffae9cfc6f2a4ad286aec98..b8dd44aa8b6c8a427d104aca2b7adfe3b3985612 100644
--- a/tests/unit/device/viewport.spec.ts
+++ b/tests/unit/device/viewport.spec.ts
@@ -55,8 +55,8 @@ describe("device", () => {
     /* Create a subject emitting changes in viewport offset */
     describe(".offset", () => {
 
-      /* Test: it should return a behavior subject */
-      it("should return a behavior subject", () => {
+      /* Test: it should return a subject */
+      it("should return a subject", () => {
         const offset$ = offset()
         expect(offset$).toEqual(jasmine.any(Subject))
       })
@@ -99,8 +99,8 @@ describe("device", () => {
     /* Create a subject emitting changes in viewport size */
     describe(".size", () => {
 
-      /* Test: it should return a behavior subject */
-      it("should return a behavior subject", () => {
+      /* Test: it should return a subject */
+      it("should return a subject", () => {
         const size$ = size()
         expect(size$).toEqual(jasmine.any(Subject))
       })
diff --git a/tsconfig.json b/tsconfig.json
index 9b6438a16e4bcd79287887e0e7ab2e43768fbee3..b4761f04a1fee05465ea8e70611766ca029297cb 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -25,6 +25,7 @@
     "removeComments": true,
     "sourceMap": true,
     "strictFunctionTypes": true,
+    "strictNullChecks": true,
     "stripInternal": true,
     "target": "es5"
   },