diff --git a/EXTRA_DOCS.md b/EXTRA_DOCS.md
index 2ea87a3..d3c9de8 100644
--- a/EXTRA_DOCS.md
+++ b/EXTRA_DOCS.md
@@ -532,3 +532,86 @@ result.addListener({
- - -
+### `tween(config)`
+
+Creates a stream of numbers emitted in a quick burst, following a numeric
+function like sine or elastic or quadratic. tween() is meant for creating
+streams for animations.
+
+Example:
+
+```js
+import tween from 'xstream/extra/tween'
+
+const stream = tween({
+ from: 20,
+ to: 100,
+ ease: tween.exponential.easeIn,
+ duration: 1000, // milliseconds
+})
+
+stream.addListener({
+ next: (x) => console.log(x),
+ error: (err) => console.error(err),
+ complete: () => console.log('concat completed'),
+})
+```
+
+The stream would behave like the plot below:
+
+```text
+100 #
+|
+|
+|
+|
+80 #
+|
+|
+|
+| #
+60
+|
+| #
+|
+| #
+40
+| #
+| #
+| ##
+| ###
+20########
++---------------------> time
+```
+
+Provide a configuration object with **from**, **to**, **duration**, **ease**,
+**interval** (optional), and this factory function will return a stream of
+numbers following that pattern. The first number emitted will be `from`, and
+the last number will be `to`. The numbers in between follow the easing
+function you specify in `ease`, and the stream emission will last in total
+`duration` milliseconds.
+
+The easing functions are attached to `tween` too, such as
+`tween.linear.ease`, `tween.power2.easeIn`, `tween.exponential.easeOut`, etc.
+Here is a list of all the available easing options:
+
+- `tween.linear` with ease
+- `tween.power2` with easeIn, easeOut, easeInOut
+- `tween.power3` with easeIn, easeOut, easeInOut
+- `tween.power4` with easeIn, easeOut, easeInOut
+- `tween.exponential` with easeIn, easeOut, easeInOut
+- `tween.back` with easeIn, easeOut, easeInOut
+- `tween.bounce` with easeIn, easeOut, easeInOut
+- `tween.circular` with easeIn, easeOut, easeInOut
+- `tween.elastic` with easeIn, easeOut, easeInOut
+- `tween.sine` with easeIn, easeOut, easeInOut
+
+#### Arguments:
+
+- `config: TweenConfig` An object with properties `from: number`, `to: number`, `duration: number`, `ease: function` (optional, defaults to
+linear), `interval: number` (optional, defaults to 15).
+
+#### Returns: Stream
+
+- - -
+
diff --git a/src/extra/tween.ts b/src/extra/tween.ts
new file mode 100644
index 0000000..cabbe82
--- /dev/null
+++ b/src/extra/tween.ts
@@ -0,0 +1,224 @@
+import {Stream} from '../core';
+import concat from './concat';
+
+export type Ease = (x: number, from: number, to: number) => number;
+export type Easings = {
+ easeIn: Ease;
+ easeOut: Ease;
+ easeInOut: Ease;
+}
+
+export type NumericFunction = (input: number) => number;
+
+export interface TweenConfig {
+ from: number;
+ to: number;
+ duration: number;
+ ease?: Ease;
+ interval?: number;
+}
+
+function interpolate(y: number, from: number, to: number): number {
+ return (from * (1 - y) + to * y);
+}
+
+function flip(fn: NumericFunction): NumericFunction {
+ return x => 1 - fn(1 - x);
+}
+
+function createEasing(fn: NumericFunction): Easings {
+ let fnFlipped = flip(fn);
+ return {
+ easeIn(x, from, to) {
+ return interpolate(fn(x), from, to);
+ },
+ easeOut(x, from, to) {
+ return interpolate(fnFlipped(x), from, to);
+ },
+ easeInOut(x, from, to) {
+ const y = (x < 0.5) ?
+ (fn(2 * x) * 0.5) :
+ (0.5 + fnFlipped(2 * (x - 0.5)) * 0.5);
+ return interpolate(y, from, to);
+ }
+ };
+};
+
+let easingPower2 = createEasing(x => x * x);
+let easingPower3 = createEasing(x => x * x * x);
+let easingPower4 = createEasing(x => {
+ const xx = x * x;
+ return xx * xx;
+});
+
+const EXP_WEIGHT = 6;
+const EXP_MAX = Math.exp(EXP_WEIGHT) - 1;
+function expFn(x: number): number {
+ return (Math.exp(x * EXP_WEIGHT) - 1) / EXP_MAX;
+}
+let easingExponential = createEasing(expFn);
+
+const OVERSHOOT = 1.70158;
+let easingBack = createEasing(x => x * x * ((OVERSHOOT + 1) * x - OVERSHOOT));
+
+const PARAM1 = 7.5625;
+const PARAM2 = 2.75;
+function easeOutFn(x: number): number {
+ let z = x;
+ if (z < 1 / PARAM2) {
+ return (PARAM1 * z * z);
+ } else if (z < 2 / PARAM2) {
+ return (PARAM1 * (z -= 1.5 / PARAM2) * z + 0.75);
+ } else if (z < 2.5 / PARAM2) {
+ return (PARAM1 * (z -= 2.25 / PARAM2) * z + 0.9375);
+ } else {
+ return (PARAM1 * (z -= 2.625 / PARAM2) * z + 0.984375);
+ }
+}
+let easingBounce = createEasing(x => 1 - easeOutFn(1 - x));
+
+let easingCirc = createEasing(x => -(Math.sqrt(1 - x * x) - 1));
+
+const PERIOD = 0.3;
+const OVERSHOOT_ELASTIC = PERIOD / 4;
+const AMPLITUDE = 1;
+function elasticIn(x: number): number {
+ let z = x;
+ if (z <= 0) {
+ return 0;
+ } else if (z >= 1) {
+ return 1;
+ } else {
+ z -= 1;
+ return -(AMPLITUDE * Math.pow(2, 10 * z))
+ * Math.sin((z - OVERSHOOT_ELASTIC) * (2 * Math.PI) / PERIOD);
+ }
+}
+let easingElastic = createEasing(elasticIn);
+
+const HALF_PI = Math.PI * 0.5;
+let easingSine = createEasing(x => 1 - Math.cos(x * HALF_PI));
+
+const DEFAULT_INTERVAL: number = 15;
+
+export interface TweenFactory {
+ (config: TweenConfig): Stream;
+ linear: { ease: Ease };
+ power2: Easings;
+ power3: Easings;
+ power4: Easings;
+ exponential: Easings;
+ back: Easings;
+ bounce: Easings;
+ circular: Easings;
+ elastic: Easings;
+ sine: Easings;
+}
+
+/**
+ * Creates a stream of numbers emitted in a quick burst, following a numeric
+ * function like sine or elastic or quadratic. tween() is meant for creating
+ * streams for animations.
+ *
+ * Example:
+ *
+ * ```js
+ * import tween from 'xstream/extra/tween'
+ *
+ * const stream = tween({
+ * from: 20,
+ * to: 100,
+ * ease: tween.exponential.easeIn,
+ * duration: 1000, // milliseconds
+ * })
+ *
+ * stream.addListener({
+ * next: (x) => console.log(x),
+ * error: (err) => console.error(err),
+ * complete: () => console.log('concat completed'),
+ * })
+ * ```
+ *
+ * The stream would behave like the plot below:
+ *
+ * ```text
+ * 100 #
+ * |
+ * |
+ * |
+ * |
+ * 80 #
+ * |
+ * |
+ * |
+ * | #
+ * 60
+ * |
+ * | #
+ * |
+ * | #
+ * 40
+ * | #
+ * | #
+ * | ##
+ * | ###
+ * 20########
+ * +---------------------> time
+ * ```
+ *
+ * Provide a configuration object with **from**, **to**, **duration**, **ease**,
+ * **interval** (optional), and this factory function will return a stream of
+ * numbers following that pattern. The first number emitted will be `from`, and
+ * the last number will be `to`. The numbers in between follow the easing
+ * function you specify in `ease`, and the stream emission will last in total
+ * `duration` milliseconds.
+ *
+ * The easing functions are attached to `tween` too, such as
+ * `tween.linear.ease`, `tween.power2.easeIn`, `tween.exponential.easeOut`, etc.
+ * Here is a list of all the available easing options:
+ *
+ * - `tween.linear` with ease
+ * - `tween.power2` with easeIn, easeOut, easeInOut
+ * - `tween.power3` with easeIn, easeOut, easeInOut
+ * - `tween.power4` with easeIn, easeOut, easeInOut
+ * - `tween.exponential` with easeIn, easeOut, easeInOut
+ * - `tween.back` with easeIn, easeOut, easeInOut
+ * - `tween.bounce` with easeIn, easeOut, easeInOut
+ * - `tween.circular` with easeIn, easeOut, easeInOut
+ * - `tween.elastic` with easeIn, easeOut, easeInOut
+ * - `tween.sine` with easeIn, easeOut, easeInOut
+ *
+ * @factory true
+ * @param {TweenConfig} config An object with properties `from: number`,
+ * `to: number`, `duration: number`, `ease: function` (optional, defaults to
+ * linear), `interval: number` (optional, defaults to 15).
+ * @return {Stream}
+ */
+function tween({
+ from,
+ to,
+ duration,
+ ease = tweenFactory.linear.ease,
+ interval = DEFAULT_INTERVAL
+}): Stream {
+ const totalTicks = Math.round(duration / interval);
+ return Stream.periodic(interval)
+ .take(totalTicks)
+ .map(tick => ease(tick / totalTicks, from, to))
+ .compose(s => concat(s, Stream.of(to)));
+}
+
+const tweenFactory: TweenFactory = tween;
+
+tweenFactory.linear = { ease: interpolate };
+tweenFactory.power2 = easingPower2;
+tweenFactory.power3 = easingPower3;
+tweenFactory.power4 = easingPower4;
+tweenFactory.exponential = easingExponential;
+tweenFactory.back = easingBack;
+tweenFactory.bounce = easingBounce;
+tweenFactory.circular = easingCirc;
+tweenFactory.elastic = easingElastic;
+tweenFactory.sine = easingSine;
+
+export default tweenFactory;
diff --git a/tests/extra/tween.ts b/tests/extra/tween.ts
new file mode 100644
index 0000000..935c536
--- /dev/null
+++ b/tests/extra/tween.ts
@@ -0,0 +1,407 @@
+///
+///
+import xs, {Stream} from '../../src/index';
+import tween from '../../src/extra/tween';
+import * as assert from 'assert';
+
+const STEPS = 20;
+const DURATION = 1000;
+
+const plotTweenConfigs = {
+ from: 0,
+ to: STEPS,
+ duration: DURATION,
+ interval: DURATION / STEPS
+};
+
+function setCharAt(str: string, idx: number, chr: string): string {
+ if (idx > str.length - 1){
+ return str.toString();
+ } else {
+ return str.substr(0, idx) + chr + str.substr(idx + 1);
+ }
+}
+
+function rotate(lines: Array): Array {
+ let len = lines[0].length;
+ return lines[0].split('')
+ .map((col, i) =>
+ lines
+ .map(row => row.split('')[len-i-1])
+ )
+ .map(row => row.join(''));
+}
+
+function stutter(char: string, length: number): string {
+ return new Array(length + 1).join(char);
+}
+
+function plot(position$: Stream): Stream {
+ return position$
+ .fold((acc, curr) => {
+ acc.push(curr);
+ return acc;
+ }, [])
+ .last()
+ .map(arr => {
+ let coords = arr.map((y, x) => [x, y]);
+ let lines = coords.reduce((lines, [x, y]) => {
+ let newline: string;
+ if (y < 0) {
+ newline = setCharAt(stutter(' ', STEPS + 1), 0, '_');
+ } else {
+ newline = setCharAt(stutter(' ', STEPS + 1), Math.round(y), '#');
+ }
+ lines.push(newline);
+ return lines;
+ }, []);
+ return rotate(lines)
+ .map(line => '|'.concat(line.replace(/ *$/g, '')).concat('\n'))
+ .reduce((lines, line) => lines.concat(line), '')
+ .concat('+' + stutter('-', STEPS + 1));
+ });
+}
+
+function makeAssertPlot(done: MochaDone, assert: any, expected: string) {
+ return {
+ next: function assertPlot(actual: string) {
+ assert.equal('\n' + actual, expected);
+ },
+ error: (err: any) => done(err),
+ complete: () => {
+ done();
+ },
+ };
+}
+
+describe('tween (extra)', () => {
+ it('should do linear tweening', (done) => {
+ let position$ = tween({
+ ease: tween.linear.ease,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done, assert, `
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+| #
+|#
++---------------------`));
+ });
+
+ it('should do power of 2 easing (ease in)', (done) => {
+ let position$ = tween({
+ ease: tween.power2.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done, assert, `
+| #
+|
+| #
+|
+| #
+|
+| #
+| #
+|
+| #
+| #
+|
+| #
+| #
+| #
+| #
+| #
+| #
+| ##
+| ##
+|####
++---------------------`));
+ });
+
+ it("should do power of 3 easing (ease in)", function (done) {
+ let position$ = tween({
+ ease: tween.power3.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done, assert, `
+| #
+|
+|
+| #
+|
+| #
+|
+|
+| #
+|
+| #
+|
+| #
+| #
+|
+| #
+| #
+| ##
+| #
+| ###
+|######
++---------------------`));
+ });
+
+ it("should do power of 4 easing (ease in)", function (done) {
+ let position$ = tween({
+ ease: tween.power4.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done, assert, `
+| #
+|
+|
+|
+| #
+|
+|
+| #
+|
+|
+| #
+|
+| #
+|
+| #
+| #
+| #
+| #
+| #
+| ###
+|########
++---------------------`));
+ });
+
+ it("should do exponential easing (ease in)", function (done) {
+ let position$ = tween({
+ ease: tween.exponential.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done, assert, `
+| #
+|
+|
+|
+|
+| #
+|
+|
+|
+| #
+|
+|
+| #
+|
+| #
+|
+| #
+| #
+| ##
+| ###
+|#########
++---------------------`));
+ });
+
+ it("should do back easing (ease in)", function (done) {
+ let position$ = tween({
+ ease: tween.back.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done, assert, `
+| #
+|
+|
+|
+| #
+|
+|
+|
+| #
+|
+|
+| #
+|
+|
+| #
+|
+| #
+|
+| #
+|
+|#____________#
++---------------------`));
+ });
+
+ it("should do bounce easing (ease in)", function (done) {
+ let position$ = tween({
+ ease: tween.bounce.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done, assert, `
+| ##
+|
+| #
+| #
+|
+|
+| #
+|
+|
+| #
+|
+|
+|
+|
+| #
+| ###
+| #
+| #
+| #
+| #### #
+|###
++---------------------`));
+ });
+
+ it("should do circular easing (ease in)", function (done) {
+ let position$ = tween({
+ ease: tween.circular.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done,assert, `
+| #
+|
+|
+|
+|
+|
+| #
+|
+|
+| #
+|
+| #
+| #
+| #
+| #
+| #
+| #
+| ##
+| ##
+| ###
+|#####
++---------------------`));
+ });
+
+ it("should do elastic easing (ease in)", function (done) {
+ let position$ = tween({
+ ease: tween.elastic.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done,assert, `
+| #
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| #
+|
+|
+|
+|
+| ##
+| #
+|####___###___ ___
++---------------------`));
+ });
+
+ it("should do sine easing (ease in)", function (done) {
+ let position$ = tween({
+ ease: tween.sine.easeIn,
+ from: plotTweenConfigs.from,
+ to: plotTweenConfigs.to,
+ duration: plotTweenConfigs.duration,
+ interval: plotTweenConfigs.interval,
+ });
+ plot(position$).addListener(makeAssertPlot(done, assert, `
+| #
+|
+| #
+| #
+|
+| #
+| #
+|
+| #
+| #
+| #
+|
+| #
+| #
+| #
+| #
+| #
+| #
+| ##
+| ##
+|###
++---------------------`));
+ });
+});
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 5e5fc70..31d2a7d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -29,6 +29,7 @@
"src/extra/fromEvent.ts",
"src/extra/pairwise.ts",
"src/extra/split.ts",
+ "src/extra/tween.ts",
"src/index.ts"
],
"filesGlob": [