diff --git a/src/client/components/autocomplete.vue b/src/client/components/autocomplete.vue
index 2ec6e8a3b9..1faa5aeb7d 100644
--- a/src/client/components/autocomplete.vue
+++ b/src/client/components/autocomplete.vue
@@ -376,7 +376,7 @@ export default defineComponent({
 
 		chooseUser() {
 			this.close();
-			const vm = os.popup(MkUserSelect, {});
+			const vm = os.modal(MkUserSelect, {});
 			vm.$once('selected', user => {
 				this.complete('user', user);
 			});
diff --git a/src/client/components/deck/notifications-column.vue b/src/client/components/deck/notifications-column.vue
index a8ffbaa575..94efe8a03f 100644
--- a/src/client/components/deck/notifications-column.vue
+++ b/src/client/components/deck/notifications-column.vue
@@ -43,7 +43,7 @@ export default defineComponent({
 			icon: faCog,
 			text: this.$t('notificationSetting'),
 			action: async () => {
-				os.popup(await import('../notification-setting-window.vue'), {
+				os.modal(await import('../notification-setting-window.vue'), {
 					includingTypes: this.column.includingTypes,
 				}).$on('ok', async ({ includingTypes }) => {
 					this.$set(this.column, 'includingTypes', includingTypes);
diff --git a/src/client/components/dialog.vue b/src/client/components/dialog.vue
index 8a2edf9da4..141d2ab7f7 100644
--- a/src/client/components/dialog.vue
+++ b/src/client/components/dialog.vue
@@ -1,46 +1,44 @@
 <template>
-<x-modal @closed="$emit('closed')" @click="onBgClick" :showing="showing">
-	<div class="mk-dialog" :class="{ iconOnly }">
-		<template v-if="type == 'signin'">
-			<mk-signin/>
-		</template>
-		<template v-else>
-			<div class="icon" v-if="icon">
-				<fa :icon="icon"/>
-			</div>
-			<div class="icon" v-else-if="!input && !select && !user" :class="type">
-				<fa :icon="faCheck" v-if="type === 'success'"/>
-				<fa :icon="faTimesCircle" v-if="type === 'error'"/>
-				<fa :icon="faExclamationTriangle" v-if="type === 'warning'"/>
-				<fa :icon="faInfoCircle" v-if="type === 'info'"/>
-				<fa :icon="faQuestionCircle" v-if="type === 'question'"/>
-				<fa :icon="faSpinner" pulse v-if="type === 'waiting'"/>
-			</div>
-			<header v-if="title" v-html="title"></header>
-			<header v-if="title == null && user">{{ $t('enterUsername') }}</header>
-			<div class="body" v-if="text" v-html="text"></div>
-			<mk-input v-if="input" v-model:value="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></mk-input>
-			<mk-input v-if="user" v-model:value="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></mk-input>
-			<mk-select v-if="select" v-model:value="selectedValue" autofocus>
-				<template v-if="select.items">
-					<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
-				</template>
-				<template v-else>
-					<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
-						<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
-					</optgroup>
-				</template>
-			</mk-select>
-			<div class="buttons" v-if="!iconOnly && (showOkButton || showCancelButton) && !actions">
-				<mk-button inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('ok') : $t('gotIt') }}</mk-button>
-				<mk-button inline @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('cancel') }}</mk-button>
-			</div>
-			<div class="buttons" v-if="actions">
-				<mk-button v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</mk-button>
-			</div>
-		</template>
-	</div>
-</x-modal>
+<div class="mk-dialog" :class="{ iconOnly }">
+	<template v-if="type == 'signin'">
+		<mk-signin/>
+	</template>
+	<template v-else>
+		<div class="icon" v-if="icon">
+			<fa :icon="icon"/>
+		</div>
+		<div class="icon" v-else-if="!input && !select && !user" :class="type">
+			<fa :icon="faCheck" v-if="type === 'success'"/>
+			<fa :icon="faTimesCircle" v-if="type === 'error'"/>
+			<fa :icon="faExclamationTriangle" v-if="type === 'warning'"/>
+			<fa :icon="faInfoCircle" v-if="type === 'info'"/>
+			<fa :icon="faQuestionCircle" v-if="type === 'question'"/>
+			<fa :icon="faSpinner" pulse v-if="type === 'waiting'"/>
+		</div>
+		<header v-if="title" v-html="title"></header>
+		<header v-if="title == null && user">{{ $t('enterUsername') }}</header>
+		<div class="body" v-if="text" v-html="text"></div>
+		<mk-input v-if="input" v-model:value="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></mk-input>
+		<mk-input v-if="user" v-model:value="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></mk-input>
+		<mk-select v-if="select" v-model:value="selectedValue" autofocus>
+			<template v-if="select.items">
+				<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
+			</template>
+			<template v-else>
+				<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
+					<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
+				</optgroup>
+			</template>
+		</mk-select>
+		<div class="buttons" v-if="!iconOnly && (showOkButton || showCancelButton) && !actions">
+			<mk-button inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('ok') : $t('gotIt') }}</mk-button>
+			<mk-button inline @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('cancel') }}</mk-button>
+		</div>
+		<div class="buttons" v-if="actions">
+			<mk-button v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</mk-button>
+		</div>
+	</template>
+</div>
 </template>
 
 <script lang="ts">
@@ -52,12 +50,10 @@ import MkInput from './ui/input.vue';
 import MkSelect from './ui/select.vue';
 import MkSignin from './signin.vue';
 import parseAcct from '../../misc/acct/parse';
-import XModal from './modal.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		XModal,
 		MkButton,
 		MkInput,
 		MkSelect,
@@ -65,9 +61,6 @@ export default defineComponent({
 	},
 
 	props: {
-		showing: {
-			required: true
-		},
 		type: {
 			type: String,
 			required: false,
@@ -118,7 +111,7 @@ export default defineComponent({
 		},
 	},
 
-	emits: ['done', 'closed'],
+	emits: ['done'],
 
 	data() {
 		return {
diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue
index 3facebd970..25469fb831 100644
--- a/src/client/components/media-image.vue
+++ b/src/client/components/media-image.vue
@@ -72,7 +72,7 @@ export default defineComponent({
 			if (this.$store.state.device.imageNewTab) {
 				window.open(this.image.url, '_blank');
 			} else {
-				const viewer = os.popup(ImageViewer, {
+				const viewer = os.modal(ImageViewer, {
 					image: this.image
 				});
 				this.$once('hook:beforeDestroy', () => {
diff --git a/src/client/components/menu.vue b/src/client/components/menu.vue
index 820e4c3dde..5b5a64335e 100644
--- a/src/client/components/menu.vue
+++ b/src/client/components/menu.vue
@@ -1,55 +1,43 @@
 <template>
-<x-modal :source="source" :no-center="noCenter" @click="close()" @closed="$emit('closed')" :showing="showing">
-	<div class="rrevdjwt" :class="{ left: align === 'left' }" ref="items" :style="{ width: width + 'px' }">
-		<template v-for="(item, i) in items.filter(item => item !== undefined)">
-			<div v-if="item === null" class="divider" :key="i"></div>
-			<span v-else-if="item.type === 'label'" class="label item" :key="i">
-				<span>{{ item.text }}</span>
-			</span>
-			<router-link v-else-if="item.type === 'link'" :to="item.to" @click.native="close()" :tabindex="i" class="_button item" :key="i">
-				<fa v-if="item.icon" :icon="item.icon" fixed-width/>
-				<mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
-				<span>{{ item.text }}</span>
-				<i v-if="item.indicate"><fa :icon="faCircle"/></i>
-			</router-link>
-			<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item" :key="i">
-				<fa v-if="item.icon" :icon="item.icon" fixed-width/>
-				<span>{{ item.text }}</span>
-				<i v-if="item.indicate"><fa :icon="faCircle"/></i>
-			</a>
-			<button v-else-if="item.type === 'user'" @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i">
-				<mk-avatar :user="item.user" class="avatar"/><mk-user-name :user="item.user"/>
-				<i v-if="item.indicate"><fa :icon="faCircle"/></i>
-			</button>
-			<button v-else @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i">
-				<fa v-if="item.icon" :icon="item.icon" fixed-width/>
-				<mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
-				<span>{{ item.text }}</span>
-				<i v-if="item.indicate"><fa :icon="faCircle"/></i>
-			</button>
-		</template>
-	</div>
-</x-modal>
+<div class="rrevdjwt" :class="{ left: align === 'left' }" ref="items" :style="{ width: width + 'px' }">
+	<template v-for="(item, i) in items.filter(item => item !== undefined)">
+		<div v-if="item === null" class="divider" :key="i"></div>
+		<span v-else-if="item.type === 'label'" class="label item" :key="i">
+			<span>{{ item.text }}</span>
+		</span>
+		<router-link v-else-if="item.type === 'link'" :to="item.to" @click.native="close()" :tabindex="i" class="_button item" :key="i">
+			<fa v-if="item.icon" :icon="item.icon" fixed-width/>
+			<mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+			<span>{{ item.text }}</span>
+			<i v-if="item.indicate"><fa :icon="faCircle"/></i>
+		</router-link>
+		<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item" :key="i">
+			<fa v-if="item.icon" :icon="item.icon" fixed-width/>
+			<span>{{ item.text }}</span>
+			<i v-if="item.indicate"><fa :icon="faCircle"/></i>
+		</a>
+		<button v-else-if="item.type === 'user'" @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i">
+			<mk-avatar :user="item.user" class="avatar"/><mk-user-name :user="item.user"/>
+			<i v-if="item.indicate"><fa :icon="faCircle"/></i>
+		</button>
+		<button v-else @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i">
+			<fa v-if="item.icon" :icon="item.icon" fixed-width/>
+			<mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+			<span>{{ item.text }}</span>
+			<i v-if="item.indicate"><fa :icon="faCircle"/></i>
+		</button>
+	</template>
+</div>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faCircle } from '@fortawesome/free-solid-svg-icons';
-import XModal from './modal.vue';
 import { focusPrev, focusNext } from '@/scripts/focus';
 import * as os from '@/os';
 
 export default defineComponent({
-	components: {
-		XModal
-	},
 	props: {
-		showing: {
-			required: true
-		},
-		source: {
-			required: true
-		},
 		items: {
 			type: Array,
 			required: true
diff --git a/src/client/components/modal.vue b/src/client/components/modal.vue
index 62ffc3f69c..7c016edc9e 100644
--- a/src/client/components/modal.vue
+++ b/src/client/components/modal.vue
@@ -18,6 +18,9 @@ import * as os from '@/os';
 // memo: 旧popup.vueのfixedプロパティに相当するものはsource要素の祖先を辿るなどして自動で判定できるのでは
 
 export default defineComponent({
+	provide: {
+		modal: true
+	},
 	props: {
 		showing: {
 			type: Boolean,
@@ -59,58 +62,60 @@ export default defineComponent({
 
 			const popover = this.$refs.content as any;
 
-			const rect = this.source.getBoundingClientRect();
-			const width = popover.offsetWidth;
-			const height = popover.offsetHeight;
+			new ResizeObserver((entries, observer) => {
+				const rect = this.source.getBoundingClientRect();
+				const width = popover.offsetWidth;
+				const height = popover.offsetHeight;
 
-			let left;
-			let top;
+				let left;
+				let top;
 
-			if (os.isMobile && !this.noCenter) {
-				const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2);
-				const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.source.offsetHeight / 2);
-				left = (x - (width / 2));
-				top = (y - (height / 2));
-				popover.style.transformOrigin = 'center';
-			} else {
-				const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2);
-				const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.source.offsetHeight;
-				left = (x - (width / 2));
-				top = y;
-			}
-
-			if (this.fixed) {
-				if (left + width > window.innerWidth) {
-					left = window.innerWidth - width;
+				if (os.isMobile && !this.noCenter) {
+					const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2);
+					const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.source.offsetHeight / 2);
+					left = (x - (width / 2));
+					top = (y - (height / 2));
 					popover.style.transformOrigin = 'center';
+				} else {
+					const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2);
+					const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.source.offsetHeight;
+					left = (x - (width / 2));
+					top = y;
 				}
 
-				if (top + height > window.innerHeight) {
-					top = window.innerHeight - height;
-					popover.style.transformOrigin = 'center';
-				}
-			} else {
-				if (left + width - window.pageXOffset > window.innerWidth) {
-					left = window.innerWidth - width + window.pageXOffset;
-					popover.style.transformOrigin = 'center';
+				if (this.fixed) {
+					if (left + width > window.innerWidth) {
+						left = window.innerWidth - width;
+						popover.style.transformOrigin = 'center';
+					}
+
+					if (top + height > window.innerHeight) {
+						top = window.innerHeight - height;
+						popover.style.transformOrigin = 'center';
+					}
+				} else {
+					if (left + width - window.pageXOffset > window.innerWidth) {
+						left = window.innerWidth - width + window.pageXOffset;
+						popover.style.transformOrigin = 'center';
+					}
+
+					if (top + height - window.pageYOffset > window.innerHeight) {
+						top = window.innerHeight - height + window.pageYOffset;
+						popover.style.transformOrigin = 'center';
+					}
 				}
 
-				if (top + height - window.pageYOffset > window.innerHeight) {
-					top = window.innerHeight - height + window.pageYOffset;
-					popover.style.transformOrigin = 'center';
+				if (top < 0) {
+					top = 0;
 				}
-			}
 
-			if (top < 0) {
-				top = 0;
-			}
+				if (left < 0) {
+					left = 0;
+				}
 
-			if (left < 0) {
-				left = 0;
-			}
-
-			popover.style.left = left + 'px';
-			popover.style.top = top + 'px';
+				popover.style.left = left + 'px';
+				popover.style.top = top + 'px';
+			}).observe(popover);
 		});
 	},
 });
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index e74ec55d06..926f8e0ccf 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -96,7 +96,7 @@
 </template>
 
 <script lang="ts">
-import { defineComponent } from 'vue';
+import { defineAsyncComponent, defineComponent } from 'vue';
 import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
 import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
 import { parse } from '../../mfm/parse';
@@ -108,8 +108,6 @@ import XReactionsViewer from './reactions-viewer.vue';
 import XMediaList from './media-list.vue';
 import XCwButton from './cw-button.vue';
 import XPoll from './poll.vue';
-import MkUrlPreview from './url-preview.vue';
-import MkReactionPicker from './reaction-picker.vue';
 import { pleaseLogin } from '@/scripts/please-login';
 import { focusPrev, focusNext } from '@/scripts/focus';
 import { url } from '@/config';
@@ -133,7 +131,7 @@ export default defineComponent({
 		XMediaList,
 		XCwButton,
 		XPoll,
-		MkUrlPreview,
+		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
 	},
 
 	inject: {
@@ -485,17 +483,17 @@ export default defineComponent({
 		react(viaKeyboard = false) {
 			pleaseLogin();
 			this.blur();
-			const close = os.popup(MkReactionPicker, {
-				source: this.$refs.reactButton,
+			os.modal(defineAsyncComponent(() => import('@/components/reaction-picker.vue')), {
 				showFocus: viaKeyboard,
 			}, reaction => {
 				os.api('notes/reactions/create', {
 					noteId: this.appearNote.id,
 					reaction: reaction
-				}).then(() => {
-					close();
 				});
-			}, this.focus);
+				this.focus();
+			}, {
+				source: this.$refs.reactButton
+			});
 		},
 
 		reactDirectly(reaction) {
diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue
index 22fc9e64f9..14eb26ba91 100644
--- a/src/client/components/notes.vue
+++ b/src/client/components/notes.vue
@@ -32,12 +32,11 @@ import { defineComponent } from 'vue';
 import paging from '@/scripts/paging';
 import XNote from './note.vue';
 import XList from './date-separated-list.vue';
-import MkButton from './ui/button.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		XNote, XList, MkButton
+		XNote, XList,
 	},
 
 	mixins: [
@@ -83,9 +82,9 @@ export default defineComponent({
 		updated(oldValue, newValue) {
 			const i = this.notes.findIndex(n => n === oldValue);
 			if (this.prop) {
-				Vue.set(this.items[i], this.prop, newValue);
+				this.items[i][this.prop] = newValue;
 			} else {
-				Vue.set(this.items, i, newValue);
+				this.items[i] = newValue;
 			}
 		},
 
diff --git a/src/client/components/particle.vue b/src/client/components/particle.vue
index 3fe0256cf6..c392f3066f 100644
--- a/src/client/components/particle.vue
+++ b/src/client/components/particle.vue
@@ -57,9 +57,6 @@ import * as os from '@/os';
 
 export default defineComponent({
 	props: {
-		destroy: {
-			required: true
-		},
 		x: {
 			type: Number,
 			required: true
@@ -94,7 +91,7 @@ export default defineComponent({
 	},
 	mounted() {
 		setTimeout(() => {
-			this.destroy();
+			this.$emit('closed');
 		}, 1100);
 	}
 });
diff --git a/src/client/components/post-form-dialog.vue b/src/client/components/post-form-dialog.vue
deleted file mode 100644
index c0231d0e3f..0000000000
--- a/src/client/components/post-form-dialog.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-<template>
-<x-modal @closed="$emit('closed')" @click="onBgClick" :showing="showing">
-	<x-post-form ref="form" class="ulveipgl"
-		:reply="reply"
-		:renote="renote"
-		:mention="mention"
-		:specified="specified"
-		:initial-text="initialText"
-		:initial-note="initialNote"
-		:instant="instant"
-		@posted="onPosted"
-		@cancel="onCanceled"
-	/>
-</x-modal>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import XModal from './modal.vue';
-import XPostForm from './post-form.vue';
-import * as os from '@/os';
-
-export default defineComponent({
-	components: {
-		XModal,
-		XPostForm,
-	},
-
-	props: {
-		showing: {
-			required: true
-		},
-		reply: {
-			type: Object,
-			required: false
-		},
-		renote: {
-			type: Object,
-			required: false
-		},
-		mention: {
-			type: Object,
-			required: false
-		},
-		specified: {
-			type: Object,
-			required: false
-		},
-		initialText: {
-			type: String,
-			required: false
-		},
-		initialNote: {
-			type: Object,
-			required: false
-		},
-		instant: {
-			type: Boolean,
-			required: false,
-			default: false
-		}
-	},
-
-	methods: {
-		focus() {
-			this.$refs.form.focus();
-		},
-
-		onPosted() {
-			this.$emit('done', 'posted');
-		},
-
-		onCanceled() {
-			this.$emit('done', 'canceled');
-		},
-
-		onKeydown(e) {
-			if (e.which === 27) { // Esc
-				e.preventDefault();
-				e.stopPropagation();
-				this.$emit('done', 'canceled');
-			}
-		},
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.ulveipgl {
-	width: 100%;
-	max-width: 500px;
-	border-radius: var(--radius);
-}
-</style>
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index 410ebb90cf..35739082f0 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="gafaadew"
+<div class="gafaadew" :class="{ modal }"
 	@dragover.stop="onDragover"
 	@dragenter="onDragenter"
 	@dragleave="onDragleave"
@@ -80,6 +80,8 @@ export default defineComponent({
 		XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue'))
 	},
 
+	inject: ['modal'],
+
 	props: {
 		reply: {
 			type: Object,
@@ -417,7 +419,7 @@ export default defineComponent({
 				// TODO: information dialog
 				return;
 			}
-			const w = os.popup(MkVisibilityChooser, {
+			const w = os.modal(MkVisibilityChooser, {
 				source: this.$refs.visibilityButton,
 				currentVisibility: this.visibility,
 				currentLocalOnly: this.localOnly
@@ -433,7 +435,7 @@ export default defineComponent({
 		},
 
 		addVisibleUser() {
-			const vm = os.popup(MkUserSelect, {});
+			const vm = os.modal(MkUserSelect, {});
 			vm.$once('selected', user => {
 				this.visibleUsers.push(user);
 			});
@@ -593,18 +595,18 @@ export default defineComponent({
 		},
 
 		cancel() {
-			this.$emit('cancel');
+			this.$emit('done');
 		},
 
 		insertMention() {
-			const vm = os.popup(MkUserSelect, {});
+			const vm = os.modal(MkUserSelect, {});
 			vm.$once('selected', user => {
 				insertTextAtCursor(this.$refs.text, getAcct(user) + ' ');
 			});
 		},
 
 		async insertEmoji(ev) {
-			const vm = os.popup(await import('./emoji-picker.vue'), {
+			const vm = os.modal(await import('./emoji-picker.vue'), {
 				source: ev.currentTarget || ev.target
 			}).$once('chosen', emoji => {
 				insertTextAtCursor(this.$refs.text, emoji);
@@ -636,6 +638,12 @@ export default defineComponent({
 	position: relative;
 	background: var(--panel);
 
+	&.modal {
+		width: 100%;
+		max-width: 500px;
+		border-radius: var(--radius);
+	}
+
 	> header {
 		z-index: 1000;
 		height: 66px;
diff --git a/src/client/components/reaction-picker.vue b/src/client/components/reaction-picker.vue
index e6e9d4137d..96549fd67a 100644
--- a/src/client/components/reaction-picker.vue
+++ b/src/client/components/reaction-picker.vue
@@ -1,36 +1,24 @@
 <template>
-<XModal :source="source" @closed="$emit('closed')" :showing="showing" @click="close" v-hotkey.global="keymap">
-	<div class="rdfaahpb">
-		<div class="buttons" ref="buttons" :class="{ showFocus }">
-			<button class="_button" v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" :tabindex="i + 1" :title="reaction" v-particle><x-reaction-icon :reaction="reaction"/></button>
-		</div>
-		<input class="text" v-model.trim="text" :placeholder="$t('enterEmoji')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }">
+<div class="rdfaahpb">
+	<div class="buttons" ref="buttons" :class="{ showFocus }">
+		<button class="_button" v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" :tabindex="i + 1" :title="reaction" v-particle><x-reaction-icon :reaction="reaction"/></button>
 	</div>
-</XModal>
+	<input class="text" v-model.trim="text" :placeholder="$t('enterEmoji')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }">
+</div>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { emojiRegex } from '../../misc/emoji-regex';
 import XReactionIcon from './reaction-icon.vue';
-import XModal from './modal.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		XModal,
 		XReactionIcon,
 	},
 
 	props: {
-		showing: {
-			required: true
-		},
-
-		source: {
-			required: true
-		},
-
 		reactions: {
 			required: false
 		},
diff --git a/src/client/components/reactions-viewer.reaction.vue b/src/client/components/reactions-viewer.reaction.vue
index f61374b7e2..60cc4bd2e4 100644
--- a/src/client/components/reactions-viewer.reaction.vue
+++ b/src/client/components/reactions-viewer.reaction.vue
@@ -111,7 +111,7 @@ export default defineComponent({
 
 				this.closeDetails();
 				if (!this.isHovering) return;
-				this.details = os.popup(XDetails, {
+				this.details = os.modal(XDetails, {
 					reaction: this.reaction,
 					users,
 					count: this.count,
diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue
index 1f60a1bdb1..ab8f62916e 100644
--- a/src/client/components/sidebar.vue
+++ b/src/client/components/sidebar.vue
@@ -276,7 +276,7 @@ export default defineComponent({
 		},
 
 		async addAcount() {
-			os.popup(await import('./signin-dialog.vue')).$once('login', res => {
+			os.modal(await import('./signin-dialog.vue')).$once('login', res => {
 				this.$store.dispatch('addAcount', res);
 				os.dialog({
 					type: 'success',
@@ -286,7 +286,7 @@ export default defineComponent({
 		},
 
 		async createAccount() {
-			os.popup(await import('./signup-dialog.vue')).$once('signup', res => {
+			os.modal(await import('./signup-dialog.vue')).$once('signup', res => {
 				this.$store.dispatch('addAcount', res);
 				this.switchAccountWithToken(res.i);
 			});
diff --git a/src/client/deck.vue b/src/client/deck.vue
index 15883fb933..0f6a0f4255 100644
--- a/src/client/deck.vue
+++ b/src/client/deck.vue
@@ -166,7 +166,7 @@ export default defineComponent({
 					id: notification.id
 				});
 
-				os.popup(await import('@/components/toast.vue'), {
+				os.modal(await import('@/components/toast.vue'), {
 					notification
 				});
 			}
diff --git a/src/client/default.vue b/src/client/default.vue
index 2899f684ee..cf92d226db 100644
--- a/src/client/default.vue
+++ b/src/client/default.vue
@@ -335,7 +335,7 @@ export default defineComponent({
 					id: notification.id
 				});
 
-				os.popup(await import('@/components/toast.vue'), {
+				os.modal(await import('@/components/toast.vue'), {
 					notification
 				});
 			}
diff --git a/src/client/os.ts b/src/client/os.ts
index e6e6c2a76f..f9ef20c85d 100644
--- a/src/client/os.ts
+++ b/src/client/os.ts
@@ -1,4 +1,4 @@
-import { Component, defineAsyncComponent, ref } from 'vue';
+import { Component, defineAsyncComponent, markRaw, ref } from 'vue';
 import Stream from '@/scripts/stream';
 import { store } from '@/store';
 import { apiUrl } from '@/config';
@@ -44,46 +44,81 @@ export function api(endpoint: string, data: Record<string, any> = {}, token?: st
 	return promise;
 }
 
-export function popup(component: Component, props: Record<string, any>, callback?: Function) {
+export function popup(component: Component, props: Record<string, any>, callback?: Function, option?) {
+	markRaw(component);
 	const id = Math.random().toString(); // TODO: uuidとか使う
 	const showing = ref(true);
-	const popup = {
+	const close = (...args) => {
+		if (callback) callback(...args);
+		showing.value = false;
+	};
+	const modal = {
+		type: 'popup',
 		component,
-		props: {
-			...props,
-			showing
-		},
+		props,
 		showing,
-		done: (...args) => {
-			if (callback) callback(...args);
-			showing.value = false;
-		},
+		source: option?.source,
+		done: close,
+		bgClick: () => close(),
 		closed: () => {
 			store.commit('removePopup', id);
 		},
 		id,
 	};
-	store.commit('addPopup', popup);
-	return () => {
+	store.commit('addPopup', modal);
+	return close;
+}
+
+export function modal(component: Component, props: Record<string, any>, callback?: Function, option?) {
+	markRaw(component);
+	const id = Math.random().toString(); // TODO: uuidとか使う
+	const showing = ref(true);
+	const close = (...args) => {
+		if (callback) callback(...args);
 		showing.value = false;
 	};
+	const modal = {
+		type: 'modal',
+		component,
+		props,
+		showing,
+		source: option?.source,
+		done: close,
+		bgClick: () => close(),
+		closed: () => {
+			store.commit('removePopup', id);
+		},
+		id,
+	};
+	store.commit('addPopup', modal);
+	return close;
 }
 
 export function dialog(props: Record<string, any>) {
 	return new Promise((res, rej) => {
-		popup(defineAsyncComponent(() => import('@/components/dialog.vue')), props, res);
+		modal(defineAsyncComponent(() => import('@/components/dialog.vue')), props, result => {
+			if (result) {
+				res(result);
+			} else {
+				res({ canceled: true });
+			}
+		});
 	});
 }
 
 export function menu(props: Record<string, any>) {
+	const source = props.source; // TODO: sourceはpropsの外に出して追加の引数として受け取るようにする
 	return new Promise((res, rej) => {
-		popup(defineAsyncComponent(() => import('@/components/menu.vue')), props, res);
+		modal(defineAsyncComponent(() => import('@/components/menu.vue')), props, res, {
+			position: 'source',
+			source
+		});
 	});
 }
 
 export function post(props: Record<string, any>) {
 	return new Promise((res, rej) => {
-		popup(defineAsyncComponent(() => import('@/components/post-form-dialog.vue')), props, res);
+		modal(defineAsyncComponent(() => import('@/components/post-form.vue')), props, res);
 	});
 }
 
diff --git a/src/client/pages/index.welcome.entrance.vue b/src/client/pages/index.welcome.entrance.vue
index d1fab0b236..0715d0c062 100644
--- a/src/client/pages/index.welcome.entrance.vue
+++ b/src/client/pages/index.welcome.entrance.vue
@@ -54,13 +54,13 @@ export default defineComponent({
 
 	methods: {
 		signin() {
-			os.popup(XSigninDialog, {
+			os.modal(XSigninDialog, {
 				autoSet: true
 			});
 		},
 
 		signup() {
-			os.popup(XSignupDialog, {
+			os.modal(XSignupDialog, {
 				autoSet: true
 			});
 		}
diff --git a/src/client/pages/instance/federation.vue b/src/client/pages/instance/federation.vue
index 81860eee12..0044bbd8f0 100644
--- a/src/client/pages/instance/federation.vue
+++ b/src/client/pages/instance/federation.vue
@@ -125,7 +125,7 @@ export default defineComponent({
 		},
 
 		info(instance) {
-			os.popup(MkInstanceInfo, {
+			os.modal(MkInstanceInfo, {
 				instance: instance
 			});
 		}
diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue
index 668a4a2e1f..64044cc6b6 100644
--- a/src/client/pages/instance/index.vue
+++ b/src/client/pages/instance/index.vue
@@ -550,7 +550,7 @@ export default defineComponent({
 					host: q
 				});
 			}
-			os.popup(MkInstanceInfo, {
+			os.modal(MkInstanceInfo, {
 				instance: instance
 			});
 		},
diff --git a/src/client/pages/instance/instance.vue b/src/client/pages/instance/instance.vue
index 88cbce5514..95c49b9791 100644
--- a/src/client/pages/instance/instance.vue
+++ b/src/client/pages/instance/instance.vue
@@ -439,7 +439,7 @@ export default defineComponent({
 		},
 
 		showFollowing() {
-			os.popup(MkUsersDialog, {
+			os.modal(MkUsersDialog, {
 				title: this.$t('instanceFollowing'),
 				pagination: {
 					endpoint: 'federation/following',
@@ -453,7 +453,7 @@ export default defineComponent({
 		},
 
 		showFollowers() {
-			os.popup(MkUsersDialog, {
+			os.modal(MkUsersDialog, {
 				title: this.$t('instanceFollowers'),
 				pagination: {
 					endpoint: 'federation/followers',
@@ -467,7 +467,7 @@ export default defineComponent({
 		},
 
 		showUsers() {
-			os.popup(MkUsersDialog, {
+			os.modal(MkUsersDialog, {
 				title: this.$t('instanceUsers'),
 				pagination: {
 					endpoint: 'federation/users',
diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue
index 294ec4fb03..5dfbd21f4d 100644
--- a/src/client/pages/instance/settings.vue
+++ b/src/client/pages/instance/settings.vue
@@ -452,7 +452,7 @@ export default defineComponent({
 		},
 
 		addPinUser() {
-			os.popup(MkUserSelect, {}).$once('selected', user => {
+			os.modal(MkUserSelect, {}).$once('selected', user => {
 				this.pinnedUsers = this.pinnedUsers.trim();
 				this.pinnedUsers += '\n@' + getAcct(user);
 				this.pinnedUsers = this.pinnedUsers.trim();
@@ -460,7 +460,7 @@ export default defineComponent({
 		},
 
 		chooseProxyAccount() {
-			os.popup(MkUserSelect, {}).$once('selected', user => {
+			os.modal(MkUserSelect, {}).$once('selected', user => {
 				this.proxyAccount = user;
 				this.proxyAccountId = user.id;
 				this.save(true);
diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue
index 56bfa353fe..919627c0a3 100644
--- a/src/client/pages/instance/users.vue
+++ b/src/client/pages/instance/users.vue
@@ -180,7 +180,7 @@ export default defineComponent({
 		},
 
 		searchUser() {
-			os.popup(MkUserSelect, {}).$once('selected', user => {
+			os.modal(MkUserSelect, {}).$once('selected', user => {
 				this.show(user);
 			});
 		},
diff --git a/src/client/pages/messaging/index.vue b/src/client/pages/messaging/index.vue
index f5ab27ce02..618f49182d 100644
--- a/src/client/pages/messaging/index.vue
+++ b/src/client/pages/messaging/index.vue
@@ -132,7 +132,7 @@ export default defineComponent({
 		},
 
 		async startUser() {
-			os.popup(MkUserSelect, {}).$once('selected', user => {
+			os.modal(MkUserSelect, {}).$once('selected', user => {
 				this.$router.push(`/my/messaging/${getAcct(user)}`);
 			});
 		},
diff --git a/src/client/pages/messaging/messaging-room.form.vue b/src/client/pages/messaging/messaging-room.form.vue
index 07d21bbf14..81229b7d70 100644
--- a/src/client/pages/messaging/messaging-room.form.vue
+++ b/src/client/pages/messaging/messaging-room.form.vue
@@ -220,7 +220,7 @@ export default defineComponent({
 		},
 
 		async insertEmoji(ev) {
-			const vm = os.popup(await import('@/components/emoji-picker.vue'), {
+			const vm = os.modal(await import('@/components/emoji-picker.vue'), {
 				source: ev.currentTarget || ev.target
 			}).$once('chosen', emoji => {
 				insertTextAtCursor(this.$refs.text, emoji);
diff --git a/src/client/pages/my-antennas/index.antenna.vue b/src/client/pages/my-antennas/index.antenna.vue
index 67f69a1250..52af31606d 100644
--- a/src/client/pages/my-antennas/index.antenna.vue
+++ b/src/client/pages/my-antennas/index.antenna.vue
@@ -177,7 +177,7 @@ export default defineComponent({
 		},
 
 		addUser() {
-			os.popup(MkUserSelect, {}).$once('selected', user => {
+			os.modal(MkUserSelect, {}).$once('selected', user => {
 				this.users = this.users.trim();
 				this.users += '\n@' + getAcct(user);
 				this.users = this.users.trim();
diff --git a/src/client/pages/my-groups/group.vue b/src/client/pages/my-groups/group.vue
index 580aa345cf..e0edffa80d 100644
--- a/src/client/pages/my-groups/group.vue
+++ b/src/client/pages/my-groups/group.vue
@@ -89,7 +89,7 @@ export default defineComponent({
 		},
 
 		invite() {
-			os.popup(MkUserSelect, {}).$once('selected', user => {
+			os.modal(MkUserSelect, {}).$once('selected', user => {
 				os.api('users/groups/invite', {
 					groupId: this.group.id,
 					userId: user.id
@@ -134,7 +134,7 @@ export default defineComponent({
 		},
 
 		transfer() {
-			os.popup(MkUserSelect, {}).$once('selected', user => {
+			os.modal(MkUserSelect, {}).$once('selected', user => {
 				os.api('users/groups/transfer', {
 					groupId: this.group.id,
 					userId: user.id
diff --git a/src/client/pages/my-lists/list.vue b/src/client/pages/my-lists/list.vue
index 961cf02404..9185323c98 100644
--- a/src/client/pages/my-lists/list.vue
+++ b/src/client/pages/my-lists/list.vue
@@ -88,7 +88,7 @@ export default defineComponent({
 		},
 
 		addUser() {
-			os.popup(MkUserSelect, {}).$once('selected', user => {
+			os.modal(MkUserSelect, {}).$once('selected', user => {
 				os.api('users/lists/push', {
 					listId: this.list.id,
 					userId: user.id
diff --git a/src/client/pages/my-settings/api.vue b/src/client/pages/my-settings/api.vue
index a6a8fc5017..a163c9a029 100644
--- a/src/client/pages/my-settings/api.vue
+++ b/src/client/pages/my-settings/api.vue
@@ -26,7 +26,7 @@ export default defineComponent({
 	},
 	methods: {
 		async generateToken() {
-			os.popup(await import('@/components/token-generate-window.vue'), {
+			os.modal(await import('@/components/token-generate-window.vue'), {
 			}).$on('ok', async ({ name, permissions }) => {
 				const { token } = await os.api('miauth/gen-token', {
 					session: null,
diff --git a/src/client/pages/my-settings/index.vue b/src/client/pages/my-settings/index.vue
index c20ccae65a..905d2ac214 100644
--- a/src/client/pages/my-settings/index.vue
+++ b/src/client/pages/my-settings/index.vue
@@ -114,7 +114,7 @@ export default defineComponent({
 		},
 
 		async configure() {
-			os.popup(await import('@/components/notification-setting-window.vue'), {
+			os.modal(await import('@/components/notification-setting-window.vue'), {
 				includingTypes: this.$store.state.i.includingNotificationTypes,
 				showGlobalToggle: false,
 			}).$on('ok', async ({ includingTypes: value }: any) => {
diff --git a/src/client/pages/my-settings/reaction.vue b/src/client/pages/my-settings/reaction.vue
index 586a9e330d..e4f57c5123 100644
--- a/src/client/pages/my-settings/reaction.vue
+++ b/src/client/pages/my-settings/reaction.vue
@@ -58,7 +58,7 @@ export default defineComponent({
 		},
 
 		preview(ev) {
-			const picker = os.popup(MkReactionPicker, {
+			const picker = os.modal(MkReactionPicker, {
 				source: ev.currentTarget || ev.target,
 				reactions: this.splited,
 				showFocus: false,
@@ -73,7 +73,7 @@ export default defineComponent({
 		},
 
 		async chooseEmoji(ev) {
-			const vm = os.popup(await import('@/components/emoji-picker.vue'), {
+			const vm = os.modal(await import('@/components/emoji-picker.vue'), {
 				source: ev.currentTarget || ev.target
 			}).$once('chosen', emoji => {
 				this.reactions += emoji;
diff --git a/src/client/pages/preferences/plugins.vue b/src/client/pages/preferences/plugins.vue
index 6f4df5aed3..d79b884194 100644
--- a/src/client/pages/preferences/plugins.vue
+++ b/src/client/pages/preferences/plugins.vue
@@ -118,7 +118,7 @@ export default defineComponent({
 			}
 
 			const token = permissions == null || permissions.length === 0 ? null : await new Promise(async (res, rej) => {
-				os.popup(await import('@/components/token-generate-window.vue'), {
+				os.modal(await import('@/components/token-generate-window.vue'), {
 					title: this.$t('tokenRequested'),
 					information: this.$t('pluginTokenRequestedDescription'),
 					initialName: name,
diff --git a/src/client/pages/share.vue b/src/client/pages/share.vue
index 809a07876c..20b763d70c 100644
--- a/src/client/pages/share.vue
+++ b/src/client/pages/share.vue
@@ -60,7 +60,7 @@ export default defineComponent({
 			if (this.title) text += `【${this.title}】\n`;
 			if (this.text) text += `${this.text}\n`;
 			if (this.url) text += `${this.url}`;
-			os.popup(PostFormDialog, {
+			os.modal(PostFormDialog, {
 				instant: true,
 				initialText: text.trim()
 			}).$once('posted', () => {
diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue
index 7a2ca72564..c1ddba8d1f 100644
--- a/src/client/pages/user/index.vue
+++ b/src/client/pages/user/index.vue
@@ -189,7 +189,7 @@ export default defineComponent({
 		},
 
 		menu() {
-			os.popup(XUserMenu, {
+			os.modal(XUserMenu, {
 				source: this.$refs.menu,
 				user: this.user
 			});
diff --git a/src/client/root.vue b/src/client/root.vue
index 41d295092d..da75cc862c 100644
--- a/src/client/root.vue
+++ b/src/client/root.vue
@@ -2,13 +2,17 @@
 <DeckUI v-if="deckmode"/>
 <DefaultUI v-else/>
 
-<component v-for="popup in $store.state.popups" :is="popup.component" v-bind="popup.props" :key="popup.id" @done="popup.done" @closed="popup.closed"/>
+<XModal v-for="modal in $store.state.popups.filter(x => x.type === 'modal')" :key="modal.id" @closed="modal.closed" @click="modal.bgClick" :showing="modal.showing" :source="modal.source">
+	<component :is="modal.component" v-bind="modal.props" @done="modal.done"/>
+</XModal>
+
+<component v-for="popup in $store.state.popups.filter(x => x.type === 'popup')" :key="popup.id" :is="popup.component" v-bind="popup.props" @done="popup.done" @closed="popup.closed"/>
 
 <div id="wait" v-if="$store.state.pendingApiRequestsCount > 0"></div>
 </template>
 
 <script lang="ts">
-import { defineComponent } from 'vue';
+import { defineAsyncComponent, defineComponent } from 'vue';
 import DefaultUI from './default.vue';
 import DeckUI from './deck.vue';
 import { instanceName, deckmode } from '@/config';
@@ -17,6 +21,7 @@ export default defineComponent({
 	components: {
 		DefaultUI,
 		DeckUI,
+		XModal: defineAsyncComponent(() => import('@/components/modal.vue'))
 	},
 
 	metaInfo: {
diff --git a/src/client/store.ts b/src/client/store.ts
index c2a2b98068..f16d9426af 100644
--- a/src/client/store.ts
+++ b/src/client/store.ts
@@ -111,6 +111,7 @@ export const store = createStore({
 		popups: [] as {
 			id: any;
 			component: any;
+			type: 'popup' | 'modal',
 			props: Record<string, any>;
 		}[],
 		fullView: false,
diff --git a/src/client/widgets/notifications.vue b/src/client/widgets/notifications.vue
index 71b56b3808..37a8391b84 100644
--- a/src/client/widgets/notifications.vue
+++ b/src/client/widgets/notifications.vue
@@ -51,7 +51,7 @@ export default defineComponent({
 
 	methods: {
 		async configure() {
-			os.popup(await import('@/components/notification-setting-window.vue'), {
+			os.modal(await import('@/components/notification-setting-window.vue'), {
 				includingTypes: this.props.includingTypes,
 			}).$on('ok', async ({ includingTypes }) => {
 				this.props.includingTypes = includingTypes;
diff --git a/src/client/widgets/welcome.vue b/src/client/widgets/welcome.vue
index b8ca7664f1..3f239cd5bf 100644
--- a/src/client/widgets/welcome.vue
+++ b/src/client/widgets/welcome.vue
@@ -52,13 +52,13 @@ export default defineComponent({
 
 	methods: {
 		signin() {
-			os.popup(XSigninDialog, {
+			os.modal(XSigninDialog, {
 				autoSet: true
 			});
 		},
 
 		signup() {
-			os.popup(XSignupDialog, {
+			os.modal(XSignupDialog, {
 				autoSet: true
 			});
 		}