diff --git a/build-system/dep-check-config.js b/build-system/dep-check-config.js
index 725c835d2c77..188ed725750a 100644
--- a/build-system/dep-check-config.js
+++ b/build-system/dep-check-config.js
@@ -157,6 +157,8 @@ exports.rules = [
'extensions/amp-youtube/0.1/amp-youtube.js->' +
'src/service/video-manager-impl.js',
'extensions/amp-a4a/0.1/amp-a4a.js->src/service/variable-source.js',
+ 'extensions/amp-fx-parallax/0.1/amp-fx-parallax.js->' +
+ 'src/service/parallax-impl.js',
],
},
{
diff --git a/examples/article-parallax.amp.html b/examples/article-parallax.amp.html
new file mode 100644
index 000000000000..c41b03111bd7
--- /dev/null
+++ b/examples/article-parallax.amp.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus
+ luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse
+ potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin
+ felis aliquam tortor vulputate, ac posuere velit semper.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus
+ luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse
+ potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin
+ felis aliquam tortor vulputate, ac posuere velit semper.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus
+ luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse
+ potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin
+ felis aliquam tortor vulputate, ac posuere velit semper.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus
+ luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse
+ potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin
+ felis aliquam tortor vulputate, ac posuere velit semper.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus
+ luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse
+ potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin
+ felis aliquam tortor vulputate, ac posuere velit semper.
+
+
+
+
diff --git a/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js b/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js
new file mode 100644
index 000000000000..1da4533973f3
--- /dev/null
+++ b/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js
@@ -0,0 +1,24 @@
+/**
+ * Copyright 2017 The AMP HTML Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS-IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {ampdocServiceFor} from '../../../src/ampdoc';
+import {installParallaxForDoc} from '../../../src/service/parallax-impl';
+import {onDocumentReady} from '../../../src/document-ready';
+
+const ampdoc = ampdocServiceFor(AMP.win).getAmpDoc();
+onDocumentReady(ampdoc.win.document, () => {
+ installParallaxForDoc(ampdoc.getRootNode());
+});
diff --git a/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js b/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js
new file mode 100644
index 000000000000..74fe98d39a8b
--- /dev/null
+++ b/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js
@@ -0,0 +1,202 @@
+/**
+ * Copyright 2017 The AMP HTML Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS-IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {createIframePromise} from '../../../../testing/iframe';
+import {installParallaxForDoc} from '../../../../src/service/parallax-impl';
+import {parallaxForDoc} from '../../../../src/parallax';
+import {toggleExperiment} from '../../../../src/experiments';
+import {viewportForDoc} from '../../../../src/viewport';
+import {vsyncFor} from '../../../../src/vsync';
+
+describes.sandboxed('amp-fx-parallax', {}, () => {
+ const DEFAULT_FACTOR = 1.7;
+
+ function addTextChildren(iframe) {
+ return [iframe.doc.createTextNode('AMP: Accelerated Mobile Pages')];
+ }
+
+ function getAmpParallaxElement(opt_childrenCallback, opt_factor, opt_top) {
+ const factor = opt_factor || DEFAULT_FACTOR;
+ const top = opt_top || 0;
+ let viewport;
+ let parallaxElement;
+
+ return createIframePromise().then(iframe => {
+ const bodyResizer = iframe.doc.createElement('div');
+ bodyResizer.style.height = '4000px';
+ bodyResizer.style.width = '1px';
+ iframe.doc.body.appendChild(bodyResizer);
+
+ viewport = viewportForDoc(iframe.win.document);
+ viewport.resize_();
+
+ toggleExperiment(iframe.win, 'amp-fx-parallax', true);
+
+ parallaxElement = iframe.doc.createElement('div');
+ parallaxElement.setAttribute('amp-fx-parallax', factor);
+ if (opt_childrenCallback) {
+ const children = opt_childrenCallback(iframe, parallaxElement);
+ children.forEach(child => {
+ parallaxElement.appendChild(child);
+ });
+ }
+
+ const parent = iframe.doc.querySelector('#parent');
+ parent.appendChild(parallaxElement);
+ installParallaxForDoc(iframe.doc);
+
+ return new Promise(resolve => {
+ vsyncFor(iframe.win).mutate(() => {
+ resolve({
+ element: parallaxElement,
+ iframe,
+ viewport,
+ });
+ });
+ viewport.setScrollTop(top);
+ });
+ }).catch(error => {
+ return Promise.reject({error, parallaxElement, stack: error.stack});
+ });
+ }
+
+ it('should move when the user scrolls, if visible', () => {
+ const scroll = 10;
+ const expectedParallax = -1 * DEFAULT_FACTOR * scroll;
+
+ return getAmpParallaxElement(addTextChildren)
+ .then(({element, iframe, viewport}) => {
+ const parallaxService = parallaxForDoc(iframe.doc);
+ const top = element.getBoundingClientRect().top;
+ expect(top).to.equal(viewport.getScrollTop());
+
+ return new Promise(resolve => {
+ parallaxService.addScrollListener_(() => {
+ const top = element.getBoundingClientRect().top;
+ expect(top).to.equal(expectedParallax);
+ resolve();
+ });
+ viewport.setScrollTop(scroll);
+ });
+ });
+ });
+
+ it('should not move after it is outside of the viewport', () => {
+ const scroll = 100;
+ const expectedParallax = -1 * DEFAULT_FACTOR * scroll;
+
+ return getAmpParallaxElement(addTextChildren, DEFAULT_FACTOR)
+ .then(({element, iframe, viewport}) => {
+ const parallaxService = parallaxForDoc(iframe.doc);
+
+ return new Promise(resolve => {
+ parallaxService.addScrollListener_(() => {
+ const top = element.getBoundingClientRect().top;
+ expect(top).to.not.equal(expectedParallax);
+ resolve();
+ });
+ viewport.setScrollTop(scroll);
+ });
+ });
+ });
+
+ it('should move downward with a negative parallax factor', () => {
+ const scroll = 10;
+ const expectedParallax = -1 * DEFAULT_FACTOR * scroll;
+
+ return getAmpParallaxElement(addTextChildren, DEFAULT_FACTOR)
+ .then(({element, iframe, viewport}) => {
+ const parallaxService = parallaxForDoc(iframe.doc);
+ return new Promise(resolve => {
+ parallaxService.addScrollListener_(() => {
+ const top = element.getBoundingClientRect().top;
+ expect(top).to.equal(expectedParallax);
+ resolve();
+ });
+ viewport.setScrollTop(scroll);
+ });
+ });
+ });
+
+ it('should apply multiple scrolls as if they were one large scroll', () => {
+ const scroll = 10;
+ const factor = -1.7; // move downward so it stays in the viewport
+ const expectedParallax = -1 * factor * 2 * scroll;
+
+ return getAmpParallaxElement(addTextChildren, factor)
+ .then(({element, iframe, viewport}) => {
+ const parallaxService = parallaxForDoc(iframe.doc);
+ return new Promise(resolve => {
+ parallaxService.addScrollListener_(afterFirstScroll);
+ viewport.setScrollTop(scroll);
+
+ function afterFirstScroll() {
+ parallaxService.removeScrollListener_(afterFirstScroll);
+ parallaxService.addScrollListener_(afterSecondScroll);
+ viewport.setScrollTop(2 * scroll);
+ }
+
+ function afterSecondScroll() {
+ const top = element.getBoundingClientRect().top;
+ expect(top).to.equal(expectedParallax);
+ resolve();
+ }
+ });
+ });
+ });
+
+ it('should return to its original position when scrolling back', () => {
+ const factor = -1.7; // move downward so it stays in the viewport
+
+ return getAmpParallaxElement(addTextChildren, factor)
+ .then(({element, iframe, viewport}) => {
+ const parallaxService = parallaxForDoc(iframe.doc);
+ return new Promise(resolve => {
+ parallaxService.addScrollListener_(afterFirstScroll);
+ viewport.setScrollTop(10);
+
+ function afterFirstScroll() {
+ parallaxService.removeScrollListener_(afterFirstScroll);
+ parallaxService.addScrollListener_(afterSecondScroll);
+ viewport.setScrollTop(200);
+ }
+
+ function afterSecondScroll() {
+ parallaxService.removeScrollListener_(afterSecondScroll);
+ parallaxService.addScrollListener_(afterThirdScroll);
+ viewport.setScrollTop(0);
+ }
+
+ function afterThirdScroll() {
+ const top = element.getBoundingClientRect().top;
+ expect(top).to.equal(0);
+ resolve();
+ }
+ });
+ });
+ });
+
+ it('should render moved if the page loads partially scrolled', () => {
+ const scroll = 10;
+ const expectedParallax = -1 * DEFAULT_FACTOR * scroll;
+
+ return getAmpParallaxElement(addTextChildren, DEFAULT_FACTOR, scroll)
+ .then(({element}) => {
+ const top = element.getBoundingClientRect().top;
+ expect(top).to.equal(expectedParallax);
+ });
+ });
+});
diff --git a/extensions/amp-fx-parallax/amp-fx-parallax.md b/extensions/amp-fx-parallax/amp-fx-parallax.md
new file mode 100644
index 000000000000..690e42766df7
--- /dev/null
+++ b/extensions/amp-fx-parallax/amp-fx-parallax.md
@@ -0,0 +1,53 @@
+
+
+#