diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 38827ea35a..c97aad482e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -45,6 +45,7 @@ loadMore: "もっと見る"
 youGotNewFollower: "フォローされました"
 receiveFollowRequest: "フォローリクエストされました"
 followRequestAccepted: "フォローが承認されました"
+mention: "メンション"
 mentions: "あなた宛て"
 directNotes: "ダイレクト投稿"
 importAndExport: "インポートとエクスポート"
@@ -104,6 +105,7 @@ suspendConfirm: "凍結しますか?"
 unsuspendConfirm: "解凍しますか?"
 selectList: "リストを選択"
 customEmojis: "カスタム絵文字"
+emoji: "絵文字"
 emojiName: "絵文字名"
 emojiUrl: "絵文字画像URL"
 addEmoji: "絵文字を追加"
@@ -510,6 +512,9 @@ serviceworkerInfo: "プッシュ通知を行うには有効する必要があり
 deletedNote: "削除された投稿"
 invisibleNote: "非公開の投稿"
 enableInfiniteScroll: "自動でもっと見る"
+visibility: "公開範囲"
+poll: "アンケート"
+useCw: "内容を隠す"
 
 _theme:
   explore: "テーマを探す"
@@ -648,7 +653,6 @@ _cw:
   show: "もっと見る"
   chars: "{count}文字"
   files: "{count}ファイル"
-  poll: "アンケート"
 
 _poll:
   noOnlyOneChoice: "選択肢は最低2つ必要です"
@@ -1119,3 +1123,4 @@ _notification:
   youReceivedFollowRequest: "フォローリクエストが来ました"
   yourFollowRequestAccepted: "フォローリクエストが承認されました"
   youWereInvitedToGroup: "グループに招待されました"
+
diff --git a/src/client/components/cw-button.vue b/src/client/components/cw-button.vue
index 07a44d970f..16a9b84f62 100644
--- a/src/client/components/cw-button.vue
+++ b/src/client/components/cw-button.vue
@@ -27,7 +27,7 @@ export default Vue.extend({
 			return concat([
 				this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [],
 				this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [],
-				this.note.poll != null ? [this.$t('_cw.poll')] : []
+				this.note.poll != null ? [this.$t('poll')] : []
 			] as string[][]).join(' / ');
 		}
 	},
diff --git a/src/client/components/link.vue b/src/client/components/link.vue
index 4c709375d3..7a364d0986 100644
--- a/src/client/components/link.vue
+++ b/src/client/components/link.vue
@@ -62,13 +62,13 @@ export default Vue.extend({
 			}
 		},
 		onMouseover() {
-			if (isDeviceTouch()) return;
+			if (isDeviceTouch) return;
 			clearTimeout(this.showTimer);
 			clearTimeout(this.hideTimer);
 			this.showTimer = setTimeout(this.showPreview, 500);
 		},
 		onMouseleave() {
-			if (isDeviceTouch()) return;
+			if (isDeviceTouch) return;
 			clearTimeout(this.showTimer);
 			clearTimeout(this.hideTimer);
 			this.hideTimer = setTimeout(this.closePreview, 500);
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index cdb61f51d5..ee6148a355 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -9,13 +9,13 @@
 		<button v-if="!fixed" class="cancel _button" @click="cancel"><fa :icon="faTimes"/></button>
 		<div>
 			<span class="text-count" :class="{ over: trimmedLength(text) > max }">{{ max - trimmedLength(text) }}</span>
-			<button class="_button visibility" @click="setVisibility" ref="visibilityButton">
+			<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$t('visibility')">
 				<span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span>
 				<span v-if="visibility === 'home'"><fa :icon="faHome"/></span>
 				<span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span>
 				<span v-if="visibility === 'specified'"><fa :icon="faEnvelope"/></span>
 			</button>
-			<button class="_button localOnly" v-if="visibility !== 'specified'" @click="localOnly = !localOnly" :class="{ active: localOnly }"><fa :icon="faBiohazard"/></button>
+			<button class="_button localOnly" v-if="visibility !== 'specified'" @click="localOnly = !localOnly" :class="{ active: localOnly }" v-tooltip="$t('_visibility.localOnly')"><fa :icon="faBiohazard"/></button>
 			<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button>
 		</div>
 	</header>
@@ -26,7 +26,7 @@
 		<div v-if="visibility === 'specified'" class="to-specified">
 			<span style="margin-right: 8px;">{{ $t('recipient') }}</span>
 			<div class="visibleUsers">
-				<span v-for="u in visibleUsers">
+				<span v-for="u in visibleUsers" :key="u.id">
 					<mk-acct :user="u"/>
 					<button class="_button" @click="removeVisibleUser(u)"><fa :icon="faTimes"/></button>
 				</span>
@@ -39,11 +39,11 @@
 		<x-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/>
 		<x-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/>
 		<footer>
-			<button class="_button" @click="chooseFileFrom"><fa :icon="faPhotoVideo"/></button>
-			<button class="_button" @click="poll = !poll" :class="{ active: poll }"><fa :icon="faPollH"/></button>
-			<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }"><fa :icon="faEyeSlash"/></button>
-			<button class="_button" @click="insertMention"><fa :icon="faAt"/></button>
-			<button class="_button" @click="insertEmoji"><fa :icon="faLaughSquint"/></button>
+			<button class="_button" @click="chooseFileFrom" v-tooltip="$t('attachFile')"><fa :icon="faPhotoVideo"/></button>
+			<button class="_button" @click="poll = !poll" :class="{ active: poll }" v-tooltip="$t('poll')"><fa :icon="faPollH"/></button>
+			<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$t('useCw')"><fa :icon="faEyeSlash"/></button>
+			<button class="_button" @click="insertMention" v-tooltip="$t('mention')"><fa :icon="faAt"/></button>
+			<button class="_button" @click="insertEmoji" v-tooltip="$t('emoji')"><fa :icon="faLaughSquint"/></button>
 		</footer>
 		<input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/>
 	</div>
