diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 868beb7765..5c7e7d6411 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -1,5 +1,5 @@
 <template>
-<Transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened">
+<Transition :name="transitionName" :duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened">
 	<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
 		<div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
 		<div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick">
@@ -74,20 +74,27 @@ const type = $computed(() => {
 		return props.preferType!;
 	}
 });
+let transitionName = $ref(defaultStore.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : '');
+let transitionDuration = $ref(defaultStore.state.animation ? 200 : 0);
 
 let contentClicking = false;
 
-const close = () => {
+function close(opts: { useSendAnimation?: boolean } = {}) {
+	if (opts.useSendAnimation) {
+		transitionName = 'send';
+		transitionDuration = 400;
+	}
+
 	// eslint-disable-next-line vue/no-mutating-props
 	if (props.src) props.src.style.pointerEvents = 'auto';
 	showing = false;
 	emit('close');
-};
+}
 
-const onBgClick = () => {
+function onBgClick() {
 	if (contentClicking) return;
 	emit('click');
-};
+}
 
 if (type === 'drawer') {
 	maxHeight = window.innerHeight / 1.5;
@@ -254,6 +261,30 @@ defineExpose({
 </script>
 
 <style lang="scss" scoped>
+.send-enter-active, .send-leave-active {
+	> .bg {
+		transition: opacity 0.3s !important;
+	}
+
+	> .content {
+		transform-style: preserve-3d;
+    transform: perspective(50cm) translateZ(0px) translateY(0px) rotateX(0deg);
+		transition: opacity 0.4s cubic-bezier(.5,-0.5,.75,1), transform 0.4s cubic-bezier(.5,-0.5,.75,1) !important;
+	}
+}
+.send-enter-from, .send-leave-to {
+	> .bg {
+		opacity: 0;
+	}
+
+	> .content {
+		pointer-events: none;
+		opacity: 0;
+		transform-style: preserve-3d;
+		transform: perspective(50cm) translateZ(-300px) translateY(-200px) rotateX(40deg);
+	}
+}
+
 .modal-enter-active, .modal-leave-active {
 	> .bg {
 		transition: opacity 0.2s !important;
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 875c6753b7..b8784620c0 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -22,7 +22,14 @@
 				<span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span>
 			</button>
 			<button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
-			<button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i></button>
+			<button v-click-anime class="submit _button" :class="{ posting }" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
+				<div class="inner _buttonGradate">
+					<template v-if="posted"></template>
+					<template v-else-if="posting"><MkEllipsis/></template>
+					<template v-else>{{ submitText }}</template>
+					<i :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i>
+				</div>
+			</button>
 		</div>
 	</header>
 	<div class="form" :class="{ fixed }">
@@ -41,7 +48,7 @@
 		</div>
 		<MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
 		<input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
-		<textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
+		<textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
 		<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
 		<XPostFormAttaches v-model="files" class="attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/>
 		<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
@@ -90,6 +97,7 @@ import { instance } from '@/instance';
 import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
 import { uploadFile } from '@/scripts/upload';
 import { deepClone } from '@/scripts/clone';
+import Ripple from '@/components/MkRipple.vue';
 
 const modal = inject('modal');
 
@@ -108,6 +116,7 @@ const props = withDefaults(defineProps<{
 	instant?: boolean;
 	fixed?: boolean;
 	autofocus?: boolean;
+	freezeAfterPosted?: boolean;
 }>(), {
 	initialVisibleUsers: () => [],
 	autofocus: true,
@@ -125,6 +134,7 @@ const hashtagsInputEl = $ref<HTMLInputElement | null>(null);
 const visibilityButton = $ref<HTMLElement | null>(null);
 
 let posting = $ref(false);
+let posted = $ref(false);
 let text = $ref(props.initialText ?? '');
 let files = $ref(props.initialFiles ?? []);
 let poll = $ref<{
@@ -206,7 +216,7 @@ const maxTextLength = $computed((): number => {
 });
 
 const canPost = $computed((): boolean => {
-	return !posting &&
+	return !posting && !posted &&
 		(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
 		(textLength <= maxTextLength) &&
 		(!poll || poll.choices.length >= 2);
@@ -559,7 +569,15 @@ function deleteDraft() {
 	localStorage.setItem('drafts', JSON.stringify(draftData));
 }
 
-async function post() {
+async function post(ev?: MouseEvent) {
+	if (ev) {
+		const el = ev.currentTarget ?? ev.target;
+		const rect = el.getBoundingClientRect();
+		const x = rect.left + (el.offsetWidth / 2);
+		const y = rect.top + (el.offsetHeight / 2);
+		os.popup(Ripple, { x, y }, {}, 'end');
+	}
+
 	let postData = {
 		text: text === '' ? undefined : text,
 		fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
@@ -594,7 +612,11 @@ async function post() {
 
 	posting = true;
 	os.api('notes/create', postData, token).then(() => {
-		clear();
+		if (props.freezeAfterPosted) {
+			posted = true;
+		} else {
+			clear();
+		}
 		nextTick(() => {
 			deleteDraft();
 			emit('posted');
@@ -713,6 +735,10 @@ onMounted(() => {
 		nextTick(() => watchForDraft());
 	});
 });
+
+defineExpose({
+	clear,
+});
 </script>
 
 <style lang="scss" scoped>
@@ -793,19 +819,28 @@ onMounted(() => {
 
 			> .submit {
 				margin: 16px 16px 16px 0;
-				padding: 0 12px;
-				line-height: 34px;
-				font-weight: bold;
 				vertical-align: bottom;
-				border-radius: 4px;
-				font-size: 0.9em;
 
 				&:disabled {
 					opacity: 0.7;
 				}
 
-				> i {
-					margin-left: 6px;
+				&.posting {
+					cursor: wait;
+				}
+
+				> .inner {
+					padding: 0 12px;
+					line-height: 34px;
+					font-weight: bold;
+					border-radius: 4px;
+					font-size: 0.9em;
+					min-width: 90px;
+					box-sizing: border-box;
+
+					> i {
+						margin-left: 6px;
+					}
 				}
 			}
 		}
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index 6dabb1db14..71c07ed658 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -1,19 +1,46 @@
 <template>
-<MkModal ref="modal" :prefer-type="'dialog:top'" @click="$refs.modal.close()" @closed="$emit('closed')">
-	<MkPostForm v-bind="$attrs" @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()"/>
+<MkModal ref="modal" :prefer-type="'dialog:top'" @click="modal.close()" @closed="onModalClosed()">
+	<MkPostForm ref="form" v-bind="props" autofocus freeze-after-posted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/>
 </MkModal>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
 import MkModal from '@/components/MkModal.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
 
-export default defineComponent({
-	components: {
-		MkModal,
-		MkPostForm,
-	},
-	emits: ['closed'],
-});
+const props = defineProps<{
+	reply?: misskey.entities.Note;
+	renote?: misskey.entities.Note;
+	channel?: any; // TODO
+	mention?: misskey.entities.User;
+	specified?: misskey.entities.User;
+	initialText?: string;
+	initialVisibility?: typeof misskey.noteVisibilities;
+	initialFiles?: misskey.entities.DriveFile[];
+	initialLocalOnly?: boolean;
+	initialVisibleUsers?: misskey.entities.User[];
+	initialNote?: misskey.entities.Note;
+	instant?: boolean;
+	fixed?: boolean;
+	autofocus?: boolean;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+let modal = $ref<InstanceType<typeof MkModal>>();
+let form = $ref<InstanceType<typeof MkPostForm>>();
+
+function onPosted() {
+	modal.close({
+		useSendAnimation: true,
+	});
+}
+
+function onModalClosed() {
+	emit('closed');
+}
 </script>
diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts
index e2f514b7ca..83ec08543f 100644
--- a/packages/frontend/src/directives/click-anime.ts
+++ b/packages/frontend/src/directives/click-anime.ts
@@ -2,30 +2,32 @@ import { Directive } from 'vue';
 import { defaultStore } from '@/store';
 
 export default {
-	mounted(el, binding, vn) {
-		/*
+	mounted(el: HTMLElement, binding, vn) {
 		if (!defaultStore.state.animation) return;
 
-		el.classList.add('_anime_bounce_standBy');
+		const target = el.children[0];
+
+		if (target == null) return;
+
+		target.classList.add('_anime_bounce_standBy');
 
 		el.addEventListener('mousedown', () => {
-			el.classList.add('_anime_bounce_standBy');
-			el.classList.add('_anime_bounce_ready');
+			target.classList.add('_anime_bounce_standBy');
+			target.classList.add('_anime_bounce_ready');
 
-			el.addEventListener('mouseleave', () => {
-				el.classList.remove('_anime_bounce_ready');
+			target.addEventListener('mouseleave', () => {
+				target.classList.remove('_anime_bounce_ready');
 			});
 		});
 
 		el.addEventListener('click', () => {
-			el.classList.add('_anime_bounce');
+			target.classList.add('_anime_bounce');
 		});
 
 		el.addEventListener('animationend', () => {
-			el.classList.remove('_anime_bounce_ready');
-			el.classList.remove('_anime_bounce');
-			el.classList.add('_anime_bounce_standBy');
+			target.classList.remove('_anime_bounce_ready');
+			target.classList.remove('_anime_bounce');
+			target.classList.add('_anime_bounce_standBy');
 		});
-		*/
 	},
 } as Directive;