Detect if an Ember View or Component is in the viewport @ 60FPS
ember-in-viewport is built and maintained by DockYard, contact us for expert Ember.js consulting.
This Ember addon adds a simple, highly performant Service or Mixin to your app. This library will allow you to check if that Component
has entered the browser's viewport. By default, the this uses the IntersectionObserver
API if it detects it in your user's browser – failing which, it falls back to using requestAnimationFrame
, then if not available, the Ember run loop and event listeners.
We utilize pooling techniques to reuse Intersection Observers and rAF observers in order to make your app as performant as possible and do as little works as possible.
- Lazy loading responsive images (see
dummy-artwork
for an example artwork component). Visithttp://localhost:4200/infinity-modifier
to see it in action - Dummy app (
ember serve
): https://github.com/DockYard/ember-in-viewport/tree/master/tests/dummy - Use with Ember Modifiers and @ember/render-modifiers
- Use with Native Classes
- ember-infinity
- ember-light-table
- Tracking advertisement impressions
- Occlusion culling
ember install ember-in-viewport
Usage is simple. First, add the Mixin to your Component
:
import Component from '@ember/component';
import InViewportMixin from 'ember-in-viewport';
export default Component.extend(InViewportMixin, {
// ...
});
These hooks fire once whenever the Component
enters or exits the viewport. You can handle them the same way you would handle any other native Ember hook:
import Component from '@ember/component';
import InViewportMixin from 'ember-in-viewport';
export default Component.extend(InViewportMixin, {
didEnterViewport() {
console.log('entered');
},
didExitViewport() {
console.log('exited');
}
});
The didScroll
hook fires when an element enters the viewport. For example, if you scrolled down in order to move the element in the viewport, the didScroll
hook would fire and also receive the direction as a string. You can then handle it like another hook as in the above example.
import Component from '@ember/component';
import InViewportMixin from 'ember-in-viewport';
export default Component.extend(InViewportMixin, {
didScroll(direction) {
console.log(direction); // 'up' || 'down' || 'left' || 'right'
}
});
To apply an .active
class to your Component
when it enters the viewport, you can simply bind the active
class to the mixed in property viewportEntered
, like so:
import Component from '@ember/component';
import InViewportMixin from 'ember-in-viewport';
export default Component.extend(InViewportMixin, {
classNameBindings: [ 'viewportEntered:active' ]
});
This hook fires whenever the Component
leaves the viewport.
The mixin comes with some options. Due to the way listeners and IntersectionObserver API
or requestAnimationFrame
is setup, you'll have to override the options this way:
import Component from '@ember/component';
import InViewportMixin from 'ember-in-viewport';
import { setProperties } from '@ember/object';
export default Component.extend(InViewportMixin, {
init() {
this._super(...arguments);
setProperties(this, {
viewportEnabled : true,
viewportUseRAF : true,
viewportSpy : false,
viewportScrollSensitivity : 1,
viewportRefreshRate : 150,
intersectionThreshold : 0,
scrollableArea : null,
viewportTolerance: {
top : 50,
bottom : 50,
left : 20,
right : 20
}
});
}
});
-
viewportEnabled: boolean
Default:
true
Set to false to have no listeners registered. Useful if you have components that function with either viewport listening on or off.
-
viewportUseIntersectionObserver: boolean
Default: Depends on browser
Read-only
The Mixin by default will use the IntersectionObserver API. If IntersectionObserver is not supported in the target browser, ember-in-viewport will fallback to rAF. We prevent users from explicitly setting this to
true
as browsers lacking support for IntersectionObserver will throw an error.(https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) (https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/thresholds#Browser_compatibility)
-
intersectionThreshold: decimal or array
Default: 0
A single number or array of numbers between 0.0 and 1.0. A value of 0.0 means the target will be visible when the first pixel enters the viewport. A value of 1.0 means the entire target must be visible to fire the didEnterViewport hook. Similarily, [0, .25, .5, .75, 1] will fire didEnterViewport every 25% of the target that is visible. (https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#Thresholds)
Some notes:
- If the target is offscreen, you will get a notification via
didExitViewport
that the target is initially offscreen. Similarily, this is possible to notify if onscreen when your site loads. - If intersectionThreshold is set to anything greater than 0, you will not see
didExitViewport
hook fired due to our use of theisIntersecting
property. See last comment here: https://bugs.chromium.org/p/chromium/issues/detail?id=713819 for purpose ofisIntersecting
- To get around the above issue and have
didExitViewport
fire, set yourintersectionThreshold
to[0, 1.0]
. When set to just1.0
, when the element is 99% visible and still has isIntersecting as true, when the element leaves the viewport, the element isn't applicable to the observer anymore, so the callback isn't called again. - If your intersectionThreshold is set to 0 you will get notified if the target
didEnterViewport
anddidExitViewport
at the appropriate time.
- If the target is offscreen, you will get a notification via
-
scrollableArea: string | HTMLElement
Default: null
A CSS selector for the scrollable area. e.g.
".my-list"
-
viewportUseRAF: boolean
Default: Depends on browser
As its name suggests, if this is
true
and the IntersectionObserver API is not available in the target browser, the Mixin will userequestAnimationFrame
. Unless you want to force enabling or disabling this, you won't need to override this option. -
viewportSpy: boolean
Default:
false
When
true
, the Mixin will continually watch theComponent
and re-fire hooks whenever it enters or leaves the viewport. Because this is expensive, this behaviour is opt-in. When false, the Mixin will only watch theComponent
until it enters the viewport once, and then it setsviewportEntered
totrue
(permanently), and unbinds listeners. This reduces the load on the Ember run loop and your application.NOTE: If using IntersectionObserver (default), viewportSpy wont put too much of a tax on your application. However, for browsers (Safari) that don't currently support IntersectionObserver, we fallback to rAF. Depending on your use case, the default of
false
may be acceptable. -
viewportDidScroll: boolean
Default:
true
When
true
, the Mixin enables listening to thedidScroll
hook. This will become by default false in a future major release -
viewportScrollSensitivity: number
Default:
1
This value determines the degree of sensitivity (in
px
) in which a DOM element is considered to have scrolled into the viewport. For example, if you setviewportScrollSensitivity
to10
, thedidScroll{...}
hooks would only fire if the scroll was greater than10px
. Only applicable if IntersectionObserver and rAF are not applied. -
viewportRefreshRate: number
Default:
100
If
IntersectionObserver
andrequestAnimationFrame
is not present, this value determines how often the Mixin checks your component to determine whether or not it has entered or left the viewport. The lower this number, the more often it checks, and the more load is placed on your application. Generally, you'll want this value between100
to300
, which is about the range at which people consider things to be "real-time".This value also affects how often the Mixin checks scroll direction.
-
viewportTolerance: object
Default:
{ top: 0, left: 0, bottom: 0, right: 0 }
This option determines how accurately the
Component
needs to be within the viewport for it to be considered as entered. Add bottom margin to preemptively trigger didEnterViewport.For IntersectionObserver, this property interpolates to rootMargin. For rAF, this property will use
bottom
tolerance and measure against the height of the container to determine when to trigger didEnterViewport.Also, if your sentinel (component that uses this mixin) is a zero-height element, ensure that the sentinel actually is able to enter the viewport.
You can set application wide defaults for ember-in-viewport
in your app (they are still manually overridable inside of a Component). To set new defaults, just add a config object to config/environment.js
, like so:
module.exports = function(environment) {
var ENV = {
// ...
viewportConfig: {
viewportEnabled : false,
viewportUseRAF : true,
viewportSpy : false,
viewportScrollSensitivity : 1,
viewportRefreshRate : 100,
viewportListeners : [],
intersectionThreshold : 0,
scrollableArea : null,
viewportTolerance: {
top : 0,
left : 0,
bottom : 0,
right : 0
}
}
};
};
Note if you want to disable right and left in-viewport triggers, set these values to `Infinity`.
Using with Modifiers is easy. Note, modifiers currently only works to watch entering the viewport.
- Install @ember/render-modifiers
- Use the
did-insert
hook inside a component - Wire up the component like so
Note - This is in lieu of a did-enter-viewport
modifier, which we plan on adding in the future. Compared to the solution below, did-enter-viewport
won't need a container (this
) passed to it. But for now, to start using modifiers, this is the easy path.
import Component from '@ember/component';
import { set } from '@ember/object';
import InViewportMixin from 'ember-in-viewport';
export default Component.extend(InViewportMixin, {
tagName: '',
didInsertNode(element, [instance]) {
instance.watchElement(element);
},
didInsertElement() {
this._super(...arguments);
set(this, 'viewportSpy', true);
set(this, 'viewportTolerance', {
bottom: 300
});
this._super(...arguments);
},
didEnterViewport() {
// this will only work with one element being watched in the container. This is still a TODO to enable
this.infinityLoad();
}
});
This allows you to absolve yourself from using a mixin in native classes!
import Component from '@ember/component';
import { tagName } from '@ember-decorators/component';
import { inject as service } from '@ember/service'; // with polyfill
@tagName('')
export default class MyClass extends Component {
@service inViewport
didInsertElement() {
super();
const loader = document.getElementById('loader');
const viewportTolerance = { bottom: 200 };
const { onEnter, onExit } = this.inViewport.watchElement(loader, { viewportTolerance });
onEnter(this.didEnterViewport.bind(this));
}
didEnterViewport() {
// do some other stuff
this.infinityLoad();
},
willDestroyElement() {
// need to manage cache yourself if you don't use the mixin
const loader = document.getElementById('loader');
this.inViewport.stopWatching(loader);
}
}
And with Classes + Modifiers!
import Component from '@ember/component';
import { tagName } from '@ember-decorators/component';
import { inject as service } from '@ember/service'; // with polyfill
@tagName('')
export default class MyClass extends Component {
@service inViewport
didInsertNode(element, [instance]) {
const viewportTolerance = { bottom: 200 };
const { onEnter, onExit } = instance.inViewport.watchElement(element, { viewportTolerance });
onEnter(instance.didEnterViewport.bind(instance));
}
didEnterViewport() {
// do some other stuff
this.infinityLoad();
},
willDestroyElement() {
// need to manage cache yourself if you don't use the mixin
const loader = document.getElementById('loader');
this.inViewport.stopWatching(loader);
}
}
Options as the second argument to inViewport.watchElement
include intersectionThreshold
, scrollableArea
, viewportSpy
&& viewportTolerance
Chrome | 51 [1] |
Firefox (Gecko) | 55 [2] |
MS Edge | 15 |
Internet Explorer | Not supported |
Opera [1] | 38 |
Safari | Safari Technology Preview |
Chrome for Android | 59 |
Android Browser | 56 |
Opera Mobile | 37 |
- [1] Reportedly available, it didn't trigger the events on initial load and lacks
isIntersecting
until later versions. - [2] This feature was implemented in Gecko 53.0 (Firefox 53.0 / Thunderbird 53.0 / SeaMonkey 2.50) behind the preference
dom.IntersectionObserver.enabled
.
ember serve
- Visit your app at http://localhost:4200.
ember test
ember test --serve
ember build
For more information on using ember-cli, visit http://www.ember-cli.com/.
DockYard, Inc © 2015
Licensed under the MIT license
We're grateful to these wonderful contributors who've contributed to ember-in-viewport
: