diff --git a/README.md b/README.md
index f7cc9c1..91c4664 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,10 @@ React Canvas provides a set of standard React components that abstract the under
**Image** is exactly what you think it is. However, it adds the ability to hide an image until it is fully loaded and optionally fade it in on load.
+### <Carousel>
+
+**Carousel** is a touch scrolling container that renders a set of elements horizontally.
+
### <ListView>
**ListView** is a touch scrolling container that renders a list of elements in a column. Think of it like UITableView for the web. It leverages many of the same optimizations that make table views on iOS and list views on Android fast.
diff --git a/examples/carousel/app.js b/examples/carousel/app.js
new file mode 100644
index 0000000..ad85266
--- /dev/null
+++ b/examples/carousel/app.js
@@ -0,0 +1,72 @@
+/** @jsx React.DOM */
+
+'use strict';
+
+var React = require('react');
+var ReactCanvas = require('react-canvas');
+var Page = require('./components/Page');
+var articles = require('../common/data');
+
+var Surface = ReactCanvas.Surface;
+var Carousel = ReactCanvas.Carousel;
+
+var App = React.createClass({
+
+ render: function () {
+ var size = this.getSize();
+ return (
+
+
+
+ );
+ },
+
+ renderPage: function (pageIndex, scrollLeft) {
+ var size = this.getSize();
+ var article = articles[pageIndex % articles.length];
+ var pageScrollLeft = pageIndex * this.getPageWidth() - scrollLeft;
+ return (
+
+ );
+ },
+
+ getSize: function () {
+ return document.getElementById('main').getBoundingClientRect();
+ },
+
+ // Carousel
+ // ========
+
+ getCarouselStyle: function () {
+ var size = this.getSize();
+ return {
+ top: 0,
+ left: 0,
+ width: size.width,
+ height: size.height
+ };
+ },
+
+ getNumberOfPages: function () {
+ return 1000;
+ },
+
+ getPageWidth: function () {
+ return this.getSize().width;
+ }
+
+});
+
+React.render(, document.getElementById('main'));
diff --git a/examples/carousel/components/Page.js b/examples/carousel/components/Page.js
new file mode 100644
index 0000000..d1515c0
--- /dev/null
+++ b/examples/carousel/components/Page.js
@@ -0,0 +1,66 @@
+/** @jsx React.DOM */
+
+'use strict';
+
+var React = require('react');
+var ReactCanvas = require('react-canvas');
+
+var Group = ReactCanvas.Group;
+var Image = ReactCanvas.Image;
+
+var CONTENT_INSET = 14;
+var IMAGE_LAYER_INDEX = 1;
+
+var Page = React.createClass({
+
+ propTypes: {
+ width: React.PropTypes.number.isRequired,
+ height: React.PropTypes.number.isRequired,
+ article: React.PropTypes.object.isRequired,
+ scrollLeft: React.PropTypes.number.isRequired
+ },
+
+ componentWillMount: function () {
+ // Pre-compute headline/excerpt text dimensions.
+ var article = this.props.article;
+ var maxWidth = this.props.width - 2 * CONTENT_INSET;
+ },
+
+ render: function () {
+ var groupStyle = this.getGroupStyle();
+ var imageStyle = this.getImageStyle();
+
+ return (
+
+
+
+ );
+ },
+
+ getGroupStyle: function () {
+ return {
+ top: 0,
+ left: 0,
+ width: this.props.width,
+ height: this.props.height,
+ };
+ },
+
+ getImageWidth: function () {
+ return Math.round(this.props.width);
+ },
+
+ getImageStyle: function () {
+ return {
+ top: 0,
+ left: 0,
+ width: this.getImageWidth(),
+ height: this.props.height,
+ backgroundColor: '#eee',
+ zIndex: IMAGE_LAYER_INDEX
+ };
+ },
+
+});
+
+module.exports = Page;
diff --git a/examples/carousel/index.html b/examples/carousel/index.html
new file mode 100644
index 0000000..2e1610d
--- /dev/null
+++ b/examples/carousel/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ ReactCanvas: Carousel
+
+
+
+
+
+
+
+
+
diff --git a/lib/Carousel.js b/lib/Carousel.js
new file mode 100644
index 0000000..efc16dc
--- /dev/null
+++ b/lib/Carousel.js
@@ -0,0 +1,186 @@
+'use strict';
+
+var React = require('react');
+var assign = require('react/lib/Object.assign');
+var Scroller = require('scroller');
+var Group = require('./Group');
+var clamp = require('./clamp');
+
+var Carousel = React.createClass({
+
+ propTypes: {
+ style: React.PropTypes.object,
+ numberOfItemsGetter: React.PropTypes.func.isRequired,
+ itemWidthGetter: React.PropTypes.func.isRequired,
+ itemGetter: React.PropTypes.func.isRequired,
+ snapping: React.PropTypes.bool,
+ scrollingDeceleration: React.PropTypes.number,
+ scrollingPenetrationAcceleration: React.PropTypes.number,
+ onSlide: React.PropTypes.func
+ },
+
+ getDefaultProps: function () {
+ return {
+ style: { left: 0, top: 0, width: 0, height: 0 },
+ snapping: false,
+ scrollingDeceleration: 0.95,
+ scrollingPenetrationAcceleration: 0.08
+ };
+ },
+
+ getInitialState: function () {
+ return {
+ scrollLeft: 0
+ };
+ },
+
+ componentDidMount: function () {
+ this.createScroller();
+ this.updateScrollingDimensions();
+ },
+
+ render: function () {
+ var items = this.getVisibleItemIndexes().map(this.renderItem);
+ return (
+ React.createElement(Group, {
+ style: this.props.style,
+ onTouchStart: this.handleTouchStart,
+ onTouchMove: this.handleTouchMove,
+ onTouchEnd: this.handleTouchEnd,
+ onTouchCancel: this.handleTouchEnd},
+ items
+ )
+ );
+ },
+
+ renderItem: function (itemIndex) {
+ var item = this.props.itemGetter(itemIndex, this.state.scrollLeft);
+ var itemWidth = this.props.itemWidthGetter();
+ var style = {
+ top: 0,
+ left: 0,
+ width: itemWidth,
+ height: this.props.style.height,
+ translateX: (itemIndex * itemWidth) - this.state.scrollLeft,
+ zIndex: itemIndex
+ };
+
+ return (
+ React.createElement(Group, {style: style, key: itemIndex},
+ item
+ )
+ );
+ },
+
+ // Events
+ // ======
+
+ handleTouchStart: function (e) {
+ if (this.scroller) {
+ this.scroller.doTouchStart(e.touches, e.timeStamp);
+ }
+ },
+
+ handleTouchMove: function (e) {
+ if (this.scroller) {
+ e.preventDefault();
+ this.scroller.doTouchMove(e.touches, e.timeStamp, e.scale);
+ }
+ },
+
+ handleTouchEnd: function (e) {
+ if (this.scroller) {
+ this.scroller.doTouchEnd(e.timeStamp);
+ if (this.props.snapping) {
+ this.updateScrollingDeceleration();
+ }
+ }
+ },
+
+ handleScroll: function (left, top) {
+ this.setState({ scrollLeft: left });
+ if (this.props.onSlide) {
+ this.props.onSlide(left);
+ }
+ },
+
+ // Swiping
+ // =========
+
+ createScroller: function () {
+ var options = {
+ scrollingX: true,
+ scrollingY: false,
+ decelerationRate: this.props.scrollingDeceleration,
+ penetrationAcceleration: this.props.scrollingPenetrationAcceleration,
+ };
+ this.scroller = new Scroller(this.handleScroll, options);
+ },
+
+ updateScrollingDimensions: function () {
+ var width = this.props.style.width;
+ var height = this.props.style.height;
+ var scrollWidth = this.props.numberOfItemsGetter() * this.props.itemWidthGetter();
+ var scrollHeight = height;
+ this.scroller.setDimensions(width, height, scrollWidth, scrollHeight);
+ },
+
+ getVisibleItemIndexes: function () {
+ var itemIndexes = [];
+ var itemWidth = this.props.itemWidthGetter();
+ var itemCount = this.props.numberOfItemsGetter();
+ var scrollLeft = this.state.scrollLeft;
+ var itemsScrollLeft = 0;
+
+ for (var index=0; index < itemCount; index++) {
+ itemsScrollLeft = (index * itemWidth) - scrollLeft;
+
+ // Item is completely off-screen right
+ if (itemsScrollLeft >= this.props.style.width) {
+ continue;
+ }
+
+ // Item is completely off-screen left
+ if (itemsScrollLeft <= -this.props.style.width) {
+ continue;
+ }
+
+ // Part of item is on-screen.
+ itemIndexes.push(index);
+ }
+
+ return itemIndexes;
+ },
+
+ updateScrollingDeceleration: function () {
+ var currVelocity = this.scroller.__decelerationVelocityY;
+ var currScrollLeft = this.state.scrollLeft;
+ var targetScrollLeft = 0;
+ var estimatedEndScrollLeft = currScrollLeft;
+
+ while (Math.abs(currVelocity).toFixed(6) > 0) {
+ estimatedEndScrollLeft += currVelocity;
+ currVelocity *= this.props.scrollingDeceleration;
+ }
+
+ // Find the page whose estimated end scrollLeft is closest to 0.
+ var closestZeroDelta = Infinity;
+ var pageWidth = this.props.itemWidthGetter();
+ var pageCount = this.props.numberOfItemsGetter();
+ var pageScrollLeft;
+
+ for (var pageIndex=0, len=pageCount; pageIndex < len; pageIndex++) {
+ pageScrollLeft = (pageWidth * pageIndex) - estimatedEndScrollLeft;
+ if (Math.abs(pageScrollLeft) < closestZeroDelta) {
+ closestZeroDelta = Math.abs(pageScrollLeft);
+ targetScrollLeft = pageWidth * pageIndex;
+ }
+ }
+
+ this.scroller.__minDecelerationScrollLeft = targetScrollLeft;
+ this.scroller.__maxDecelerationScrollLeft = targetScrollLeft;
+ }
+
+});
+
+module.exports = Carousel;
diff --git a/lib/ReactCanvas.js b/lib/ReactCanvas.js
index ba4dd79..7d1df40 100644
--- a/lib/ReactCanvas.js
+++ b/lib/ReactCanvas.js
@@ -8,6 +8,7 @@ var ReactCanvas = {
Image: require('./Image'),
Text: require('./Text'),
ListView: require('./ListView'),
+ Carousel: require('./Carousel'),
FontFace: require('./FontFace'),
measureText: require('./measureText')
diff --git a/webpack.config.js b/webpack.config.js
index 00231d6..d438b07 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -4,6 +4,7 @@ module.exports = {
watch: true,
entry: {
+ 'carousel': ['./examples/carousel/app.js'],
'listview': ['./examples/listview/app.js'],
'timeline': ['./examples/timeline/app.js'],
'css-layout': ['./examples/css-layout/app.js']