diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9e69e8f0a..f8291a1357 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ unreleased
 * 外部サービス認証情報の配信
 * 管理画面のモデレーションのUIを強化
 * 管理画面からリモートユーザーの情報を更新できるように
+* 回転構文の追加
 * シンタックスハイライトの強化
 * 引用投稿を削除したとき単なるRenoteとしてタイムラインに残る問題を修正
 * イタリック構文の判定の改善
diff --git a/src/client/app/animation.styl b/src/client/app/animation.styl
index a629165207..9cbd3ec6c8 100644
--- a/src/client/app/animation.styl
+++ b/src/client/app/animation.styl
@@ -26,3 +26,8 @@
 		transform: translateY(0);
 	}
 }
+
+@keyframes spin {
+	0% { transform: rotate(0deg); }
+	100% { transform: rotate(360deg); }
+}
diff --git a/src/client/app/common/views/components/mfm.ts b/src/client/app/common/views/components/mfm.ts
index 8ffa566666..f6f95deb24 100644
--- a/src/client/app/common/views/components/mfm.ts
+++ b/src/client/app/common/views/components/mfm.ts
@@ -124,6 +124,17 @@ export default Vue.component('misskey-flavored-markdown', {
 					}, genEl(token.children));
 				}
 
+				case 'spin': {
+					motionCount++;
+					const isLong = sumTextsLength(token.children) > 5 || countNodesF(token.children) > 3;
+					const isMany = motionCount > 3;
+					return (createElement as any)('span', {
+						attrs: {
+							style: (this.$store.state.settings.disableAnimatedMfm || isLong || isMany) ? 'display: inline-block;' : 'display: inline-block; animation: spin 1.5s linear infinite;'
+						},
+					}, genEl(token.children));
+				}
+
 				case 'url': {
 					return [createElement(MkUrl, {
 						key: Math.random(),
diff --git a/src/mfm/html.ts b/src/mfm/html.ts
index 6af2833858..a40ff19ac8 100644
--- a/src/mfm/html.ts
+++ b/src/mfm/html.ts
@@ -55,6 +55,12 @@ export default (tokens: MfmForest, mentionedRemoteUsers: INote['mentionedRemoteU
 			return el;
 		},
 
+		spin(token) {
+			const el = doc.createElement('i');
+			appendChildren(token.children, el);
+			return el;
+		},
+
 		blockCode(token) {
 			const pre = doc.createElement('pre');
 			const inner = doc.createElement('code');
diff --git a/src/mfm/parser.ts b/src/mfm/parser.ts
index 560e226af9..5cd9fc04c2 100644
--- a/src/mfm/parser.ts
+++ b/src/mfm/parser.ts
@@ -91,6 +91,7 @@ const mfm = P.createLanguage({
 	root: r => P.alt(
 		r.big,
 		r.small,
+		r.spin,
 		r.bold,
 		r.strike,
 		r.italic,
@@ -122,6 +123,7 @@ const mfm = P.createLanguage({
 			r.hashtag,
 			r.emoji,
 			r.math,
+			r.spin,
 			r.text
 		).atLeast(1).tryParse(x), {})),
 	//#endregion
@@ -140,6 +142,15 @@ const mfm = P.createLanguage({
 		).atLeast(1).tryParse(x), {})),
 	//#endregion
 
+	//#region Spin
+	spin: r =>
+		P.regexp(/<spin>(.+?)<\/spin>/, 1)
+		.map(x => createTree('spin', P.alt(
+			r.emoji,
+			r.text
+		).atLeast(1).tryParse(x), {})),
+	//#endregion
+
 	//#region Block code
 	blockCode: r =>
 		newline.then(
@@ -173,6 +184,7 @@ const mfm = P.createLanguage({
 		.map(x => createTree('center', P.alt(
 			r.big,
 			r.small,
+			r.spin,
 			r.bold,
 			r.strike,
 			r.italic,
@@ -261,6 +273,7 @@ const mfm = P.createLanguage({
 			return createTree('link', P.alt(
 				r.big,
 				r.small,
+				r.spin,
 				r.bold,
 				r.strike,
 				r.italic,
@@ -304,6 +317,7 @@ const mfm = P.createLanguage({
 		.map(x => createTree('motion', P.alt(
 			r.bold,
 			r.small,
+			r.spin,
 			r.strike,
 			r.italic,
 			r.mention,
@@ -364,6 +378,7 @@ const mfm = P.createLanguage({
 			const contents = P.alt(
 				r.big,
 				r.small,
+				r.spin,
 				r.bold,
 				r.strike,
 				r.italic,
diff --git a/test/mfm.ts b/test/mfm.ts
index a5ea4b2933..d8cba8ee15 100644
--- a/test/mfm.ts
+++ b/test/mfm.ts
@@ -244,6 +244,15 @@ describe('MFM', () => {
 			]);
 		});
 
+		it('spin', () => {
+			const tokens = analyze('<spin>:foo:</spin>');
+			assert.deepStrictEqual(tokens, [
+				tree('spin', [
+					leaf('emoji', { name: 'foo' })
+				], {}),
+			]);
+		});
+
 		describe('motion', () => {
 			it('by triple brackets', () => {
 				const tokens = analyze('(((foo)))');