@@ -576,7 +576,7 @@ export default Vue.extend({
 				insertTextAtCursor(this.$refs.text, emoji);
 				vm.close();
 			});
-		}
+		},
 	}
 });
 </script>
diff --git a/src/client/components/reactions-viewer.details.vue b/src/client/components/reactions-viewer.details.vue
index 67c8b261be..96d1408fc1 100644
--- a/src/client/components/reactions-viewer.details.vue
+++ b/src/client/components/reactions-viewer.details.vue
@@ -1,27 +1,29 @@
 <template>
-<transition name="zoom-in-top">
-	<div class="buebdbiu" ref="popover" v-if="show">
-		<template v-if="users.length <= 10">
-			<b v-for="u in users" :key="u.id" style="margin-right: 12px;">
-				<mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
-				<mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/>
-			</b>
-		</template>
-		<template v-if="10 < users.length">
-			<b v-for="u in users" :key="u.id" style="margin-right: 12px;">
-				<mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
-				<mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/>
-			</b>
-			<span slot="omitted">+{{ count - 10 }}</span>
-		</template>
-	</div>
-</transition>
+<mk-tooltip :source="source" ref="tooltip">
+	<template v-if="users.length <= 10">
+		<b v-for="u in users" :key="u.id" style="margin-right: 12px;">
+			<mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
+			<mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/>
+		</b>
+	</template>
+	<template v-if="10 < users.length">
+		<b v-for="u in users" :key="u.id" style="margin-right: 12px;">
+			<mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
+			<mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/>
+		</b>
+		<span slot="omitted">+{{ count - 10 }}</span>
+	</template>
+</mk-tooltip>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
+import MkTooltip from './ui/tooltip.vue';
 
 export default Vue.extend({
+	components: {
+		MkTooltip
+	},
 	props: {
 		reaction: {
 			type: String,
@@ -39,77 +41,11 @@ export default Vue.extend({
 			required: true,
 		}
 	},
-	data() {
-		return {
-			show: false
-		};
-	},
-	mounted() {
-		this.show = true;
 
-		this.$nextTick(() => {
-			const popover = this.$refs.popover as any;
-
-			if (this.source == null) {
-				this.destroyDom();
-				return;
-			}
-			const rect = this.source.getBoundingClientRect();
-
-			const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
-			const y = rect.top + window.pageYOffset + this.source.offsetHeight;
-			popover.style.left = (x - 28) + 'px';
-			popover.style.top = (y + 16) + 'px';
-		});
-	}
 	methods: {
 		close() {
-			this.show = false;
-			setTimeout(this.destroyDom, 300);
+			this.$refs.tooltip.close();
 		}
 	}
 })
 </script>
-
-<style lang="scss" scoped>
-.buebdbiu {
-	z-index: 10000;
-	display: block;
-	position: absolute;
-	max-width: 240px;
-	font-size: 0.8em;
-	padding: 6px 8px;
-	background: var(--panel);
-	text-align: center;
-	border-radius: 4px;
-	box-shadow: 0 2px 8px rgba(0,0,0,0.25);
-	pointer-events: none;
-	transform-origin: center -16px;
-
-	&:before {
-		content: "";
-		pointer-events: none;
-		display: block;
-		position: absolute;
-		top: -28px;
-		left: 12px;
-		border-top: solid 14px transparent;
-		border-right: solid 14px transparent;
-		border-bottom: solid 14px rgba(0,0,0,0.1);
-		border-left: solid 14px transparent;
-	}
-
-	&:after {
-		content: "";
-		pointer-events: none;
-		display: block;
-		position: absolute;
-		top: -27px;
-		left: 12px;
-		border-top: solid 14px transparent;
-		border-right: solid 14px transparent;
-		border-bottom: solid 14px var(--panel);
-		border-left: solid 14px transparent;
-	}
-}
-</style>
diff --git a/src/client/components/reactions-viewer.reaction.vue b/src/client/components/reactions-viewer.reaction.vue
index 33911dedb8..6b72f2e105 100644
--- a/src/client/components/reactions-viewer.reaction.vue
+++ b/src/client/components/reactions-viewer.reaction.vue
@@ -4,8 +4,10 @@
 	:class="{ reacted: note.myReaction == reaction, canToggle }"
 	@click="toggleReaction(reaction)"
 	v-if="count > 0"
+	@touchstart="onMouseover"
 	@mouseover="onMouseover"
 	@mouseleave="onMouseleave"
+	@touchend="onMouseleave"
 	ref="reaction"
 	v-particle
 >
@@ -90,16 +92,17 @@ export default Vue.extend({
 			}
 		},
 		onMouseover() {
+			if (this.isHovering) return;
 			this.isHovering = true;
 			this.detailsTimeoutId = setTimeout(this.openDetails, 300);
 		},
 		onMouseleave() {
+			if (!this.isHovering) return;
 			this.isHovering = false;
 			clearTimeout(this.detailsTimeoutId);
 			this.closeDetails();
 		},
 		openDetails() {
-			if (this.$root.isMobile) return;
 			this.$root.api('notes/reactions', {
 				noteId: this.note.id,
 				type: this.reaction,
diff --git a/src/client/components/ui/tooltip.vue b/src/client/components/ui/tooltip.vue
new file mode 100644
index 0000000000..b7a56708b7
--- /dev/null
+++ b/src/client/components/ui/tooltip.vue
@@ -0,0 +1,96 @@
+<template>
+<transition name="zoom-in-top" appear>
+	<div class="buebdbiu" v-if="show">
+		<slot>{{ text }}</slot>
+	</div>
+</transition>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	props: {
+		source: {
+			required: true,
+		},
+		text: {
+			type: String,
+			required: false
+		}
+	},
+
+	data() {
+		return {
+			show: false
+		};
+	},
+
+	mounted() {
+		this.show = true;
+
+		this.$nextTick(() => {
+			if (this.source == null) {
+				this.destroyDom();
+				return;
+			}
+			const rect = this.source.getBoundingClientRect();
+
+			const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
+			const y = rect.top + window.pageYOffset + this.source.offsetHeight;
+			this.$el.style.left = (x - 28) + 'px';
+			this.$el.style.top = (y + 16) + 'px';
+		});
+	},
+
+	methods: {
+		close() {
+			this.show = false;
+			setTimeout(this.destroyDom, 300);
+		}
+	}
+})
+</script>
+
+<style lang="scss" scoped>
+.buebdbiu {
+	z-index: 11000;
+	display: block;
+	position: absolute;
+	max-width: 240px;
+	font-size: 0.8em;
+	padding: 6px 8px;
+	background: var(--panel);
+	text-align: center;
+	border-radius: 4px;
+	box-shadow: 0 2px 8px rgba(0,0,0,0.25);
+	pointer-events: none;
+	transform-origin: center -16px;
+
+	&:before {
+		content: "";
+		pointer-events: none;
+		display: block;
+		position: absolute;
+		top: -28px;
+		left: 12px;
+		border-top: solid 14px transparent;
+		border-right: solid 14px transparent;
+		border-bottom: solid 14px rgba(0,0,0,0.1);
+		border-left: solid 14px transparent;
+	}
+
+	&:after {
+		content: "";
+		pointer-events: none;
+		display: block;
+		position: absolute;
+		top: -27px;
+		left: 12px;
+		border-top: solid 14px transparent;
+		border-right: solid 14px transparent;
+		border-bottom: solid 14px var(--panel);
+		border-left: solid 14px transparent;
+	}
+}
+</style>
diff --git a/src/client/components/url.vue b/src/client/components/url.vue
index 4dd23a50ed..0a5a5bc508 100644
--- a/src/client/components/url.vue
+++ b/src/client/components/url.vue
@@ -93,13 +93,13 @@ export default Vue.extend({
 			}
 		},
 		onMouseover() {
-			if (isDeviceTouch()) return;
+			if (isDeviceTouch) return;
 			clearTimeout(this.showTimer);
 			clearTimeout(this.hideTimer);
 			this.showTimer = setTimeout(this.showPreview, 500);
 		},
 		onMouseleave() {
-			if (isDeviceTouch()) return;
+			if (isDeviceTouch) return;
 			clearTimeout(this.showTimer);
 			clearTimeout(this.hideTimer);
 			this.hideTimer = setTimeout(this.closePreview, 500);
diff --git a/src/client/directives/index.ts b/src/client/directives/index.ts
index 64d33c0ff3..8cd5ed464d 100644
--- a/src/client/directives/index.ts
+++ b/src/client/directives/index.ts
@@ -4,9 +4,11 @@ import userPreview from './user-preview';
 import autocomplete from './autocomplete';
 import size from './size';
 import particle from './particle';
+import tooltip from './tooltip';
 
 Vue.directive('autocomplete', autocomplete);
 Vue.directive('userPreview', userPreview);
 Vue.directive('user-preview', userPreview);
 Vue.directive('size', size);
 Vue.directive('particle', particle);
+Vue.directive('tooltip', tooltip);
diff --git a/src/client/directives/tooltip.ts b/src/client/directives/tooltip.ts
new file mode 100644
index 0000000000..28d22fc024
--- /dev/null
+++ b/src/client/directives/tooltip.ts
@@ -0,0 +1,62 @@
+import MkTooltip from '../components/ui/tooltip.vue';
+import { isDeviceTouch } from '../scripts/is-device-touch';
+
+const start = isDeviceTouch ? 'touchstart' : 'mouseover';
+const end = isDeviceTouch ? 'touchend' : 'mouseleave';
+
+export default {
+	bind(el: HTMLElement, binding, vn) {
+		const self = (el as any)._tooltipDirective_ = {} as any;
+
+		self.text = binding.value as string;
+		self.tag = null;
+		self.showTimer = null;
+		self.hideTimer = null;
+		self.checkTimer = null;
+
+		self.close = () => {
+			if (self.tag) {
+				clearInterval(self.checkTimer);
+				self.tag.close();
+				self.tag = null;
+			}
+		};
+
+		const show = e => {
+			if (!document.body.contains(el)) return;
+			if (self.tag) return;
+
+			self.tag = new MkTooltip({
+				parent: vn.context,
+				propsData: {
+					text: self.text,
+					source: el
+				}
+			}).$mount();
+
+			document.body.appendChild(self.tag.$el);
+		};
+
+		el.addEventListener(start, () => {
+			clearTimeout(self.showTimer);
+			clearTimeout(self.hideTimer);
+			self.showTimer = setTimeout(show, 300);
+		});
+
+		el.addEventListener(end, () => {
+			clearTimeout(self.showTimer);
+			clearTimeout(self.hideTimer);
+			self.hideTimer = setTimeout(self.close, 300);
+		});
+
+		el.addEventListener('click', () => {
+			clearTimeout(self.showTimer);
+			self.close();
+		});
+	},
+
+	unbind(el, binding, vn) {
+		const self = el._tooltipDirective_;
+		clearInterval(self.checkTimer);
+	},
+};
diff --git a/src/client/scripts/compose-notification.ts b/src/client/scripts/compose-notification.ts
index c3281955e4..1552d45e4e 100644
--- a/src/client/scripts/compose-notification.ts
+++ b/src/client/scripts/compose-notification.ts
@@ -5,7 +5,7 @@ import { clientDb, get, bulkGet } from '../db';
 const getTranslation = (text: string): Promise<string> => get(text, clientDb.i18n);
 
 export default async function(type, data): Promise<[string, NotificationOptions]> {
-	const contexts = ['deletedNote', 'invisibleNote', 'withNFiles', '_cw.poll'];
+	const contexts = ['deletedNote', 'invisibleNote', 'withNFiles', 'poll'];
 	const locale = Object.fromEntries(await bulkGet(contexts, clientDb.i18n) as [string, string][]);
 
 	switch (type) {
diff --git a/src/client/scripts/is-device-touch.ts b/src/client/scripts/is-device-touch.ts
index 9f439ae4fd..3f0bfefed2 100644
--- a/src/client/scripts/is-device-touch.ts
+++ b/src/client/scripts/is-device-touch.ts
@@ -1,3 +1 @@
-export function isDeviceTouch(): boolean {
-	return 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
-}
+export const isDeviceTouch = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
diff --git a/src/misc/get-note-summary.ts b/src/misc/get-note-summary.ts
index c23306ab11..7db8bca3ec 100644
--- a/src/misc/get-note-summary.ts
+++ b/src/misc/get-note-summary.ts
@@ -27,7 +27,7 @@ const summarize = (note: any, locale: any): string => {
 
 	// 投票が添付されているとき
 	if (note.poll) {
-		summary += ` (${locale._cw?.poll || locale['_cw.poll']})`;
+		summary += ` (${locale['poll']})`;
 	}
 
 	// 返信のとき