diff --git a/src/generators/dom/visitors/Element/addTransitions.js b/src/generators/dom/visitors/Element/addTransitions.js
index e7fda98331ba..6a5953d02ce2 100644
--- a/src/generators/dom/visitors/Element/addTransitions.js
+++ b/src/generators/dom/visitors/Element/addTransitions.js
@@ -14,14 +14,14 @@ export default function addTransitions ( generator, block, state, node, intro, o
 		block.builders.intro.addBlock( deindent`
 			${block.component}._renderHooks.push( function () {
 				if ( !${name} ) ${name} = ${wrapTransition}( ${state.name}, ${fn}, ${snippet}, true, null );
-				${name}.run( ${name}.t, 1, function () {
+				${name}.run( true, function () {
 					${block.component}.fire( 'intro.end', { node: ${state.name} });
 				});
 			});
 		` );
 
 		block.builders.outro.addBlock( deindent`
-			${name}.run( ${name}.t, 0, function () {
+			${name}.run( false, function () {
 				${block.component}.fire( 'outro.end', { node: ${state.name} });
 				if ( --${block.alias( 'outros' )} === 0 ) ${block.alias( 'outrocallback' )}();
 				${name} = null;
@@ -49,7 +49,7 @@ export default function addTransitions ( generator, block, state, node, intro, o
 			block.builders.intro.addBlock( deindent`
 				${block.component}._renderHooks.push( function () {
 					${introName} = ${wrapTransition}( ${state.name}, ${fn}, ${snippet}, true, null );
-					${introName}.run( 0, 1, function () {
+					${introName}.run( true, function () {
 						${block.component}.fire( 'intro.end', { node: ${state.name} });
 					});
 				});
@@ -66,7 +66,7 @@ export default function addTransitions ( generator, block, state, node, intro, o
 			// group) prior to their removal from the DOM
 			block.builders.outro.addBlock( deindent`
 				${outroName} = ${wrapTransition}( ${state.name}, ${fn}, ${snippet}, false, null );
-				${outroName}.run( 1, 0, function () {
+				${outroName}.run( false, function () {
 					${block.component}.fire( 'outro.end', { node: ${state.name} });
 					if ( --${block.alias( 'outros' )} === 0 ) ${block.alias( 'outrocallback' )}();
 				});
diff --git a/src/shared/transitions.js b/src/shared/transitions.js
index 289ded618220..916a69b3ddc0 100644
--- a/src/shared/transitions.js
+++ b/src/shared/transitions.js
@@ -28,7 +28,7 @@ export function generateKeyframes ( a, b, delta, duration, ease, fn, node, style
 }
 
 export function wrapTransition ( node, fn, params, intro, outgroup ) {
-	var obj = fn( node, params, intro );
+	var obj = fn( node, params );
 	var duration = obj.duration || 300;
 	var ease = obj.easing || linear;
 
@@ -40,26 +40,21 @@ export function wrapTransition ( node, fn, params, intro, outgroup ) {
 	if ( intro && obj.tick ) obj.tick( 0 );
 
 	return {
-		start: null,
-		end: null,
-		a: null,
-		b: null,
-		d: null,
-		running: false,
 		t: intro ? 0 : 1,
-		callback: null,
-		run: function ( a, b, callback ) {
-			this.a = a;
-			this.b = b;
-			this.delta = b - a;
-			this.start = window.performance.now() + ( obj.delay || 0 );
-			this.duration = duration * Math.abs( b - a );
-			this.end = this.start + this.duration;
-
-			this.callback = callback;
-
-			if ( obj.css ) {
-				generateKeyframes( this.a, this.b, this.delta, this.duration, ease, obj.css, node, style );
+		running: false,
+		program: null,
+		pending: null,
+		run: function ( intro, callback ) {
+			var program = {
+				start: window.performance.now() + ( obj.delay || 0 ),
+				intro: intro,
+				callback: callback
+			};
+
+			if ( obj.delay ) {
+				this.pending = program;
+			} else {
+				this.start( program );
 			}
 
 			if ( !this.running ) {
@@ -67,20 +62,40 @@ export function wrapTransition ( node, fn, params, intro, outgroup ) {
 				transitionManager.add( this );
 			}
 		},
+		start: function ( program ) {
+			program.a = this.t;
+			program.b = program.intro ? 1 : 0;
+			program.delta = program.b - program.a;
+			program.duration = duration * Math.abs( program.b - program.a );
+			program.end = program.start + program.duration;
+
+			if ( obj.css ) {
+				generateKeyframes( program.a, program.b, program.delta, program.duration, ease, obj.css, node, style );
+			}
+
+			this.program = program;
+			this.pending = null;
+		},
 		update: function ( now ) {
-			var p = now - this.start;
-			this.t = this.a + this.delta * ease( p / this.duration );
+			var program = this.program;
+			if ( !program ) return;
+
+			var p = now - program.start;
+			this.t = program.a + program.delta * ease( p / program.duration );
 			if ( obj.tick ) obj.tick( this.t );
 		},
 		done: function () {
-			if ( obj.tick ) obj.tick( intro ? 1 : 0 );
+			this.t = this.program.b;
+			if ( obj.tick ) obj.tick( this.t );
 			if ( obj.css ) document.head.removeChild( style );
-			this.callback();
-			this.running = false;
+			this.program.callback();
+			this.program = null;
+			this.running = !!this.pending;
 		},
 		abort: function () {
 			if ( obj.tick ) obj.tick( 1 );
 			if ( obj.css ) document.head.removeChild( style );
+			this.program = this.pending = null;
 			this.running = false;
 		}
 	};
@@ -108,16 +123,18 @@ export var transitionManager = {
 		while ( i-- ) {
 			var transition = transitionManager.transitions[i];
 
-			if ( transition.running ) {
-				if ( now >= transition.end ) {
-					transition.running = false;
-					transition.done();
-				} else if ( now > transition.start ) {
-					transition.update( now );
-				}
+			if ( transition.program && now >= transition.program.end ) {
+				transition.done();
+			}
+
+			if ( transition.pending && now >= transition.pending.start ) {
+				transition.start( transition.pending );
+			}
 
+			if ( transition.running ) {
+				transition.update( now );
 				transitionManager.running = true;
-			} else {
+			} else if ( !transition.pending ) {
 				transitionManager.transitions.splice( i, 1 );
 			}
 		}
diff --git a/src/validate/html/validateElement.js b/src/validate/html/validateElement.js
index 42e4c75f30f0..74034ceb0838 100644
--- a/src/validate/html/validateElement.js
+++ b/src/validate/html/validateElement.js
@@ -3,6 +3,10 @@ import validateEventHandler from './validateEventHandler.js';
 export default function validateElement ( validator, node ) {
 	const isComponent = node.name === ':Self' || validator.components.has( node.name );
 
+	let hasIntro;
+	let hasOutro;
+	let hasTransition;
+
 	node.attributes.forEach( attribute => {
 		if ( !isComponent && attribute.type === 'Binding' ) {
 			const { name } = attribute;
@@ -46,9 +50,31 @@ export default function validateElement ( validator, node ) {
 			}
 		}
 
-		if ( attribute.type === 'EventHandler' ) {
+		else if ( attribute.type === 'EventHandler' ) {
 			validateEventHandler( validator, attribute );
 		}
+
+		else if ( attribute.type === 'Transition' ) {
+			const bidi = attribute.intro && attribute.outro;
+
+			if ( hasTransition ) {
+				if ( bidi ) validator.error( `An element can only have one 'transition' directive`, attribute.start );
+				validator.error( `An element cannot have both a 'transition' directive and an '${attribute.intro ? 'in' : 'out'}' directive`, attribute.start );
+			}
+
+			if ( ( hasIntro && attribute.intro ) || ( hasOutro && attribute.outro ) ) {
+				if ( bidi ) validator.error( `An element cannot have both an '${hasIntro ? 'in' : 'out'}' directive and a 'transition' directive`, attribute.start );
+				validator.error( `An element can only have one '${hasIntro ? 'in' : 'out'}' directive`, attribute.start );
+			}
+
+			if ( attribute.intro ) hasIntro = true;
+			if ( attribute.outro ) hasOutro = true;
+			if ( bidi ) hasTransition = true;
+
+			if ( !validator.transitions.has( attribute.name ) ) {
+				validator.error( `Missing transition '${attribute.name}'`, attribute.start );
+			}
+		}
 	});
 }
 
diff --git a/src/validate/index.js b/src/validate/index.js
index 8040549e5693..8991803ba5a2 100644
--- a/src/validate/index.js
+++ b/src/validate/index.js
@@ -43,7 +43,8 @@ export default function validate ( parsed, source, { onerror, onwarn, name, file
 		properties: {},
 		components: new Map(),
 		methods: new Map(),
-		helpers: new Map()
+		helpers: new Map(),
+		transitions: new Map()
 	};
 
 	try {
diff --git a/src/validate/js/index.js b/src/validate/js/index.js
index 4d237e9f016a..5b101502ef94 100644
--- a/src/validate/js/index.js
+++ b/src/validate/js/index.js
@@ -63,7 +63,7 @@ export default function validateJs ( validator, js ) {
 		}
 	});
 
-	[ 'components', 'methods', 'helpers' ].forEach( key => {
+	[ 'components', 'methods', 'helpers', 'transitions' ].forEach( key => {
 		if ( validator.properties[ key ] ) {
 			validator.properties[ key ].value.properties.forEach( prop => {
 				validator[ key ].set( prop.key.name, prop.value );
diff --git a/test/runtime/samples/transition-js-delay-in-out/_config.js b/test/runtime/samples/transition-js-delay-in-out/_config.js
new file mode 100644
index 000000000000..6e97f19c3b2e
--- /dev/null
+++ b/test/runtime/samples/transition-js-delay-in-out/_config.js
@@ -0,0 +1,24 @@
+export default {
+	test ( assert, component, target, window, raf ) {
+		component.set({ visible: true });
+		const div = target.querySelector( 'div' );
+		assert.equal( div.foo, 0 );
+
+		raf.tick( 50 );
+		assert.equal( div.foo, 0 );
+
+		raf.tick( 150 );
+		assert.equal( div.foo, 1 );
+
+		component.set({ visible: false });
+		assert.equal( div.bar, undefined );
+
+		raf.tick( 200 );
+		assert.equal( div.bar, 1 );
+
+		raf.tick( 300 );
+		assert.equal( div.bar, 0 );
+
+		component.destroy();
+	}
+};
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-delay-in-out/main.html b/test/runtime/samples/transition-js-delay-in-out/main.html
new file mode 100644
index 000000000000..6a0a3f88a7ae
--- /dev/null
+++ b/test/runtime/samples/transition-js-delay-in-out/main.html
@@ -0,0 +1,29 @@
+{{#if visible}}
+	<div in:foo out:bar>delayed</div>
+{{/if}}
+
+<script>
+	export default {
+		transitions: {
+			foo: function ( node, params ) {
+				return {
+					delay: 50,
+					duration: 100,
+					tick: t => {
+						node.foo = t;
+					}
+				};
+			},
+
+			bar: function ( node, params ) {
+				return {
+					delay: 50,
+					duration: 100,
+					tick: t => {
+						node.bar = t;
+					}
+				};
+			}
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-delay/_config.js b/test/runtime/samples/transition-js-delay/_config.js
new file mode 100644
index 000000000000..eab832ca3fd7
--- /dev/null
+++ b/test/runtime/samples/transition-js-delay/_config.js
@@ -0,0 +1,29 @@
+export default {
+	test ( assert, component, target, window, raf ) {
+		component.set({ visible: true });
+		const div = target.querySelector( 'div' );
+		assert.equal( div.foo, 0 );
+
+		raf.tick( 50 );
+		assert.equal( div.foo, 0 );
+
+		raf.tick( 100 );
+		assert.equal( div.foo, 0.5 );
+
+		component.set({ visible: false });
+
+		raf.tick( 125 );
+		assert.equal( div.foo, 0.75 );
+
+		raf.tick( 150 );
+		assert.equal( div.foo, 1 );
+
+		raf.tick( 175 );
+		assert.equal( div.foo, 0.75 );
+
+		raf.tick( 250 );
+		assert.equal( div.foo, 0 );
+
+		component.destroy();
+	}
+};
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-delay/main.html b/test/runtime/samples/transition-js-delay/main.html
new file mode 100644
index 000000000000..a4277f34cebb
--- /dev/null
+++ b/test/runtime/samples/transition-js-delay/main.html
@@ -0,0 +1,19 @@
+{{#if visible}}
+	<div transition:foo>delayed</div>
+{{/if}}
+
+<script>
+	export default {
+		transitions: {
+			foo: function ( node, params ) {
+				return {
+					delay: 50,
+					duration: 100,
+					tick: t => {
+						node.foo = t;
+					}
+				};
+			}
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-dynamic-if-block-bidi/_config.js b/test/runtime/samples/transition-js-dynamic-if-block-bidi/_config.js
index fcf391ac60c3..65dece1a13e0 100644
--- a/test/runtime/samples/transition-js-dynamic-if-block-bidi/_config.js
+++ b/test/runtime/samples/transition-js-dynamic-if-block-bidi/_config.js
@@ -11,7 +11,7 @@ export default {
 		const div = target.querySelector( 'div' );
 		assert.equal( div.foo, 0 );
 
-		raf.tick( 300 );
+		raf.tick( 75 );
 		component.set({ name: 'everybody' });
 		assert.equal( div.foo, 0.75 );
 		assert.htmlEqual( div.innerHTML, 'hello everybody!' );
@@ -19,18 +19,18 @@ export default {
 		component.set({ visible: false, name: 'again' });
 		assert.htmlEqual( div.innerHTML, 'hello everybody!' );
 
-		raf.tick( 500 );
+		raf.tick( 125 );
 		assert.equal( div.foo, 0.25 );
 
 		component.set({ visible: true });
-		raf.tick( 700 );
+		raf.tick( 175 );
 		assert.equal( div.foo, 0.75 );
 		assert.htmlEqual( div.innerHTML, 'hello again!' );
 
-		raf.tick( 800 );
+		raf.tick( 200 );
 		assert.equal( div.foo, 1 );
 
-		raf.tick( 900 );
+		raf.tick( 225 );
 
 		component.destroy();
 	}
diff --git a/test/runtime/samples/transition-js-dynamic-if-block-bidi/main.html b/test/runtime/samples/transition-js-dynamic-if-block-bidi/main.html
index bc0dace68b5d..576b4efc2e91 100644
--- a/test/runtime/samples/transition-js-dynamic-if-block-bidi/main.html
+++ b/test/runtime/samples/transition-js-dynamic-if-block-bidi/main.html
@@ -8,7 +8,7 @@
 			foo: function ( node, params ) {
 				global.count += 1;
 				return {
-					duration: 400,
+					duration: 100,
 					tick: t => {
 						node.foo = t;
 					}
diff --git a/test/validator/samples/transition-duplicate-in-transition/errors.json b/test/validator/samples/transition-duplicate-in-transition/errors.json
new file mode 100644
index 000000000000..c48f56ede9a5
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-in-transition/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "An element cannot have both an 'in' directive and a 'transition' directive",
+	"loc": {
+		"line": 1,
+		"column": 12
+	},
+	"pos": 12
+}]
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-in-transition/input.html b/test/validator/samples/transition-duplicate-in-transition/input.html
new file mode 100644
index 000000000000..6f47e754f4d1
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-in-transition/input.html
@@ -0,0 +1,10 @@
+<div in:foo transition:bar>...</div>
+
+<script>
+	export default {
+		transitions: {
+			foo,
+			bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-in/errors.json b/test/validator/samples/transition-duplicate-in/errors.json
new file mode 100644
index 000000000000..a3cc8b0ec580
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-in/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "An element can only have one 'in' directive",
+	"loc": {
+		"line": 1,
+		"column": 12
+	},
+	"pos": 12
+}]
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-in/input.html b/test/validator/samples/transition-duplicate-in/input.html
new file mode 100644
index 000000000000..b9a9218f8e71
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-in/input.html
@@ -0,0 +1,10 @@
+<div in:foo in:bar>...</div>
+
+<script>
+	export default {
+		transitions: {
+			foo,
+			bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-out-transition/errors.json b/test/validator/samples/transition-duplicate-out-transition/errors.json
new file mode 100644
index 000000000000..f4bfa61ef024
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-out-transition/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "An element cannot have both an 'out' directive and a 'transition' directive",
+	"loc": {
+		"line": 1,
+		"column": 13
+	},
+	"pos": 13
+}]
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-out-transition/input.html b/test/validator/samples/transition-duplicate-out-transition/input.html
new file mode 100644
index 000000000000..ae2582b9427b
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-out-transition/input.html
@@ -0,0 +1,10 @@
+<div out:foo transition:bar>...</div>
+
+<script>
+	export default {
+		transitions: {
+			foo,
+			bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-out/errors.json b/test/validator/samples/transition-duplicate-out/errors.json
new file mode 100644
index 000000000000..988dc02bbe3a
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-out/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "An element can only have one 'out' directive",
+	"loc": {
+		"line": 1,
+		"column": 13
+	},
+	"pos": 13
+}]
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-out/input.html b/test/validator/samples/transition-duplicate-out/input.html
new file mode 100644
index 000000000000..949dae8638ba
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-out/input.html
@@ -0,0 +1,10 @@
+<div out:foo out:bar>...</div>
+
+<script>
+	export default {
+		transitions: {
+			foo,
+			bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-transition-in/errors.json b/test/validator/samples/transition-duplicate-transition-in/errors.json
new file mode 100644
index 000000000000..678ad4dd38c9
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-transition-in/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "An element cannot have both a 'transition' directive and an 'in' directive",
+	"loc": {
+		"line": 1,
+		"column": 20
+	},
+	"pos": 20
+}]
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-transition-in/input.html b/test/validator/samples/transition-duplicate-transition-in/input.html
new file mode 100644
index 000000000000..bc105c2079d1
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-transition-in/input.html
@@ -0,0 +1,10 @@
+<div transition:foo in:bar>...</div>
+
+<script>
+	export default {
+		transitions: {
+			foo,
+			bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-transition-out/errors.json b/test/validator/samples/transition-duplicate-transition-out/errors.json
new file mode 100644
index 000000000000..31dc180b5a8d
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-transition-out/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "An element cannot have both a 'transition' directive and an 'out' directive",
+	"loc": {
+		"line": 1,
+		"column": 20
+	},
+	"pos": 20
+}]
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-transition-out/input.html b/test/validator/samples/transition-duplicate-transition-out/input.html
new file mode 100644
index 000000000000..ac82cb064e1d
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-transition-out/input.html
@@ -0,0 +1,10 @@
+<div transition:foo out:bar>...</div>
+
+<script>
+	export default {
+		transitions: {
+			foo,
+			bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-transition/errors.json b/test/validator/samples/transition-duplicate-transition/errors.json
new file mode 100644
index 000000000000..585ff3745106
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-transition/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "An element can only have one 'transition' directive",
+	"loc": {
+		"line": 1,
+		"column": 20
+	},
+	"pos": 20
+}]
\ No newline at end of file
diff --git a/test/validator/samples/transition-duplicate-transition/input.html b/test/validator/samples/transition-duplicate-transition/input.html
new file mode 100644
index 000000000000..29af279564ad
--- /dev/null
+++ b/test/validator/samples/transition-duplicate-transition/input.html
@@ -0,0 +1,10 @@
+<div transition:foo transition:bar>...</div>
+
+<script>
+	export default {
+		transitions: {
+			foo,
+			bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/transition-missing/errors.json b/test/validator/samples/transition-missing/errors.json
new file mode 100644
index 000000000000..4f2b88c2f6fc
--- /dev/null
+++ b/test/validator/samples/transition-missing/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "Missing transition 'foo'",
+	"loc": {
+		"line": 1,
+		"column": 5
+	},
+	"pos": 5
+}]
\ No newline at end of file
diff --git a/test/validator/samples/transition-missing/input.html b/test/validator/samples/transition-missing/input.html
new file mode 100644
index 000000000000..5d0e1b7067c7
--- /dev/null
+++ b/test/validator/samples/transition-missing/input.html
@@ -0,0 +1 @@
+<div in:foo>...</div>
\ No newline at end of file