diff --git a/packages/client/package.json b/packages/client/package.json
index 83c8086e23..f1ab23a55e 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -77,7 +77,6 @@
 		"vite": "2.9.10",
 		"vue": "3.2.37",
 		"vue-prism-editor": "2.0.0-alpha.2",
-		"vue-router": "4.0.16",
 		"vuedraggable": "4.0.1",
 		"websocket": "1.0.34",
 		"ws": "8.8.0"
diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts
index ce4af61f18..2b07dd1990 100644
--- a/packages/client/src/account.ts
+++ b/packages/client/src/account.ts
@@ -1,11 +1,11 @@
-import { del, get, set } from '@/scripts/idb-proxy';
 import { defineAsyncComponent, reactive } from 'vue';
 import * as misskey from 'misskey-js';
+import { showSuspendedDialog } from './scripts/show-suspended-dialog';
+import { i18n } from './i18n';
+import { del, get, set } from '@/scripts/idb-proxy';
 import { apiUrl } from '@/config';
 import { waiting, api, popup, popupMenu, success, alert } from '@/os';
 import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
-import { showSuspendedDialog } from './scripts/show-suspended-dialog';
-import { i18n } from './i18n';
 
 // TODO: 他のタブと永続化されたstateを同期
 
@@ -22,13 +22,7 @@ export async function signout() {
 	waiting();
 	localStorage.removeItem('account');
 
-	//#region Remove account
-	const accounts = await getAccounts();
-	accounts.splice(accounts.findIndex(x => x.id === $i.id), 1);
-
-	if (accounts.length > 0) await set('accounts', accounts);
-	else await del('accounts');
-	//#endregion
+	await removeAccount($i.id);
 
 	//#region Remove service worker registration
 	try {
@@ -55,7 +49,7 @@ export async function signout() {
 	} catch (err) {}
 	//#endregion
 
-	document.cookie = `igi=; path=/`;
+	document.cookie = 'igi=; path=/';
 
 	if (accounts.length > 0) login(accounts[0].token);
 	else unisonReload('/');
@@ -72,14 +66,22 @@ export async function addAccount(id: Account['id'], token: Account['token']) {
 	}
 }
 
+export async function removeAccount(id: Account['id']) {
+	const accounts = await getAccounts();
+	accounts.splice(accounts.findIndex(x => x.id === id), 1);
+
+	if (accounts.length > 0) await set('accounts', accounts);
+	else await del('accounts');
+}
+
 function fetchAccount(token: string): Promise<Account> {
 	return new Promise((done, fail) => {
 		// Fetch user
 		fetch(`${apiUrl}/i`, {
 			method: 'POST',
 			body: JSON.stringify({
-				i: token
-			})
+				i: token,
+			}),
 		})
 		.then(res => res.json())
 		.then(res => {
@@ -216,13 +218,13 @@ export async function openAccountMenu(opts: {
 			type: 'link',
 			icon: 'fas fa-users',
 			text: i18n.ts.manageAccounts,
-			to: `/settings/accounts`,
+			to: '/settings/accounts',
 		}]], ev.currentTarget ?? ev.target, {
-			align: 'left'
+			align: 'left',
 		});
 	} else {
 		popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
-			align: 'left'
+			align: 'left',
 		});
 	}
 }
diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue
index 4e9c4e587a..5e9c2f03be 100644
--- a/packages/client/src/components/chart.vue
+++ b/packages/client/src/components/chart.vue
@@ -13,7 +13,7 @@
   id-denylist violation when setting it. This is causing about 60+ lint issues.
   As this is part of Chart.js's API it makes sense to disable the check here.
 */
-import { defineProps, onMounted, ref, watch, PropType, onUnmounted } from 'vue';
+import { onMounted, ref, watch, PropType, onUnmounted } from 'vue';
 import {
 	Chart,
 	ArcElement,
@@ -53,7 +53,7 @@ const props = defineProps({
 	limit: {
 		type: Number,
 		required: false,
-		default: 90
+		default: 90,
 	},
 	span: {
 		type: String as PropType<'hour' | 'day'>,
@@ -62,22 +62,22 @@ const props = defineProps({
 	detailed: {
 		type: Boolean,
 		required: false,
-		default: false
+		default: false,
 	},
 	stacked: {
 		type: Boolean,
 		required: false,
-		default: false
+		default: false,
 	},
 	bar: {
 		type: Boolean,
 		required: false,
-		default: false
+		default: false,
 	},
 	aspectRatio: {
 		type: Number,
 		required: false,
-		default: null
+		default: null,
 	},
 });
 
@@ -156,7 +156,7 @@ const getDate = (ago: number) => {
 const format = (arr) => {
 	return arr.map((v, i) => ({
 		x: getDate(i).getTime(),
-		y: v
+		y: v,
 	}));
 };
 
@@ -343,7 +343,7 @@ const render = () => {
 							min: 'original',
 							max: 'original',
 						},
-					}
+					},
 				} : undefined,
 				//gradient,
 			},
@@ -367,8 +367,8 @@ const render = () => {
 					ctx.stroke();
 					ctx.restore();
 				}
-			}
-		}]
+			},
+		}],
 	});
 };
 
@@ -433,18 +433,18 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => {
 			name: 'In',
 			type: 'area',
 			color: '#008FFB',
-			data: format(raw.inboxReceived)
+			data: format(raw.inboxReceived),
 		}, {
 			name: 'Out (succ)',
 			type: 'area',
 			color: '#00E396',
-			data: format(raw.deliverSucceeded)
+			data: format(raw.deliverSucceeded),
 		}, {
 			name: 'Out (fail)',
 			type: 'area',
 			color: '#FEB019',
-			data: format(raw.deliverFailed)
-		}]
+			data: format(raw.deliverFailed),
+		}],
 	};
 };
 
@@ -456,7 +456,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'line',
 			data: format(type === 'combined'
 				? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
-				: sum(raw[type].inc, negate(raw[type].dec))
+				: sum(raw[type].inc, negate(raw[type].dec)),
 			),
 			color: '#888888',
 		}, {
@@ -464,7 +464,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'area',
 			data: format(type === 'combined'
 				? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
-				: raw[type].diffs.renote
+				: raw[type].diffs.renote,
 			),
 			color: colors.green,
 		}, {
@@ -472,7 +472,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'area',
 			data: format(type === 'combined'
 				? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
-				: raw[type].diffs.reply
+				: raw[type].diffs.reply,
 			),
 			color: colors.yellow,
 		}, {
@@ -480,7 +480,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'area',
 			data: format(type === 'combined'
 				? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
-				: raw[type].diffs.normal
+				: raw[type].diffs.normal,
 			),
 			color: colors.blue,
 		}, {
@@ -488,7 +488,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'area',
 			data: format(type === 'combined'
 				? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile)
-				: raw[type].diffs.withFile
+				: raw[type].diffs.withFile,
 			),
 			color: colors.purple,
 		}],
@@ -522,21 +522,21 @@ const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => {
 			type: 'line',
 			data: format(total
 				? sum(raw.local.total, raw.remote.total)
-				: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+				: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)),
 			),
 		}, {
 			name: 'Local',
 			type: 'area',
 			data: format(total
 				? raw.local.total
-				: sum(raw.local.inc, negate(raw.local.dec))
+				: sum(raw.local.inc, negate(raw.local.dec)),
 			),
 		}, {
 			name: 'Remote',
 			type: 'area',
 			data: format(total
 				? raw.remote.total
-				: sum(raw.remote.inc, negate(raw.remote.dec))
+				: sum(raw.remote.inc, negate(raw.remote.dec)),
 			),
 		}],
 	};
@@ -607,8 +607,8 @@ const fetchDriveChart = async (): Promise<typeof chartData> => {
 					raw.local.incSize,
 					negate(raw.local.decSize),
 					raw.remote.incSize,
-					negate(raw.remote.decSize)
-				)
+					negate(raw.remote.decSize),
+				),
 			),
 		}, {
 			name: 'Local +',
@@ -642,8 +642,8 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
 					raw.local.incCount,
 					negate(raw.local.decCount),
 					raw.remote.incCount,
-					negate(raw.remote.decCount)
-				)
+					negate(raw.remote.decCount),
+				),
 			),
 		}, {
 			name: 'Local +',
@@ -672,18 +672,18 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
 			name: 'In',
 			type: 'area',
 			color: '#008FFB',
-			data: format(raw.requests.received)
+			data: format(raw.requests.received),
 		}, {
 			name: 'Out (succ)',
 			type: 'area',
 			color: '#00E396',
-			data: format(raw.requests.succeeded)
+			data: format(raw.requests.succeeded),
 		}, {
 			name: 'Out (fail)',
 			type: 'area',
 			color: '#FEB019',
-			data: format(raw.requests.failed)
-		}]
+			data: format(raw.requests.failed),
+		}],
 	};
 };
 
@@ -696,9 +696,9 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData
 			color: '#008FFB',
 			data: format(total
 				? raw.users.total
-				: sum(raw.users.inc, negate(raw.users.dec))
-			)
-		}]
+				: sum(raw.users.inc, negate(raw.users.dec)),
+			),
+		}],
 	};
 };
 
@@ -711,9 +711,9 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData
 			color: '#008FFB',
 			data: format(total
 				? raw.notes.total
-				: sum(raw.notes.inc, negate(raw.notes.dec))
-			)
-		}]
+				: sum(raw.notes.inc, negate(raw.notes.dec)),
+			),
+		}],
 	};
 };
 
@@ -726,17 +726,17 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> =
 			color: '#008FFB',
 			data: format(total
 				? raw.following.total
-				: sum(raw.following.inc, negate(raw.following.dec))
-			)
+				: sum(raw.following.inc, negate(raw.following.dec)),
+			),
 		}, {
 			name: 'Followers',
 			type: 'area',
 			color: '#00E396',
 			data: format(total
 				? raw.followers.total
-				: sum(raw.followers.inc, negate(raw.followers.dec))
-			)
-		}]
+				: sum(raw.followers.inc, negate(raw.followers.dec)),
+			),
+		}],
 	};
 };
 
@@ -750,9 +750,9 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char
 			color: '#008FFB',
 			data: format(total
 				? raw.drive.totalUsage
-				: sum(raw.drive.incUsage, negate(raw.drive.decUsage))
-			)
-		}]
+				: sum(raw.drive.incUsage, negate(raw.drive.decUsage)),
+			),
+		}],
 	};
 };
 
@@ -765,9 +765,9 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char
 			color: '#008FFB',
 			data: format(total
 				? raw.drive.totalFiles
-				: sum(raw.drive.incFiles, negate(raw.drive.decFiles))
-			)
-		}]
+				: sum(raw.drive.incFiles, negate(raw.drive.decFiles)),
+			),
+		}],
 	};
 };
 
diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue
index 07cd565c58..b346585cec 100644
--- a/packages/client/src/components/drive-file-thumbnail.vue
+++ b/packages/client/src/components/drive-file-thumbnail.vue
@@ -57,7 +57,7 @@ const isThumbnailAvailable = computed(() => {
 .zdjebgpv {
 	position: relative;
 	display: flex;
-	background: #e1e1e1;
+	background: var(--panel);
 	border-radius: 8px;
 	overflow: clip;
 
diff --git a/packages/client/src/components/form/folder.vue b/packages/client/src/components/form/folder.vue
index 1b960657d7..a9d8bd97b8 100644
--- a/packages/client/src/components/form/folder.vue
+++ b/packages/client/src/components/form/folder.vue
@@ -9,13 +9,13 @@
 			<i v-else class="fas fa-angle-down icon"></i>
 		</span>
 	</div>
-	<keep-alive>
+	<KeepAlive>
 		<div v-if="openedAtLeastOnce" v-show="opened" class="body">
 			<MkSpacer :margin-min="14" :margin-max="22">
 				<slot></slot>
 			</MkSpacer>
 		</div>
-	</keep-alive>
+	</KeepAlive>
 </div>
 </template>
 
diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue
index 5287d59b3e..c7cf12e8c8 100644
--- a/packages/client/src/components/global/a.vue
+++ b/packages/client/src/components/global/a.vue
@@ -5,13 +5,13 @@
 </template>
 
 <script lang="ts" setup>
+import { inject } from 'vue';
 import * as os from '@/os';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { router } from '@/router';
 import { url } from '@/config';
 import { popout as popout_ } from '@/scripts/popout';
 import { i18n } from '@/i18n';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
 
 const props = withDefaults(defineProps<{
 	to: string;
@@ -22,15 +22,16 @@ const props = withDefaults(defineProps<{
 	behavior: null,
 });
 
-const mkNav = new MisskeyNavigator();
+const router = useRouter();
 
 const active = $computed(() => {
 	if (props.activeClass == null) return false;
 	const resolved = router.resolve(props.to);
-	if (resolved.path === router.currentRoute.value.path) return true;
-	if (resolved.name == null) return false;
+	if (resolved == null) return false;
+	if (resolved.route.path === router.currentRoute.value.path) return true;
+	if (resolved.route.name == null) return false;
 	if (router.currentRoute.value.name == null) return false;
-	return resolved.name === router.currentRoute.value.name;
+	return resolved.route.name === router.currentRoute.value.name;
 });
 
 function onContextmenu(ev) {
@@ -44,31 +45,25 @@ function onContextmenu(ev) {
 		text: i18n.ts.openInWindow,
 		action: () => {
 			os.pageWindow(props.to);
-		}
-	}, mkNav.sideViewHook ? {
-		icon: 'fas fa-columns',
-		text: i18n.ts.openInSideView,
-		action: () => {
-			if (mkNav.sideViewHook) mkNav.sideViewHook(props.to);
-		}
-	} : undefined, {
+		},
+	}, {
 		icon: 'fas fa-expand-alt',
 		text: i18n.ts.showInPage,
 		action: () => {
 			router.push(props.to);
-		}
+		},
 	}, null, {
 		icon: 'fas fa-external-link-alt',
 		text: i18n.ts.openInNewTab,
 		action: () => {
 			window.open(props.to, '_blank');
-		}
+		},
 	}, {
 		icon: 'fas fa-link',
 		text: i18n.ts.copyLink,
 		action: () => {
 			copyToClipboard(`${url}${props.to}`);
-		}
+		},
 	}], ev);
 }
 
@@ -98,6 +93,6 @@ function nav() {
 		}
 	}
 
-	mkNav.push(props.to);
+	router.push(props.to);
 }
 </script>
diff --git a/packages/client/src/components/global/header.vue b/packages/client/src/components/global/header.vue
deleted file mode 100644
index 63db19a520..0000000000
--- a/packages/client/src/components/global/header.vue
+++ /dev/null
@@ -1,361 +0,0 @@
-<template>
-<div ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick">
-	<template v-if="info">
-		<div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup">
-			<MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
-			<i v-else-if="info.icon" class="icon" :class="info.icon"></i>
-
-			<div class="title">
-				<MkUserName v-if="info.userName" :user="info.userName" :nowrap="true" class="title"/>
-				<div v-else-if="info.title" class="title">{{ info.title }}</div>
-				<div v-if="!narrow && info.subtitle" class="subtitle">
-					{{ info.subtitle }}
-				</div>
-				<div v-if="narrow && hasTabs" class="subtitle activeTab">
-					{{ info.tabs.find(tab => tab.active)?.title }}
-					<i class="chevron fas fa-chevron-down"></i>
-				</div>
-			</div>
-		</div>
-		<div v-if="!narrow || hideTitle" class="tabs">
-			<button v-for="tab in info.tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick">
-				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
-				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
-			</button>
-		</div>
-	</template>
-	<div class="buttons right">
-		<template v-if="info && info.actions && !narrow">
-			<template v-for="action in info.actions">
-				<MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
-				<button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
-			</template>
-		</template>
-		<button v-if="shouldShowMenu" v-tooltip="$ts.menu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag"><i class="fas fa-ellipsis-h"></i></button>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue';
-import tinycolor from 'tinycolor2';
-import { popupMenu } from '@/os';
-import { url } from '@/config';
-import { scrollToTop } from '@/scripts/scroll';
-import MkButton from '@/components/ui/button.vue';
-import { i18n } from '@/i18n';
-import { globalEvents } from '@/events';
-
-export default defineComponent({
-	components: {
-		MkButton
-	},
-
-	props: {
-		info: {
-			type: Object as PropType<{
-				actions?: {}[];
-				tabs?: {}[];
-			}>,
-			required: true
-		},
-		menu: {
-			required: false
-		},
-		thin: {
-			required: false,
-			default: false
-		},
-	},
-
-	setup(props) {
-		const el = ref<HTMLElement>(null);
-		const bg = ref(null);
-		const narrow = ref(false);
-		const height = ref(0);
-		const hasTabs = computed(() => {
-			return props.info.tabs && props.info.tabs.length > 0;
-		});
-		const shouldShowMenu = computed(() => {
-			if (props.info == null) return false;
-			if (props.info.actions != null && narrow.value) return true;
-			if (props.info.menu != null) return true;
-			if (props.info.share != null) return true;
-			if (props.menu != null) return true;
-			return false;
-		});
-
-		const share = () => {
-			navigator.share({
-				url: url + props.info.path,
-				...props.info.share,
-			});
-		};
-
-		const showMenu = (ev: MouseEvent) => {
-			let menu = props.info.menu ? props.info.menu() : [];
-			if (narrow.value && props.info.actions) {
-				menu = [...props.info.actions.map(x => ({
-					text: x.text,
-					icon: x.icon,
-					action: x.handler
-				})), menu.length > 0 ? null : undefined, ...menu];
-			}
-			if (props.info.share) {
-				if (menu.length > 0) menu.push(null);
-				menu.push({
-					text: i18n.ts.share,
-					icon: 'fas fa-share-alt',
-					action: share
-				});
-			}
-			if (props.menu) {
-				if (menu.length > 0) menu.push(null);
-				menu = menu.concat(props.menu);
-			}
-			popupMenu(menu, ev.currentTarget ?? ev.target);
-		};
-
-		const showTabsPopup = (ev: MouseEvent) => {
-			if (!hasTabs.value) return;
-			if (!narrow.value) return;
-			ev.preventDefault();
-			ev.stopPropagation();
-			const menu = props.info.tabs.map(tab => ({
-				text: tab.title,
-				icon: tab.icon,
-				action: tab.onClick,
-			}));
-			popupMenu(menu, ev.currentTarget ?? ev.target);
-		};
-
-		const preventDrag = (ev: TouchEvent) => {
-			ev.stopPropagation();
-		};
-
-		const onClick = () => {
-			scrollToTop(el.value, { behavior: 'smooth' });
-		};
-
-		const calcBg = () => {
-			const rawBg = props.info?.bg || 'var(--bg)';
-			const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
-			tinyBg.setAlpha(0.85);
-			bg.value = tinyBg.toRgbString();
-		};
-
-		onMounted(() => {
-			calcBg();
-			globalEvents.on('themeChanged', calcBg);
-			onUnmounted(() => {
-				globalEvents.off('themeChanged', calcBg);
-			});
-		
-			if (el.value.parentElement) {
-				narrow.value = el.value.parentElement.offsetWidth < 500;
-				const ro = new ResizeObserver((entries, observer) => {
-					if (el.value) {
-						narrow.value = el.value.parentElement.offsetWidth < 500;
-					}
-				});
-				ro.observe(el.value.parentElement);
-				onUnmounted(() => {
-					ro.disconnect();
-				});
-			}
-		});
-
-		return {
-			el,
-			bg,
-			narrow,
-			height,
-			hasTabs,
-			shouldShowMenu,
-			share,
-			showMenu,
-			showTabsPopup,
-			preventDrag,
-			onClick,
-			hideTitle: inject('shouldOmitHeaderTitle', false),
-			thin_: props.thin || inject('shouldHeaderThin', false)
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.fdidabkb {
-	--height: 60px;
-	display: flex;
-	position: sticky;
-	top: var(--stickyTop, 0);
-	z-index: 1000;
-	width: 100%;
-	-webkit-backdrop-filter: var(--blur, blur(15px));
-	backdrop-filter: var(--blur, blur(15px));
-	border-bottom: solid 0.5px var(--divider);
-
-	&.thin {
-		--height: 50px;
-
-		> .buttons {
-			> .button {
-				font-size: 0.9em;
-			}
-		}
-	}
-
-	&.slim {
-		text-align: center;
-
-		> .titleContainer {
-			flex: 1;
-			margin: 0 auto;
-			margin-left: var(--height);
-
-			> *:first-child {
-				margin-left: auto;
-			}
-
-			> *:last-child {
-				margin-right: auto;
-			}
-		}
-	}
-
-	> .buttons {
-		--margin: 8px;
-		display: flex;
-    align-items: center;
-		height: var(--height);
-		margin: 0 var(--margin);
-
-		&.right {
-			margin-left: auto;
-		}
-
-		&:empty {
-			width: var(--height);
-		}
-
-		> .button {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			height: calc(var(--height) - (var(--margin) * 2));
-			width: calc(var(--height) - (var(--margin) * 2));
-			box-sizing: border-box;
-			position: relative;
-			border-radius: 5px;
-
-			&:hover {
-				background: rgba(0, 0, 0, 0.05);
-			}
-
-			&.highlighted {
-				color: var(--accent);
-			}
-		}
-
-		> .fullButton {
-			& + .fullButton {
-				margin-left: 12px;
-			}
-		}
-	}
-
-	> .titleContainer {
-		display: flex;
-		align-items: center;
-		max-width: 400px;
-		overflow: auto;
-		white-space: nowrap;
-		text-align: left;
-		font-weight: bold;
-		flex-shrink: 0;
-		margin-left: 24px;
-
-		> .avatar {
-			$size: 32px;
-			display: inline-block;
-			width: $size;
-			height: $size;
-			vertical-align: bottom;
-			margin: 0 8px;
-			pointer-events: none;
-		}
-
-		> .icon {
-			margin-right: 8px;
-		}
-
-		> .title {
-			min-width: 0;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			white-space: nowrap;
-			line-height: 1.1;
-
-			> .subtitle {
-				opacity: 0.6;
-				font-size: 0.8em;
-				font-weight: normal;
-				white-space: nowrap;
-				overflow: hidden;
-				text-overflow: ellipsis;
-
-				&.activeTab {
-					text-align: center;
-
-					> .chevron {
-						display: inline-block;
-						margin-left: 6px;
-					}
-				}
-			}
-		}
-	}
-
-	> .tabs {
-		margin-left: 16px;
-		font-size: 0.8em;
-		overflow: auto;
-		white-space: nowrap;
-
-		> .tab {
-			display: inline-block;
-			position: relative;
-			padding: 0 10px;
-			height: 100%;
-			font-weight: normal;
-			opacity: 0.7;
-
-			&:hover {
-				opacity: 1;
-			}
-
-			&.active {
-				opacity: 1;
-
-				&:after {
-					content: "";
-					display: block;
-					position: absolute;
-					bottom: 0;
-					left: 0;
-					right: 0;
-					margin: 0 auto;
-					width: 100%;
-					height: 3px;
-					background: var(--accent);
-				}
-			}
-
-			> .icon + .title {
-				margin-left: 8px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/page-header.vue
new file mode 100644
index 0000000000..c01631c6a3
--- /dev/null
+++ b/packages/client/src/components/global/page-header.vue
@@ -0,0 +1,300 @@
+<template>
+<div v-if="show" ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick">
+	<template v-if="metadata">
+		<div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup">
+			<MkAvatar v-if="metadata.avatar" class="avatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/>
+			<i v-else-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+			<div class="title">
+				<MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true" class="title"/>
+				<div v-else-if="metadata.title" class="title">{{ metadata.title }}</div>
+				<div v-if="!narrow && metadata.subtitle" class="subtitle">
+					{{ metadata.subtitle }}
+				</div>
+				<div v-if="narrow && hasTabs" class="subtitle activeTab">
+					{{ tabs.find(tab => tab.active)?.title }}
+					<i class="chevron fas fa-chevron-down"></i>
+				</div>
+			</div>
+		</div>
+		<div v-if="!narrow || hideTitle" class="tabs">
+			<button v-for="tab in tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick">
+				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+			</button>
+		</div>
+	</template>
+	<div class="buttons right">
+		<template v-for="action in actions">
+			<button v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+		</template>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref, inject } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { scrollToTop } from '@/scripts/scroll';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+import { injectPageMetadata, PageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	tabs?: {
+		title: string;
+		active: boolean;
+		icon?: string;
+		iconOnly?: boolean;
+		onClick: () => void;
+	}[];
+	actions?: {
+		text: string;
+		icon: string;
+		handler: (ev: MouseEvent) => void;
+	}[];
+	thin?: boolean;
+}>();
+
+const metadata = injectPageMetadata();
+
+const hideTitle = inject('shouldOmitHeaderTitle', false);
+const thin_ = props.thin || inject('shouldHeaderThin', false);
+
+const el = $ref<HTMLElement | null>(null);
+const bg = ref(null);
+let narrow = $ref(false);
+const height = ref(0);
+const hasTabs = $computed(() => props.tabs && props.tabs.length > 0);
+const hasActions = $computed(() => props.actions && props.actions.length > 0);
+const show = $computed(() => {
+	return !hideTitle || hasTabs || hasActions;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+	if (!hasTabs) return;
+	if (!narrow) return;
+	ev.preventDefault();
+	ev.stopPropagation();
+	const menu = props.tabs.map(tab => ({
+		text: tab.title,
+		icon: tab.icon,
+		action: tab.onClick,
+	}));
+	popupMenu(menu, ev.currentTarget ?? ev.target);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+	ev.stopPropagation();
+};
+
+const onClick = () => {
+	scrollToTop(el, { behavior: 'smooth' });
+};
+
+const calcBg = () => {
+	const rawBg = metadata?.bg || 'var(--bg)';
+	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+	tinyBg.setAlpha(0.85);
+	bg.value = tinyBg.toRgbString();
+};
+
+let ro: ResizeObserver | null;
+
+onMounted(() => {
+	calcBg();
+	globalEvents.on('themeChanged', calcBg);
+
+	if (el && el.parentElement) {
+		narrow = el.parentElement.offsetWidth < 500;
+		ro = new ResizeObserver((entries, observer) => {
+			if (el.parentElement) {
+				narrow = el.parentElement.offsetWidth < 500;
+			}
+		});
+		ro.observe(el.parentElement);
+	}
+});
+
+onUnmounted(() => {
+	globalEvents.off('themeChanged', calcBg);
+	if (ro) ro.disconnect();
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkb {
+	--height: 60px;
+	display: flex;
+	position: sticky;
+	top: var(--stickyTop, 0);
+	z-index: 1000;
+	width: 100%;
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+	border-bottom: solid 0.5px var(--divider);
+
+	&.thin {
+		--height: 50px;
+
+		> .buttons {
+			> .button {
+				font-size: 0.9em;
+			}
+		}
+	}
+
+	&.slim {
+		text-align: center;
+
+		> .titleContainer {
+			flex: 1;
+			margin: 0 auto;
+			margin-left: var(--height);
+
+			> *:first-child {
+				margin-left: auto;
+			}
+
+			> *:last-child {
+				margin-right: auto;
+			}
+		}
+	}
+
+	> .buttons {
+		--margin: 8px;
+		display: flex;
+    align-items: center;
+		height: var(--height);
+		margin: 0 var(--margin);
+
+		&.right {
+			margin-left: auto;
+		}
+
+		&:empty {
+			width: var(--height);
+		}
+
+		> .button {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			height: calc(var(--height) - (var(--margin) * 2));
+			width: calc(var(--height) - (var(--margin) * 2));
+			box-sizing: border-box;
+			position: relative;
+			border-radius: 5px;
+
+			&:hover {
+				background: rgba(0, 0, 0, 0.05);
+			}
+
+			&.highlighted {
+				color: var(--accent);
+			}
+		}
+
+		> .fullButton {
+			& + .fullButton {
+				margin-left: 12px;
+			}
+		}
+	}
+
+	> .titleContainer {
+		display: flex;
+		align-items: center;
+		max-width: 400px;
+		overflow: auto;
+		white-space: nowrap;
+		text-align: left;
+		font-weight: bold;
+		flex-shrink: 0;
+		margin-left: 24px;
+
+		> .avatar {
+			$size: 32px;
+			display: inline-block;
+			width: $size;
+			height: $size;
+			vertical-align: bottom;
+			margin: 0 8px;
+			pointer-events: none;
+		}
+
+		> .icon {
+			margin-right: 8px;
+		}
+
+		> .title {
+			min-width: 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			line-height: 1.1;
+
+			> .subtitle {
+				opacity: 0.6;
+				font-size: 0.8em;
+				font-weight: normal;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+
+				&.activeTab {
+					text-align: center;
+
+					> .chevron {
+						display: inline-block;
+						margin-left: 6px;
+					}
+				}
+			}
+		}
+	}
+
+	> .tabs {
+		margin-left: 16px;
+		font-size: 0.8em;
+		overflow: auto;
+		white-space: nowrap;
+
+		> .tab {
+			display: inline-block;
+			position: relative;
+			padding: 0 10px;
+			height: 100%;
+			font-weight: normal;
+			opacity: 0.7;
+
+			&:hover {
+				opacity: 1;
+			}
+
+			&.active {
+				opacity: 1;
+
+				&:after {
+					content: "";
+					display: block;
+					position: absolute;
+					bottom: 0;
+					left: 0;
+					right: 0;
+					margin: 0 auto;
+					width: 100%;
+					height: 3px;
+					background: var(--accent);
+				}
+			}
+
+			> .icon + .title {
+				margin-left: 8px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/components/global/router-view.vue b/packages/client/src/components/global/router-view.vue
new file mode 100644
index 0000000000..393ba30c3d
--- /dev/null
+++ b/packages/client/src/components/global/router-view.vue
@@ -0,0 +1,39 @@
+<template>
+<KeepAlive max="5">
+	<component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/>
+</KeepAlive>
+</template>
+
+<script lang="ts" setup>
+import { inject, nextTick, onMounted, onUnmounted, watch } from 'vue';
+import { Router } from '@/nirax';
+
+const props = defineProps<{
+	router?: Router;
+}>();
+
+const emit = defineEmits<{
+}>();
+
+const router = props.router ?? inject('router');
+
+if (router == null) {
+	throw new Error('no router provided');
+}
+
+let currentPageComponent = $ref(router.getCurrentComponent());
+let currentPageProps = $ref(router.getCurrentProps());
+let key = $ref(router.getCurrentKey());
+
+function onChange({ route, props: newProps, key: newKey }) {
+	currentPageComponent = route.component;
+	currentPageProps = newProps;
+	key = newKey;
+}
+
+router.addListener('change', onChange);
+
+onUnmounted(() => {
+	router.removeListener('change', onChange);
+});
+</script>
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts
index 26bac63245..aa8a591e51 100644
--- a/packages/client/src/components/index.ts
+++ b/packages/client/src/components/index.ts
@@ -10,15 +10,17 @@ import MkEllipsis from './global/ellipsis.vue';
 import MkTime from './global/time.vue';
 import MkUrl from './global/url.vue';
 import I18n from './global/i18n';
+import RouterView from './global/router-view.vue';
 import MkLoading from './global/loading.vue';
 import MkError from './global/error.vue';
 import MkAd from './global/ad.vue';
-import MkHeader from './global/header.vue';
+import MkPageHeader from './global/page-header.vue';
 import MkSpacer from './global/spacer.vue';
 import MkStickyContainer from './global/sticky-container.vue';
 
 export default function(app: App) {
 	app.component('I18n', I18n);
+	app.component('RouterView', RouterView);
 	app.component('Mfm', Mfm);
 	app.component('MkA', MkA);
 	app.component('MkAcct', MkAcct);
@@ -31,7 +33,7 @@ export default function(app: App) {
 	app.component('MkLoading', MkLoading);
 	app.component('MkError', MkError);
 	app.component('MkAd', MkAd);
-	app.component('MkHeader', MkHeader);
+	app.component('MkPageHeader', MkPageHeader);
 	app.component('MkSpacer', MkSpacer);
 	app.component('MkStickyContainer', MkStickyContainer);
 }
@@ -39,6 +41,7 @@ export default function(app: App) {
 declare module '@vue/runtime-core' {
 	export interface GlobalComponents {
 		I18n: typeof I18n;
+		RouterView: typeof RouterView;
 		Mfm: typeof Mfm;
 		MkA: typeof MkA;
 		MkAcct: typeof MkAcct;
@@ -51,7 +54,7 @@ declare module '@vue/runtime-core' {
 		MkLoading: typeof MkLoading;
 		MkError: typeof MkError;
 		MkAd: typeof MkAd;
-		MkHeader: typeof MkHeader;
+		MkPageHeader: typeof MkPageHeader;
 		MkSpacer: typeof MkSpacer;
 		MkStickyContainer: typeof MkStickyContainer;
 	}
diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue
index 21bdb657b7..aef70f113b 100644
--- a/packages/client/src/components/modal-page-window.vue
+++ b/packages/client/src/components/modal-page-window.vue
@@ -1,163 +1,118 @@
 <template>
 <MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
-	<div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
+	<div ref="rootEl" class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
 		<div class="header" @contextmenu="onContextmenu">
 			<button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button>
 			<span v-else style="display: inline-block; width: 20px"></span>
-			<span v-if="pageInfo" class="title">
-				<i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i>
-				<span>{{ pageInfo.title }}</span>
+			<span v-if="pageMetadata?.value" class="title">
+				<i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i>
+				<span>{{ pageMetadata?.value.title }}</span>
 			</span>
 			<button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button>
 		</div>
 		<div class="body">
 			<MkStickyContainer>
-				<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-				<keep-alive>
-					<component :is="component" v-bind="props" :ref="changePage"/>
-				</keep-alive>
+				<template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template>
+				<RouterView :router="router"/>
 			</MkStickyContainer>
 		</div>
 	</div>
 </MkModal>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { ComputedRef, provide } from 'vue';
 import MkModal from '@/components/ui/modal.vue';
-import { popout } from '@/scripts/popout';
+import { popout as _popout } from '@/scripts/popout';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { resolve } from '@/router';
 import { url } from '@/config';
-import * as symbols from '@/symbols';
 import * as os from '@/os';
+import { mainRouter, routes } from '@/router';
+import { i18n } from '@/i18n';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { Router } from '@/nirax';
 
-export default defineComponent({
-	components: {
-		MkModal,
-	},
+const props = defineProps<{
+	initialPath: string;
+}>();
 
-	inject: {
-		sideViewHook: {
-			default: null,
-		},
-	},
+defineEmits<{
+	(ev: 'closed'): void;
+	(ev: 'click'): void;
+}>();
 
-	provide() {
-		return {
-			navHook: (path) => {
-				this.navigate(path);
-			},
-			shouldHeaderThin: true,
-		};
-	},
+const router = new Router(routes, props.initialPath);
 
-	props: {
-		initialPath: {
-			type: String,
-			required: true,
-		},
-		initialComponent: {
-			type: Object,
-			required: true,
-		},
-		initialProps: {
-			type: Object,
-			required: false,
-			default: () => {},
-		},
-	},
-
-	emits: ['closed'],
-
-	data() {
-		return {
-			width: 860,
-			height: 660,
-			pageInfo: null,
-			path: this.initialPath,
-			component: this.initialComponent,
-			props: this.initialProps,
-			history: [],
-		};
-	},
-
-	computed: {
-		url(): string {
-			return url + this.path;
-		},
-
-		contextmenu() {
-			return [{
-				type: 'label',
-				text: this.path,
-			}, {
-				icon: 'fas fa-expand-alt',
-				text: this.$ts.showInPage,
-				action: this.expand,
-			}, this.sideViewHook ? {
-				icon: 'fas fa-columns',
-				text: this.$ts.openInSideView,
-				action: () => {
-					this.sideViewHook(this.path);
-					this.$refs.window.close();
-				},
-			} : undefined, {
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.popout,
-				action: this.popout,
-			}, null, {
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.openInNewTab,
-				action: () => {
-					window.open(this.url, '_blank');
-					this.$refs.window.close();
-				},
-			}, {
-				icon: 'fas fa-link',
-				text: this.$ts.copyLink,
-				action: () => {
-					copyToClipboard(this.url);
-				},
-			}];
-		},
-	},
-
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
-		},
-
-		navigate(path, record = true) {
-			if (record) this.history.push(this.path);
-			this.path = path;
-			const { component, props } = resolve(path);
-			this.component = component;
-			this.props = props;
-		},
-
-		back() {
-			this.navigate(this.history.pop(), false);
-		},
-
-		expand() {
-			this.$router.push(this.path);
-			this.$refs.window.close();
-		},
-
-		popout() {
-			popout(this.path, this.$el);
-			this.$refs.window.close();
-		},
-
-		onContextmenu(ev: MouseEvent) {
-			os.contextMenu(this.contextmenu, ev);
-		},
-	},
+router.addListener('push', ctx => {
+	
 });
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+let rootEl = $ref();
+let modal = $ref<InstanceType<typeof MkModal>>();
+let path = $ref(props.initialPath);
+let width = $ref(860);
+let height = $ref(660);
+const history = [];
+
+provide('router', router);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
+provide('shouldOmitHeaderTitle', true);
+provide('shouldHeaderThin', true);
+
+const pageUrl = $computed(() => url + path);
+const contextmenu = $computed(() => {
+	return [{
+		type: 'label',
+		text: path,
+	}, {
+		icon: 'fas fa-expand-alt',
+		text: i18n.ts.showInPage,
+		action: expand,
+	}, {
+		icon: 'fas fa-external-link-alt',
+		text: i18n.ts.popout,
+		action: popout,
+	}, null, {
+		icon: 'fas fa-external-link-alt',
+		text: i18n.ts.openInNewTab,
+		action: () => {
+			window.open(pageUrl, '_blank');
+			modal.close();
+		},
+	}, {
+		icon: 'fas fa-link',
+		text: i18n.ts.copyLink,
+		action: () => {
+			copyToClipboard(pageUrl);
+		},
+	}];
+});
+
+function navigate(path, record = true) {
+	if (record) history.push(router.getCurrentPath());
+	router.push(path);
+}
+
+function back() {
+	navigate(history.pop(), false);
+}
+
+function expand() {
+	mainRouter.push(path);
+	modal.close();
+}
+
+function popout() {
+	_popout(path, rootEl);
+	modal.close();
+}
+
+function onContextmenu(ev: MouseEvent) {
+	os.contextMenu(contextmenu, ev);
+}
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index 9ec1e53c1e..c2c92f541d 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -225,7 +225,7 @@ function undoReact(note): void {
 	});
 }
 
-const currentClipPage = inject<Ref<misskey.entities.Clip>>('currentClipPage');
+const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null);
 
 function onContextmenu(ev: MouseEvent): void {
 	const isLink = (el: HTMLElement) => {
diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue
index 7455236bad..7de09d3be4 100644
--- a/packages/client/src/components/page-window.vue
+++ b/packages/client/src/components/page-window.vue
@@ -1,186 +1,135 @@
 <template>
-<XWindow ref="window"
+<XWindow
+	ref="windowEl"
 	:initial-width="500"
 	:initial-height="500"
 	:can-resize="true"
 	:close-button="true"
+	:buttons-left="buttonsLeft"
+	:buttons-right="buttonsRight"
 	:contextmenu="contextmenu"
 	@closed="$emit('closed')"
 >
 	<template #header>
-		<template v-if="pageInfo">
-			<i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i>
-			<span>{{ pageInfo.title }}</span>
+		<template v-if="pageMetadata?.value">
+			<i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i>
+			<span>{{ pageMetadata.value.title }}</span>
 		</template>
 	</template>
-	<template #headerLeft>
-		<button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button>
-	</template>
-	<template #headerRight>
-		<button v-tooltip="$ts.showInPage" class="_button" @click="expand()"><i class="fas fa-expand-alt"></i></button>
-		<button v-tooltip="$ts.popout" class="_button" @click="popout()"><i class="fas fa-external-link-alt"></i></button>
-		<button class="_button" @click="menu"><i class="fas fa-ellipsis-h"></i></button>
-	</template>
 
-	<div class="yrolvcoq" :style="{ background: pageInfo?.bg }">
-		<MkStickyContainer>
-			<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-			<component :is="component" v-bind="props" :ref="changePage"/>
-		</MkStickyContainer>
+	<div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }">
+		<RouterView :router="router"/>
 	</div>
 </XWindow>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { ComputedRef, inject, provide } from 'vue';
+import RouterView from './global/router-view.vue';
 import XWindow from '@/components/ui/window.vue';
-import { popout } from '@/scripts/popout';
+import { popout as _popout } from '@/scripts/popout';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { resolve } from '@/router';
 import { url } from '@/config';
-import * as symbols from '@/symbols';
 import * as os from '@/os';
+import { mainRouter, routes } from '@/router';
+import { Router } from '@/nirax';
+import { i18n } from '@/i18n';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		XWindow,
+const props = defineProps<{
+	initialPath: string;
+}>();
+
+defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const router = new Router(routes, props.initialPath);
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+let windowEl = $ref<InstanceType<typeof XWindow>>();
+const history = $ref<string[]>([props.initialPath]);
+const buttonsLeft = $computed(() => {
+	const buttons = [];
+
+	if (history.length > 1) {
+		buttons.push({
+			icon: 'fas fa-arrow-left',
+			onClick: back,
+		});
+	}
+
+	return buttons;
+});
+const buttonsRight = $computed(() => {
+	const buttons = [{
+		icon: 'fas fa-expand-alt',
+		title: i18n.ts.showInPage,
+		onClick: expand,
+	}];
+
+	return buttons;
+});
+
+router.addListener('push', ctx => {
+	history.push(router.getCurrentPath());
+});
+
+provide('router', router);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
+provide('shouldOmitHeaderTitle', true);
+provide('shouldHeaderThin', true);
+
+const contextmenu = $computed(() => ([{
+	icon: 'fas fa-expand-alt',
+	text: i18n.ts.showInPage,
+	action: expand,
+}, {
+	icon: 'fas fa-external-link-alt',
+	text: i18n.ts.popout,
+	action: popout,
+}, {
+	icon: 'fas fa-external-link-alt',
+	text: i18n.ts.openInNewTab,
+	action: () => {
+		window.open(url + router.getCurrentPath(), '_blank');
+		windowEl.close();
 	},
-
-	inject: {
-		sideViewHook: {
-			default: null
-		}
+}, {
+	icon: 'fas fa-link',
+	text: i18n.ts.copyLink,
+	action: () => {
+		copyToClipboard(url + router.getCurrentPath());
 	},
+}]));
 
-	provide() {
-		return {
-			navHook: (path) => {
-				this.navigate(path);
-			},
-			shouldHeaderThin: true,
-		};
-	},
+function menu(ev) {
+	os.popupMenu(contextmenu, ev.currentTarget ?? ev.target);
+}
 
-	props: {
-		initialPath: {
-			type: String,
-			required: true,
-		},
-		initialComponent: {
-			type: Object,
-			required: true,
-		},
-		initialProps: {
-			type: Object,
-			required: false,
-			default: () => {},
-		},
-	},
+function back() {
+	history.pop();
+	router.change(history[history.length - 1]);
+}
 
-	emits: ['closed'],
+function close() {
+	windowEl.close();
+}
 
-	data() {
-		return {
-			pageInfo: null,
-			path: this.initialPath,
-			component: this.initialComponent,
-			props: this.initialProps,
-			history: [],
-		};
-	},
+function expand() {
+	mainRouter.push(router.getCurrentPath());
+	windowEl.close();
+}
 
-	computed: {
-		url(): string {
-			return url + this.path;
-		},
+function popout() {
+	_popout(router.getCurrentPath(), windowEl.$el);
+	windowEl.close();
+}
 
-		contextmenu() {
-			return [{
-				type: 'label',
-				text: this.path,
-			}, {
-				icon: 'fas fa-expand-alt',
-				text: this.$ts.showInPage,
-				action: this.expand
-			}, this.sideViewHook ? {
-				icon: 'fas fa-columns',
-				text: this.$ts.openInSideView,
-				action: () => {
-					this.sideViewHook(this.path);
-					this.$refs.window.close();
-				}
-			} : undefined, {
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.popout,
-				action: this.popout
-			}, null, {
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.openInNewTab,
-				action: () => {
-					window.open(this.url, '_blank');
-					this.$refs.window.close();
-				}
-			}, {
-				icon: 'fas fa-link',
-				text: this.$ts.copyLink,
-				action: () => {
-					copyToClipboard(this.url);
-				}
-			}];
-		},
-	},
-
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
-		},
-
-		navigate(path, record = true) {
-			if (record) this.history.push(this.path);
-			this.path = path;
-			const { component, props } = resolve(path);
-			this.component = component;
-			this.props = props;
-		},
-
-		menu(ev) {
-			os.popupMenu([{
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.openInNewTab,
-				action: () => {
-					window.open(this.url, '_blank');
-					this.$refs.window.close();
-				}
-			}, {
-				icon: 'fas fa-link',
-				text: this.$ts.copyLink,
-				action: () => {
-					copyToClipboard(this.url);
-				}
-			}], ev.currentTarget ?? ev.target);
-		},
-
-		back() {
-			this.navigate(this.history.pop(), false);
-		},
-
-		close() {
-			this.$refs.window.close();
-		},
-
-		expand() {
-			this.$router.push(this.path);
-			this.$refs.window.close();
-		},
-
-		popout() {
-			popout(this.path, this.$el);
-			this.$refs.window.close();
-		},
-	},
+defineExpose({
+	close,
 });
 </script>
 
diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue
index 2066cf579d..3cd4378f03 100644
--- a/packages/client/src/components/ui/window.vue
+++ b/packages/client/src/components/ui/window.vue
@@ -4,14 +4,14 @@
 		<div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown">
 			<div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu">
 				<span class="left">
-					<slot name="headerLeft"></slot>
+					<button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
 				</span>
 				<span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
 					<slot name="header"></slot>
 				</span>
 				<span class="right">
-					<slot name="headerRight"></slot>
-					<button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button>
+					<button v-for="button in buttonsRight" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
+					<button v-if="closeButton" class="button _button" @click="close()"><i class="fas fa-times"></i></button>
 				</span>
 			</div>
 			<div v-if="padding" class="body">
@@ -46,41 +46,41 @@ const minHeight = 50;
 const minWidth = 250;
 
 function dragListen(fn) {
-	window.addEventListener('mousemove',  fn);
-	window.addEventListener('touchmove',  fn);
+	window.addEventListener('mousemove', fn);
+	window.addEventListener('touchmove', fn);
 	window.addEventListener('mouseleave', dragClear.bind(null, fn));
-	window.addEventListener('mouseup',    dragClear.bind(null, fn));
-	window.addEventListener('touchend',   dragClear.bind(null, fn));
+	window.addEventListener('mouseup', dragClear.bind(null, fn));
+	window.addEventListener('touchend', dragClear.bind(null, fn));
 }
 
 function dragClear(fn) {
-	window.removeEventListener('mousemove',  fn);
-	window.removeEventListener('touchmove',  fn);
+	window.removeEventListener('mousemove', fn);
+	window.removeEventListener('touchmove', fn);
 	window.removeEventListener('mouseleave', dragClear);
-	window.removeEventListener('mouseup',    dragClear);
-	window.removeEventListener('touchend',   dragClear);
+	window.removeEventListener('mouseup', dragClear);
+	window.removeEventListener('touchend', dragClear);
 }
 
 export default defineComponent({
 	provide: {
-		inWindow: true
+		inWindow: true,
 	},
 
 	props: {
 		padding: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		initialWidth: {
 			type: Number,
 			required: false,
-			default: 400
+			default: 400,
 		},
 		initialHeight: {
 			type: Number,
 			required: false,
-			default: null
+			default: null,
 		},
 		canResize: {
 			type: Boolean,
@@ -105,7 +105,17 @@ export default defineComponent({
 		contextmenu: {
 			type: Array,
 			required: false,
-		}
+		},
+		buttonsLeft: {
+			type: Array,
+			required: false,
+			default: [],
+		},
+		buttonsRight: {
+			type: Array,
+			required: false,
+			default: [],
+		},
 	},
 
 	emits: ['closed'],
@@ -162,7 +172,10 @@ export default defineComponent({
 			this.top();
 		},
 
-		onHeaderMousedown(evt) {
+		onHeaderMousedown(evt: MouseEvent) {
+			// 右クリックはコンテキストメニューを開こうとした可能性が高いため無視
+			if (evt.button === 2) return;
+
 			const main = this.$el as any;
 
 			if (!contains(main, document.activeElement)) main.focus();
@@ -356,12 +369,12 @@ export default defineComponent({
 			const browserHeight = window.innerHeight;
 			const windowWidth = main.offsetWidth;
 			const windowHeight = main.offsetHeight;
-			if (position.left < 0) main.style.left = 0;     // 左はみ出し
-			if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px';  // 下はみ出し
-			if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px';    // 右はみ出し
-			if (position.top < 0) main.style.top = 0;       // 上はみ出し
-		}
-	}
+			if (position.left < 0) main.style.left = 0; // 左はみ出し
+			if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し
+			if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し
+			if (position.top < 0) main.style.top = 0; // 上はみ出し
+		},
+	},
 });
 </script>
 
@@ -404,17 +417,25 @@ export default defineComponent({
 			border-bottom: solid 1px var(--divider);
 
 			> .left, > .right {
-				> ::v-deep(button) {
+				> .button {
 					height: var(--height);
 					width: var(--height);
 
 					&:hover {
 						color: var(--fgHighlighted);
 					}
+
+					&.highlighted {
+						color: var(--accent);
+					}
 				}
 			}
 
 			> .left {
+				margin-right: 16px;
+			}
+
+			> .right {
 				min-width: 16px;
 			}
 
diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts
index bb6176e409..a11dd2d050 100644
--- a/packages/client/src/init.ts
+++ b/packages/client/src/init.ts
@@ -21,7 +21,6 @@ import widgets from '@/widgets';
 import directives from '@/directives';
 import components from '@/components';
 import { version, ui, lang, host } from '@/config';
-import { router } from '@/router';
 import { applyTheme } from '@/scripts/theme';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
 import { i18n } from '@/i18n';
@@ -170,11 +169,10 @@ fetchInstanceMetaPromise.then(() => {
 
 const app = createApp(
 	window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
-	!$i                               ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
-	ui === 'deck'                     ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
-	ui === 'desktop'                  ? defineAsyncComponent(() => import('@/ui/desktop.vue')) :
-	ui === 'classic'                  ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
-	defineAsyncComponent(() => import('@/ui/universal.vue'))
+	!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
+	ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
+	ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
+	defineAsyncComponent(() => import('@/ui/universal.vue')),
 );
 
 if (_DEV_) {
@@ -189,14 +187,10 @@ app.config.globalProperties = {
 	$ts: i18n.ts,
 };
 
-app.use(router);
-
 widgets(app);
 directives(app);
 components(app);
 
-await router.isReady();
-
 const splash = document.getElementById('splash');
 // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
 if (splash) splash.addEventListener('transitionend', () => {
diff --git a/packages/client/src/menu.ts b/packages/client/src/menu.ts
index ebc7898101..5e281f4ea1 100644
--- a/packages/client/src/menu.ts
+++ b/packages/client/src/menu.ts
@@ -1,11 +1,11 @@
 import { computed, ref, reactive } from 'vue';
+import { $i } from './account';
+import { mainRouter } from '@/router';
 import { search } from '@/scripts/search';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
 import { ui } from '@/config';
-import { $i } from './account';
 import { unisonReload } from '@/scripts/unison-reload';
-import { router } from './router';
 
 export const menuDef = reactive({
 	notifications: {
@@ -60,16 +60,16 @@ export const menuDef = reactive({
 		title: 'lists',
 		icon: 'fas fa-list-ul',
 		show: computed(() => $i != null),
-		active: computed(() => router.currentRoute.value.path.startsWith('/timeline/list/') || router.currentRoute.value.path === '/my/lists' || router.currentRoute.value.path.startsWith('/my/lists/')),
+		active: computed(() => mainRouter.currentRoute.value.path.startsWith('/timeline/list/') || mainRouter.currentRoute.value.path === '/my/lists' || mainRouter.currentRoute.value.path.startsWith('/my/lists/')),
 		action: (ev) => {
 			const items = ref([{
-				type: 'pending'
+				type: 'pending',
 			}]);
 			os.api('users/lists/list').then(lists => {
 				const _items = [...lists.map(list => ({
 					type: 'link',
 					text: list.name,
-					to: `/timeline/list/${list.id}`
+					to: `/timeline/list/${list.id}`,
 				})), null, {
 					type: 'link',
 					to: '/my/lists',
@@ -91,16 +91,16 @@ export const menuDef = reactive({
 		title: 'antennas',
 		icon: 'fas fa-satellite',
 		show: computed(() => $i != null),
-		active: computed(() => router.currentRoute.value.path.startsWith('/timeline/antenna/') || router.currentRoute.value.path === '/my/antennas' || router.currentRoute.value.path.startsWith('/my/antennas/')),
+		active: computed(() => mainRouter.currentRoute.value.path.startsWith('/timeline/antenna/') || mainRouter.currentRoute.value.path === '/my/antennas' || mainRouter.currentRoute.value.path.startsWith('/my/antennas/')),
 		action: (ev) => {
 			const items = ref([{
-				type: 'pending'
+				type: 'pending',
 			}]);
 			os.api('antennas/list').then(antennas => {
 				const _items = [...antennas.map(antenna => ({
 					type: 'link',
 					text: antenna.name,
-					to: `/timeline/antenna/${antenna.id}`
+					to: `/timeline/antenna/${antenna.id}`,
 				})), null, {
 					type: 'link',
 					to: '/my/antennas',
@@ -178,29 +178,22 @@ export const menuDef = reactive({
 				action: () => {
 					localStorage.setItem('ui', 'default');
 					unisonReload();
-				}
+				},
 			}, {
 				text: i18n.ts.deck,
 				active: ui === 'deck',
 				action: () => {
 					localStorage.setItem('ui', 'deck');
 					unisonReload();
-				}
+				},
 			}, {
 				text: i18n.ts.classic,
 				active: ui === 'classic',
 				action: () => {
 					localStorage.setItem('ui', 'classic');
 					unisonReload();
-				}
-			}, /*{
-				text: i18n.ts.desktop + ' (β)',
-				active: ui === 'desktop',
-				action: () => {
-					localStorage.setItem('ui', 'desktop');
-					unisonReload();
-				}
-			}*/], ev.currentTarget ?? ev.target);
+				},
+			}], ev.currentTarget ?? ev.target);
 		},
 	},
 });
diff --git a/packages/client/src/nirax.ts b/packages/client/src/nirax.ts
new file mode 100644
index 0000000000..6840483c21
--- /dev/null
+++ b/packages/client/src/nirax.ts
@@ -0,0 +1,200 @@
+import { EventEmitter } from 'eventemitter3';
+import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue';
+
+type RouteDef = {
+	path: string;
+	component: Component;
+	query?: Record<string, string>;
+	name?: string;
+	globalCacheKey?: string;
+};
+
+type ParsedPath = (string | {
+	name: string;
+	startsWith?: string;
+	wildcard?: boolean;
+	optional?: boolean;
+})[];
+
+function parsePath(path: string): ParsedPath {
+	const res = [] as ParsedPath;
+
+	path = path.substring(1);
+
+	for (const part of path.split('/')) {
+		if (part.includes(':')) {
+			const prefix = part.substring(0, part.indexOf(':'));
+			const placeholder = part.substring(part.indexOf(':') + 1);
+			const wildcard = placeholder.includes('(*)');
+			const optional = placeholder.endsWith('?');
+			res.push({
+				name: placeholder.replace('(*)', '').replace('?', ''),
+				startsWith: prefix !== '' ? prefix : undefined,
+				wildcard,
+				optional,
+			});
+		} else {
+			res.push(part);
+		}
+	}
+
+	return res;
+}
+
+export class Router extends EventEmitter<{
+	change: (ctx: {
+		beforePath: string;
+		path: string;
+		route: RouteDef | null;
+		props: Map<string, string> | null;
+		key: string;
+	}) => void;
+	push: (ctx: {
+		beforePath: string;
+		path: string;
+		route: RouteDef | null;
+		props: Map<string, string> | null;
+		key: string;
+	}) => void;
+}> {
+	private routes: RouteDef[];
+	private currentPath: string;
+	private currentComponent: Component | null = null;
+	private currentProps: Map<string, string> | null = null;
+	private currentKey = Date.now().toString();
+
+	public currentRoute: ShallowRef<RouteDef | null> = shallowRef(null);
+
+	constructor(routes: Router['routes'], currentPath: Router['currentPath']) {
+		super();
+
+		this.routes = routes;
+		this.currentPath = currentPath;
+		this.navigate(currentPath, null, true);
+	}
+
+	public resolve(path: string): { route: RouteDef; props: Map<string, string>; } | null {
+		let queryString: string | null = null;
+		if (path[0] === '/') path = path.substring(1);
+		if (path.includes('?')) {
+			queryString = path.substring(path.indexOf('?') + 1);
+			path = path.substring(0, path.indexOf('?'));
+		}
+
+		if (_DEV_) console.log('Routing: ', path, queryString);
+
+		forEachRouteLoop:
+		for (const route of this.routes) {
+			let parts = path.split('/');
+			const props = new Map<string, string>();
+
+			pathMatchLoop:
+			for (const p of parsePath(route.path)) {
+				if (typeof p === 'string') {
+					if (p === parts[0]) {
+						parts.shift();
+					} else {
+						continue forEachRouteLoop;
+					}
+				} else {
+					if (parts[0] == null && !p.optional) {
+						continue forEachRouteLoop;
+					}
+					if (p.wildcard) {
+						if (parts.length !== 0) {
+							props.set(p.name, parts.join('/'));
+							parts = [];
+						}
+						break pathMatchLoop;
+					} else {
+						if (p.startsWith && (parts[0] == null || !parts[0].startsWith(p.startsWith))) continue forEachRouteLoop;
+
+						props.set(p.name, parts[0]);
+						parts.shift();
+					}
+				}
+			}
+
+			if (parts.length !== 0) continue forEachRouteLoop;
+
+			if (route.query != null && queryString != null) {
+				const queryObject = [...new URLSearchParams(queryString).entries()]
+					.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
+
+				for (const q in route.query) {
+					const as = route.query[q];
+					if (queryObject[q]) {
+						props.set(as, queryObject[q]);
+					}
+				}
+			}
+			return {
+				route,
+				props,
+			};
+		}
+
+		return null;
+	}
+
+	private navigate(path: string, key: string | null | undefined, initial = false) {
+		const beforePath = this.currentPath;
+		const beforeRoute = this.currentRoute.value;
+		this.currentPath = path;
+
+		const res = this.resolve(this.currentPath);
+
+		if (res == null) {
+			throw new Error('no route found for: ' + path);
+		}
+
+		const isSamePath = beforePath === path;
+		if (isSamePath && key == null) key = this.currentKey;
+		this.currentComponent = res.route.component;
+		this.currentProps = res.props;
+		this.currentRoute.value = res.route;
+		this.currentKey = this.currentRoute.value.globalCacheKey ?? key ?? Date.now().toString();
+
+		if (!initial) {
+			this.emit('change', {
+				beforePath,
+				path,
+				route: this.currentRoute.value,
+				props: this.currentProps,
+				key: this.currentKey,
+			});
+		}
+	}
+
+	public getCurrentComponent() {
+		return this.currentComponent;
+	}
+
+	public getCurrentProps() {
+		return this.currentProps;
+	}
+
+	public getCurrentPath() {
+		return this.currentPath;
+	}
+
+	public getCurrentKey() {
+		return this.currentKey;
+	}
+
+	public push(path: string) {
+		const beforePath = this.currentPath;
+		this.navigate(path, null);
+		this.emit('push', {
+			beforePath,
+			path,
+			route: this.currentRoute.value,
+			props: this.currentProps,
+			key: this.currentKey,
+		});
+	}
+
+	public change(path: string, key?: string | null) {
+		this.navigate(path, key);
+	}
+}
diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts
index 14860465fa..e823d3719c 100644
--- a/packages/client/src/os.ts
+++ b/packages/client/src/os.ts
@@ -8,7 +8,6 @@ import { apiUrl, url } from '@/config';
 import MkPostFormDialog from '@/components/post-form-dialog.vue';
 import MkWaitingDialog from '@/components/waiting-dialog.vue';
 import { MenuItem } from '@/types/menu';
-import { resolve } from '@/router';
 import { $i } from '@/account';
 
 export const pendingApiRequestsCount = ref(0);
@@ -155,20 +154,14 @@ export async function popup(component: Component, props: Record<string, any>, ev
 }
 
 export function pageWindow(path: string) {
-	const { component, props } = resolve(path);
 	popup(defineAsyncComponent(() => import('@/components/page-window.vue')), {
 		initialPath: path,
-		initialComponent: markRaw(component),
-		initialProps: props,
 	}, {}, 'closed');
 }
 
 export function modalPageWindow(path: string) {
-	const { component, props } = resolve(path);
 	popup(defineAsyncComponent(() => import('@/components/modal-page-window.vue')), {
 		initialPath: path,
-		initialComponent: markRaw(component),
-		initialProps: props,
 	}, {}, 'closed');
 }
 
diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue
index 4cfe2e255c..6ac1f4297a 100644
--- a/packages/client/src/pages/_error_.vue
+++ b/packages/client/src/pages/_error_.vue
@@ -21,11 +21,11 @@
 import { } from 'vue';
 import * as misskey from 'misskey-js';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
 import { version } from '@/config';
 import * as os from '@/os';
 import { unisonReload } from '@/scripts/unison-reload';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = withDefaults(defineProps<{
 	error?: Error;
@@ -52,11 +52,13 @@ function reload() {
 	unisonReload();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.error,
-		icon: 'fas fa-exclamation-triangle',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.error,
+	icon: 'fas fa-exclamation-triangle',
 });
 </script>
 
diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue
index 691bc4f07b..ba85860cda 100644
--- a/packages/client/src/pages/about-misskey.vue
+++ b/packages/client/src/pages/about-misskey.vue
@@ -1,62 +1,65 @@
 <template>
-<div style="overflow: clip;">
-	<MkSpacer :content-max="600" :margin-min="20">
-		<div class="_formRoot znqjceqz">
-			<div id="debug"></div>
-			<div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }">
-				<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
-				<div class="misskey">Misskey</div>
-				<div class="version">v{{ version }}</div>
-				<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
-			</div>
-			<div class="_formBlock" style="text-align: center;">
-				{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
-			</div>
-			<div class="_formBlock" style="text-align: center;">
-				<MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
-			</div>
-			<FormSection>
-				<div class="_formLinks">
-					<FormLink to="https://github.com/misskey-dev/misskey" external>
-						<template #icon><i class="fas fa-code"></i></template>
-						{{ i18n.ts._aboutMisskey.source }}
-						<template #suffix>GitHub</template>
-					</FormLink>
-					<FormLink to="https://crowdin.com/project/misskey" external>
-						<template #icon><i class="fas fa-language"></i></template>
-						{{ i18n.ts._aboutMisskey.translation }}
-						<template #suffix>Crowdin</template>
-					</FormLink>
-					<FormLink to="https://www.patreon.com/syuilo" external>
-						<template #icon><i class="fas fa-hand-holding-medical"></i></template>
-						{{ i18n.ts._aboutMisskey.donate }}
-						<template #suffix>Patreon</template>
-					</FormLink>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div style="overflow: clip;">
+		<MkSpacer :content-max="600" :margin-min="20">
+			<div class="_formRoot znqjceqz">
+				<div id="debug"></div>
+				<div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }">
+					<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
+					<div class="misskey">Misskey</div>
+					<div class="version">v{{ version }}</div>
+					<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
 				</div>
-			</FormSection>
-			<FormSection>
-				<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
-				<div class="_formLinks">
-					<FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
-					<FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
-					<FormLink to="https://github.com/mei23" external>@mei23</FormLink>
-					<FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
-					<FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
-					<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
-					<FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
-					<FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
-					<FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
+				<div class="_formBlock" style="text-align: center;">
+					{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
 				</div>
-				<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
-			</FormSection>
-			<FormSection>
-				<template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
-				<div v-for="patron in patrons" :key="patron">{{ patron }}</div>
-				<template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
-			</FormSection>
-		</div>
-	</MkSpacer>
-</div>
+				<div class="_formBlock" style="text-align: center;">
+					<MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
+				</div>
+				<FormSection>
+					<div class="_formLinks">
+						<FormLink to="https://github.com/misskey-dev/misskey" external>
+							<template #icon><i class="fas fa-code"></i></template>
+							{{ i18n.ts._aboutMisskey.source }}
+							<template #suffix>GitHub</template>
+						</FormLink>
+						<FormLink to="https://crowdin.com/project/misskey" external>
+							<template #icon><i class="fas fa-language"></i></template>
+							{{ i18n.ts._aboutMisskey.translation }}
+							<template #suffix>Crowdin</template>
+						</FormLink>
+						<FormLink to="https://www.patreon.com/syuilo" external>
+							<template #icon><i class="fas fa-hand-holding-medical"></i></template>
+							{{ i18n.ts._aboutMisskey.donate }}
+							<template #suffix>Patreon</template>
+						</FormLink>
+					</div>
+				</FormSection>
+				<FormSection>
+					<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
+					<div class="_formLinks">
+						<FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
+						<FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
+						<FormLink to="https://github.com/mei23" external>@mei23</FormLink>
+						<FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
+						<FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
+						<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
+						<FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
+						<FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
+						<FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
+					</div>
+					<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
+				</FormSection>
+				<FormSection>
+					<template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
+					<div v-for="patron in patrons" :key="patron">{{ patron }}</div>
+					<template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
+				</FormSection>
+			</div>
+		</MkSpacer>
+	</div>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -67,10 +70,10 @@ import FormSection from '@/components/form/section.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkLink from '@/components/link.vue';
 import { physics } from '@/scripts/physics';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { defaultStore } from '@/store';
 import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const patrons = [
 	'まっちゃとーにゅ',
@@ -194,12 +197,14 @@ onBeforeUnmount(() => {
 	}
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.aboutMisskey,
-		icon: null,
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.aboutMisskey,
+	icon: null,
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
index 6cc2e387ec..20497c86fc 100644
--- a/packages/client/src/pages/about.vue
+++ b/packages/client/src/pages/about.vue
@@ -1,78 +1,81 @@
 <template>
-<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
-	<div class="_formRoot">
-		<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
-			<div class="content">
-				<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
-				<div class="name">
-					<b>{{ $instance.name || host }}</b>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
+		<div class="_formRoot">
+			<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+				<div class="content">
+					<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+					<div class="name">
+						<b>{{ $instance.name || host }}</b>
+					</div>
 				</div>
 			</div>
-		</div>
 
-		<MkKeyValue class="_formBlock">
-			<template #key>{{ $ts.description }}</template>
-			<template #value>{{ $instance.description }}</template>
-		</MkKeyValue>
-
-		<FormSection>
-			<MkKeyValue class="_formBlock" :copy="version">
-				<template #key>Misskey</template>
-				<template #value>{{ version }}</template>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ $ts.description }}</template>
+				<template #value>{{ $instance.description }}</template>
 			</MkKeyValue>
-			<FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink>
-		</FormSection>
 
-		<FormSection>
-			<FormSplit>
-				<MkKeyValue class="_formBlock">
-					<template #key>{{ $ts.administrator }}</template>
-					<template #value>{{ $instance.maintainerName }}</template>
-				</MkKeyValue>
-				<MkKeyValue class="_formBlock">
-					<template #key>{{ $ts.contact }}</template>
-					<template #value>{{ $instance.maintainerEmail }}</template>
-				</MkKeyValue>
-			</FormSplit>
-			<FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink>
-		</FormSection>
-
-		<FormSuspense :p="initStats">
 			<FormSection>
-				<template #label>{{ $ts.statistics }}</template>
+				<MkKeyValue class="_formBlock" :copy="version">
+					<template #key>Misskey</template>
+					<template #value>{{ version }}</template>
+				</MkKeyValue>
+				<FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink>
+			</FormSection>
+
+			<FormSection>
 				<FormSplit>
 					<MkKeyValue class="_formBlock">
-						<template #key>{{ $ts.users }}</template>
-						<template #value>{{ number(stats.originalUsersCount) }}</template>
+						<template #key>{{ $ts.administrator }}</template>
+						<template #value>{{ $instance.maintainerName }}</template>
 					</MkKeyValue>
 					<MkKeyValue class="_formBlock">
-						<template #key>{{ $ts.notes }}</template>
-						<template #value>{{ number(stats.originalNotesCount) }}</template>
+						<template #key>{{ $ts.contact }}</template>
+						<template #value>{{ $instance.maintainerEmail }}</template>
 					</MkKeyValue>
 				</FormSplit>
+				<FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink>
 			</FormSection>
-		</FormSuspense>
 
-		<FormSection>
-			<template #label>Well-known resources</template>
-			<div class="_formLinks">
-				<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
-				<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
-				<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
-				<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
-				<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
-			</div>
-		</FormSection>
-	</div>
-</MkSpacer>
-<MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20">
-	<MkInstanceStats :chart-limit="500" :detailed="true"/>
-</MkSpacer>
+			<FormSuspense :p="initStats">
+				<FormSection>
+					<template #label>{{ $ts.statistics }}</template>
+					<FormSplit>
+						<MkKeyValue class="_formBlock">
+							<template #key>{{ $ts.users }}</template>
+							<template #value>{{ number(stats.originalUsersCount) }}</template>
+						</MkKeyValue>
+						<MkKeyValue class="_formBlock">
+							<template #key>{{ $ts.notes }}</template>
+							<template #value>{{ number(stats.originalNotesCount) }}</template>
+						</MkKeyValue>
+					</FormSplit>
+				</FormSection>
+			</FormSuspense>
+
+			<FormSection>
+				<template #label>Well-known resources</template>
+				<div class="_formLinks">
+					<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
+					<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
+					<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
+					<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
+					<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
+				</div>
+			</FormSection>
+		</div>
+	</MkSpacer>
+	<MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20">
+		<MkInstanceStats :chart-limit="500" :detailed="true"/>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { ref, computed } from 'vue';
-import { version, instanceName } from '@/config';
+import { version, instanceName , host } from '@/config';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
 import FormSuspense from '@/components/form/suspense.vue';
@@ -81,9 +84,8 @@ import MkKeyValue from '@/components/key-value.vue';
 import MkInstanceStats from '@/components/instance-stats.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
-import * as symbols from '@/symbols';
-import { host } from '@/config';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let stats = $ref(null);
 let tab = $ref('overview');
@@ -93,23 +95,24 @@ const initStats = () => os.api('stats', {
 	stats = res;
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.instanceInfo,
-		icon: 'fas fa-info-circle',
-		bg: 'var(--bg)',
-		tabs: [{
-			active: tab === 'overview',
-			title: i18n.ts.overview,
-			onClick: () => { tab = 'overview'; },
-		}, {
-			active: tab === 'charts',
-			title: i18n.ts.charts,
-			icon: 'fas fa-chart-bar',
-			onClick: () => { tab = 'charts'; },
-		},],
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'overview',
+	title: i18n.ts.overview,
+	onClick: () => { tab = 'overview'; },
+}, {
+	active: tab === 'charts',
+	title: i18n.ts.charts,
+	icon: 'fas fa-chart-bar',
+	onClick: () => { tab = 'charts'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.instanceInfo,
+	icon: 'fas fa-info-circle',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin-file.vue b/packages/client/src/pages/admin-file.vue
index b0e879b8f3..67dcd88bc4 100644
--- a/packages/client/src/pages/admin-file.vue
+++ b/packages/client/src/pages/admin-file.vue
@@ -1,30 +1,33 @@
 <template>
-<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
-	<div v-if="file" class="cxqhhsmd _formRoot">
-		<div class="_formBlock">
-			<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
-			<div class="info">
-				<span style="margin-right: 1em;">{{ file.type }}</span>
-				<span>{{ bytes(file.size) }}</span>
-				<MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
+		<div v-if="file" class="cxqhhsmd _formRoot">
+			<div class="_formBlock">
+				<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+				<div class="info">
+					<span style="margin-right: 1em;">{{ file.type }}</span>
+					<span>{{ bytes(file.size) }}</span>
+					<MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
+				</div>
+			</div>
+			<div class="_formBlock">
+				<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
+			</div>
+			<div class="_formBlock">
+				<MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
+			</div>
+			<div class="_formBlock">
+				<MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+			</div>
+			<div v-if="info" class="_formBlock">
+				<details class="_content rawdata">
+					<pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
+				</details>
 			</div>
 		</div>
-		<div class="_formBlock">
-			<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
-		</div>
-		<div class="_formBlock">
-			<MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
-		</div>
-		<div class="_formBlock">
-			<MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
-		</div>
-		<div v-if="info" class="_formBlock">
-			<details class="_content rawdata">
-				<pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
-			</details>
-		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -35,7 +38,7 @@ import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
 import bytes from '@/filters/bytes';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let file: any = $ref(null);
 let info: any = $ref(null);
@@ -74,13 +77,15 @@ async function toggleIsSensitive(v) {
 	isSensitive = v;
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file,
-		icon: 'fas fa-file',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file,
+	icon: 'fas fa-file',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue
new file mode 100644
index 0000000000..9e11d065d9
--- /dev/null
+++ b/packages/client/src/pages/admin/_header_.vue
@@ -0,0 +1,249 @@
+<template>
+<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick">
+	<template v-if="metadata">
+		<div class="titleContainer" @click="showTabsPopup">
+			<i v-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+			<div class="title">
+				<div class="title">{{ metadata.title }}</div>
+			</div>
+		</div>
+		<div class="tabs">
+			<button v-for="tab in tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick">
+				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+			</button>
+		</div>
+	</template>
+	<div class="buttons right">
+		<template v-if="actions">
+			<template v-for="action in actions">
+				<MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
+				<button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+			</template>
+		</template>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref, inject } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { url } from '@/config';
+import { scrollToTop } from '@/scripts/scroll';
+import MkButton from '@/components/ui/button.vue';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+import { injectPageMetadata, PageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	tabs?: {
+		title: string;
+		active: boolean;
+		icon?: string;
+		iconOnly?: boolean;
+		onClick: () => void;
+	}[];
+	actions?: {
+		text: string;
+		icon: string;
+		asFullButton?: boolean;
+		handler: (ev: MouseEvent) => void;
+	}[];
+	thin?: boolean;
+}>();
+
+const metadata = injectPageMetadata();
+
+const el = ref<HTMLElement>(null);
+const bg = ref(null);
+const height = ref(0);
+const hasTabs = computed(() => {
+	return props.tabs && props.tabs.length > 0;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+	if (!hasTabs.value) return;
+	if (!narrow.value) return;
+	ev.preventDefault();
+	ev.stopPropagation();
+	const menu = props.tabs.map(tab => ({
+		text: tab.title,
+		icon: tab.icon,
+		action: tab.onClick,
+	}));
+	popupMenu(menu, ev.currentTarget ?? ev.target);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+	ev.stopPropagation();
+};
+
+const onClick = () => {
+	scrollToTop(el.value, { behavior: 'smooth' });
+};
+
+const calcBg = () => {
+	const rawBg = metadata?.bg || 'var(--bg)';
+	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+	tinyBg.setAlpha(0.85);
+	bg.value = tinyBg.toRgbString();
+};
+
+onMounted(() => {
+	calcBg();
+	globalEvents.on('themeChanged', calcBg);
+});
+
+onUnmounted(() => {
+	globalEvents.off('themeChanged', calcBg);
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkc {
+	--height: 60px;
+	display: flex;
+	position: sticky;
+	top: var(--stickyTop, 0);
+	z-index: 1000;
+	width: 100%;
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+
+	> .buttons {
+		--margin: 8px;
+		display: flex;
+    align-items: center;
+		height: var(--height);
+		margin: 0 var(--margin);
+
+		&.right {
+			margin-left: auto;
+		}
+
+		&:empty {
+			width: var(--height);
+		}
+
+		> .button {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			height: calc(var(--height) - (var(--margin) * 2));
+			width: calc(var(--height) - (var(--margin) * 2));
+			box-sizing: border-box;
+			position: relative;
+			border-radius: 5px;
+
+			&:hover {
+				background: rgba(0, 0, 0, 0.05);
+			}
+
+			&.highlighted {
+				color: var(--accent);
+			}
+		}
+
+		> .fullButton {
+			& + .fullButton {
+				margin-left: 12px;
+			}
+		}
+	}
+
+	> .titleContainer {
+		display: flex;
+		align-items: center;
+		max-width: 400px;
+		overflow: auto;
+		white-space: nowrap;
+		text-align: left;
+		font-weight: bold;
+		flex-shrink: 0;
+		margin-left: 24px;
+
+		> .avatar {
+			$size: 32px;
+			display: inline-block;
+			width: $size;
+			height: $size;
+			vertical-align: bottom;
+			margin: 0 8px;
+			pointer-events: none;
+		}
+
+		> .icon {
+			margin-right: 8px;
+		}
+
+		> .title {
+			min-width: 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			line-height: 1.1;
+
+			> .subtitle {
+				opacity: 0.6;
+				font-size: 0.8em;
+				font-weight: normal;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+
+				&.activeTab {
+					text-align: center;
+
+					> .chevron {
+						display: inline-block;
+						margin-left: 6px;
+					}
+				}
+			}
+		}
+	}
+
+	> .tabs {
+		margin-left: 16px;
+		font-size: 0.8em;
+		overflow: auto;
+		white-space: nowrap;
+
+		> .tab {
+			display: inline-block;
+			position: relative;
+			padding: 0 10px;
+			height: 100%;
+			font-weight: normal;
+			opacity: 0.7;
+
+			&:hover {
+				opacity: 1;
+			}
+
+			&.active {
+				opacity: 1;
+
+				&:after {
+					content: "";
+					display: block;
+					position: absolute;
+					bottom: 0;
+					left: 0;
+					right: 0;
+					margin: 0 auto;
+					width: 100%;
+					height: 3px;
+					background: var(--accent);
+				}
+			}
+
+			> .icon + .title {
+				margin-left: 8px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue
index e1d0361c0b..2b6dadf7c6 100644
--- a/packages/client/src/pages/admin/abuses.vue
+++ b/packages/client/src/pages/admin/abuses.vue
@@ -1,28 +1,31 @@
 <template>
-<div class="lcixvhis">
-	<div class="_section reports">
-		<div class="_content">
-			<div class="inputs" style="display: flex;">
-				<MkSelect v-model="state" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.state }}</template>
-					<option value="all">{{ $ts.all }}</option>
-					<option value="unresolved">{{ $ts.unresolved }}</option>
-					<option value="resolved">{{ $ts.resolved }}</option>
-				</MkSelect>
-				<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.reporteeOrigin }}</template>
-					<option value="combined">{{ $ts.all }}</option>
-					<option value="local">{{ $ts.local }}</option>
-					<option value="remote">{{ $ts.remote }}</option>
-				</MkSelect>
-				<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.reporterOrigin }}</template>
-					<option value="combined">{{ $ts.all }}</option>
-					<option value="local">{{ $ts.local }}</option>
-					<option value="remote">{{ $ts.remote }}</option>
-				</MkSelect>
-			</div>
-			<!-- TODO
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="lcixvhis">
+			<div class="_section reports">
+				<div class="_content">
+					<div class="inputs" style="display: flex;">
+						<MkSelect v-model="state" style="margin: 0; flex: 1;">
+							<template #label>{{ $ts.state }}</template>
+							<option value="all">{{ $ts.all }}</option>
+							<option value="unresolved">{{ $ts.unresolved }}</option>
+							<option value="resolved">{{ $ts.resolved }}</option>
+						</MkSelect>
+						<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+							<template #label>{{ $ts.reporteeOrigin }}</template>
+							<option value="combined">{{ $ts.all }}</option>
+							<option value="local">{{ $ts.local }}</option>
+							<option value="remote">{{ $ts.remote }}</option>
+						</MkSelect>
+						<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+							<template #label>{{ $ts.reporterOrigin }}</template>
+							<option value="combined">{{ $ts.all }}</option>
+							<option value="local">{{ $ts.local }}</option>
+							<option value="remote">{{ $ts.remote }}</option>
+						</MkSelect>
+					</div>
+					<!-- TODO
 			<div class="inputs" style="display: flex; padding-top: 1.2em;">
 				<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false">
 					<span>{{ $ts.username }}</span>
@@ -33,24 +36,27 @@
 			</div>
 			-->
 
-			<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
-				<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
-			</MkPagination>
+					<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
+						<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
+					</MkPagination>
+				</div>
+			</div>
 		</div>
-	</div>
-</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { computed } from 'vue';
 
+import XHeader from './_header_.vue';
 import MkInput from '@/components/form/input.vue';
 import MkSelect from '@/components/form/select.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import XAbuseReport from '@/components/abuse-report.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let reports = $ref<InstanceType<typeof MkPagination>>();
 
@@ -74,12 +80,14 @@ function resolved(reportId) {
 	reports.removeItem(item => item.id === reportId);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.abuseReports,
-		icon: 'fas fa-exclamation-circle',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.abuseReports,
+	icon: 'fas fa-exclamation-circle',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue
index b18e08db96..05557469e7 100644
--- a/packages/client/src/pages/admin/ads.vue
+++ b/packages/client/src/pages/admin/ads.vue
@@ -1,21 +1,23 @@
 <template>
-<MkSpacer :content-max="900">
-	<div class="uqshojas">
-		<div v-for="ad in ads" class="_panel _formRoot ad">
-			<MkAd v-if="ad.url" :specify="ad"/>
-			<MkInput v-model="ad.url" type="url" class="_formBlock">
-				<template #label>URL</template>
-			</MkInput>
-			<MkInput v-model="ad.imageUrl" class="_formBlock">
-				<template #label>{{ i18n.ts.imageUrl }}</template>
-			</MkInput>
-			<FormRadios v-model="ad.place" class="_formBlock">
-				<template #label>Form</template>
-				<option value="square">square</option>
-				<option value="horizontal">horizontal</option>
-				<option value="horizontal-big">horizontal-big</option>
-			</FormRadios>
-			<!--
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="uqshojas">
+			<div v-for="ad in ads" class="_panel _formRoot ad">
+				<MkAd v-if="ad.url" :specify="ad"/>
+				<MkInput v-model="ad.url" type="url" class="_formBlock">
+					<template #label>URL</template>
+				</MkInput>
+				<MkInput v-model="ad.imageUrl" class="_formBlock">
+					<template #label>{{ i18n.ts.imageUrl }}</template>
+				</MkInput>
+				<FormRadios v-model="ad.place" class="_formBlock">
+					<template #label>Form</template>
+					<option value="square">square</option>
+					<option value="horizontal">horizontal</option>
+					<option value="horizontal-big">horizontal-big</option>
+				</FormRadios>
+				<!--
 			<div style="margin: 32px 0;">
 				{{ i18n.ts.priority }}
 				<MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio>
@@ -23,36 +25,38 @@
 				<MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio>
 			</div>
 			-->
-			<FormSplit>
-				<MkInput v-model="ad.ratio" type="number">
-					<template #label>{{ i18n.ts.ratio }}</template>
-				</MkInput>
-				<MkInput v-model="ad.expiresAt" type="date">
-					<template #label>{{ i18n.ts.expiration }}</template>
-				</MkInput>
-			</FormSplit>
-			<MkTextarea v-model="ad.memo" class="_formBlock">
-				<template #label>{{ i18n.ts.memo }}</template>
-			</MkTextarea>
-			<div class="buttons _formBlock">
-				<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
-				<MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+				<FormSplit>
+					<MkInput v-model="ad.ratio" type="number">
+						<template #label>{{ i18n.ts.ratio }}</template>
+					</MkInput>
+					<MkInput v-model="ad.expiresAt" type="date">
+						<template #label>{{ i18n.ts.expiration }}</template>
+					</MkInput>
+				</FormSplit>
+				<MkTextarea v-model="ad.memo" class="_formBlock">
+					<template #label>{{ i18n.ts.memo }}</template>
+				</MkTextarea>
+				<div class="buttons _formBlock">
+					<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
+					<MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+				</div>
 			</div>
 		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import FormRadios from '@/components/form/radios.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let ads: any[] = $ref([]);
 
@@ -81,7 +85,7 @@ function remove(ad) {
 		if (canceled) return;
 		ads = ads.filter(x => x !== ad);
 		os.apiWithDialog('admin/ad/delete', {
-			id: ad.id
+			id: ad.id,
 		});
 	});
 }
@@ -90,28 +94,29 @@ function save(ad) {
 	if (ad.id == null) {
 		os.apiWithDialog('admin/ad/create', {
 			...ad,
-			expiresAt: new Date(ad.expiresAt).getTime()
+			expiresAt: new Date(ad.expiresAt).getTime(),
 		});
 	} else {
 		os.apiWithDialog('admin/ad/update', {
 			...ad,
-			expiresAt: new Date(ad.expiresAt).getTime()
+			expiresAt: new Date(ad.expiresAt).getTime(),
 		});
 	}
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.ads,
-		icon: 'fas fa-audio-description',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.add,
-			handler: add,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.add,
+	handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.ads,
+	icon: 'fas fa-audio-description',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue
index 97774975de..025897d093 100644
--- a/packages/client/src/pages/admin/announcements.vue
+++ b/packages/client/src/pages/admin/announcements.vue
@@ -1,34 +1,40 @@
 <template>
-<div class="ztgjmzrw">
-	<section v-for="announcement in announcements" class="_card _gap announcements">
-		<div class="_content announcement">
-			<MkInput v-model="announcement.title">
-				<template #label>{{ i18n.ts.title }}</template>
-			</MkInput>
-			<MkTextarea v-model="announcement.text">
-				<template #label>{{ i18n.ts.text }}</template>
-			</MkTextarea>
-			<MkInput v-model="announcement.imageUrl">
-				<template #label>{{ i18n.ts.imageUrl }}</template>
-			</MkInput>
-			<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
-			<div class="buttons">
-				<MkButton class="button" inline primary @click="save(announcement)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
-				<MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
-			</div>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="ztgjmzrw">
+			<section v-for="announcement in announcements" class="_card _gap announcements">
+				<div class="_content announcement">
+					<MkInput v-model="announcement.title">
+						<template #label>{{ i18n.ts.title }}</template>
+					</MkInput>
+					<MkTextarea v-model="announcement.text">
+						<template #label>{{ i18n.ts.text }}</template>
+					</MkTextarea>
+					<MkInput v-model="announcement.imageUrl">
+						<template #label>{{ i18n.ts.imageUrl }}</template>
+					</MkInput>
+					<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
+					<div class="buttons">
+						<MkButton class="button" inline primary @click="save(announcement)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
+						<MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+					</div>
+				</div>
+			</section>
 		</div>
-	</section>
-</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let announcements: any[] = $ref([]);
 
@@ -41,7 +47,7 @@ function add() {
 		id: null,
 		title: '',
 		text: '',
-		imageUrl: null
+		imageUrl: null,
 	});
 }
 
@@ -61,41 +67,42 @@ function save(announcement) {
 		os.api('admin/announcements/create', announcement).then(() => {
 			os.alert({
 				type: 'success',
-				text: i18n.ts.saved
+				text: i18n.ts.saved,
 			});
 		}).catch(err => {
 			os.alert({
 				type: 'error',
-				text: err
+				text: err,
 			});
 		});
 	} else {
 		os.api('admin/announcements/update', announcement).then(() => {
 			os.alert({
 				type: 'success',
-				text: i18n.ts.saved
+				text: i18n.ts.saved,
 			});
 		}).catch(err => {
 			os.alert({
 				type: 'error',
-				text: err
+				text: err,
 			});
 		});
 	}
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.announcements,
-		icon: 'fas fa-broadcast-tower',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.add,
-			handler: add,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.add,
+	handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.announcements,
+	icon: 'fas fa-broadcast-tower',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue
index 30fee5015a..d2e7919b4f 100644
--- a/packages/client/src/pages/admin/bot-protection.vue
+++ b/packages/client/src/pages/admin/bot-protection.vue
@@ -51,7 +51,6 @@ import FormButton from '@/components/ui/button.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import FormSlot from '@/components/form/slot.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 
 const MkCaptcha = defineAsyncComponent(() => import('@/components/captcha.vue'));
diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue
index d3519922b1..b9c5f9e393 100644
--- a/packages/client/src/pages/admin/database.vue
+++ b/packages/client/src/pages/admin/database.vue
@@ -1,12 +1,13 @@
-<template>
-<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
 	<FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory">
 		<MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;">
 			<template #key>{{ table[0] }}</template>
 			<template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template>
 		</MkKeyValue>
 	</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -14,18 +15,20 @@ import { } from 'vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import bytes from '@/filters/bytes';
 import number from '@/filters/number';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size));
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.database,
-		icon: 'fas fa-database',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.database,
+	icon: 'fas fa-database',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue
index aa13043193..5487c5f333 100644
--- a/packages/client/src/pages/admin/email-settings.vue
+++ b/packages/client/src/pages/admin/email-settings.vue
@@ -1,49 +1,53 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormSwitch v-model="enableEmail" class="_formBlock">
-				<template #label>{{ i18n.ts.enableEmail }}</template>
-				<template #caption>{{ i18n.ts.emailConfigInfo }}</template>
-			</FormSwitch>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormSwitch v-model="enableEmail" class="_formBlock">
+					<template #label>{{ i18n.ts.enableEmail }}</template>
+					<template #caption>{{ i18n.ts.emailConfigInfo }}</template>
+				</FormSwitch>
 
-			<template v-if="enableEmail">
-				<FormInput v-model="email" type="email" class="_formBlock">
-					<template #label>{{ i18n.ts.emailAddress }}</template>
-				</FormInput>
+				<template v-if="enableEmail">
+					<FormInput v-model="email" type="email" class="_formBlock">
+						<template #label>{{ i18n.ts.emailAddress }}</template>
+					</FormInput>
 
-				<FormSection>
-					<template #label>{{ i18n.ts.smtpConfig }}</template>
-					<FormSplit :min-width="280">
-						<FormInput v-model="smtpHost" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpHost }}</template>
-						</FormInput>
-						<FormInput v-model="smtpPort" type="number" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpPort }}</template>
-						</FormInput>
-					</FormSplit>
-					<FormSplit :min-width="280">
-						<FormInput v-model="smtpUser" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpUser }}</template>
-						</FormInput>
-						<FormInput v-model="smtpPass" type="password" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpPass }}</template>
-						</FormInput>
-					</FormSplit>
-					<FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
-					<FormSwitch v-model="smtpSecure" class="_formBlock">
-						<template #label>{{ i18n.ts.smtpSecure }}</template>
-						<template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
-					</FormSwitch>
-				</FormSection>
-			</template>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+					<FormSection>
+						<template #label>{{ i18n.ts.smtpConfig }}</template>
+						<FormSplit :min-width="280">
+							<FormInput v-model="smtpHost" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpHost }}</template>
+							</FormInput>
+							<FormInput v-model="smtpPort" type="number" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpPort }}</template>
+							</FormInput>
+						</FormSplit>
+						<FormSplit :min-width="280">
+							<FormInput v-model="smtpUser" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpUser }}</template>
+							</FormInput>
+							<FormInput v-model="smtpPass" type="password" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpPass }}</template>
+							</FormInput>
+						</FormSplit>
+						<FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
+						<FormSwitch v-model="smtpSecure" class="_formBlock">
+							<template #label>{{ i18n.ts.smtpSecure }}</template>
+							<template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
+						</FormSwitch>
+					</FormSection>
+				</template>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormInput from '@/components/form/input.vue';
 import FormInfo from '@/components/ui/info.vue';
@@ -51,9 +55,9 @@ import FormSuspense from '@/components/form/suspense.vue';
 import FormSplit from '@/components/form/split.vue';
 import FormSection from '@/components/form/section.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance, instance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let enableEmail: boolean = $ref(false);
 let email: any = $ref(null);
@@ -78,13 +82,13 @@ async function testEmail() {
 	const { canceled, result: destination } = await os.inputText({
 		title: i18n.ts.destination,
 		type: 'email',
-		placeholder: instance.maintainerEmail
+		placeholder: instance.maintainerEmail,
 	});
 	if (canceled) return;
 	os.apiWithDialog('admin/send-email', {
 		to: destination,
 		subject: 'Test email',
-		text: 'Yo'
+		text: 'Yo',
 	});
 }
 
@@ -102,21 +106,22 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.emailServer,
-		icon: 'fas fa-envelope',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			text: i18n.ts.testEmail,
-			handler: testEmail,
-		}, {
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.save,
-			handler: save,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	text: i18n.ts.testEmail,
+	handler: testEmail,
+}, {
+	asFullButton: true,
+	icon: 'fas fa-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.emailServer,
+	icon: 'fas fa-envelope',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
index 8ca5b3d65c..9d6b56dbc5 100644
--- a/packages/client/src/pages/admin/emojis.vue
+++ b/packages/client/src/pages/admin/emojis.vue
@@ -1,69 +1,75 @@
 <template>
-<MkSpacer :content-max="900">
-	<div class="ogwlenmc">
-		<div v-if="tab === 'local'" class="local">
-			<MkInput v-model="query" :debounce="true" type="search">
-				<template #prefix><i class="fas fa-search"></i></template>
-				<template #label>{{ $ts.search }}</template>
-			</MkInput>
-			<MkSwitch v-model="selectMode" style="margin: 8px 0;">
-				<template #label>Select mode</template>
-			</MkSwitch>
-			<div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-				<MkButton inline @click="selectAll">Select all</MkButton>
-				<MkButton inline @click="setCategoryBulk">Set category</MkButton>
-				<MkButton inline @click="addTagBulk">Add tag</MkButton>
-				<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
-				<MkButton inline @click="setTagBulk">Set tag</MkButton>
-				<MkButton inline danger @click="delBulk">Delete</MkButton>
-			</div>
-			<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
-				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
-				<template v-slot="{items}">
-					<div class="ldhfsamy">
-						<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
-							<img :src="emoji.url" class="img" :alt="emoji.name"/>
-							<div class="body">
-								<div class="name _monospace">{{ emoji.name }}</div>
-								<div class="info">{{ emoji.category }}</div>
-							</div>
-						</button>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="900">
+			<div class="ogwlenmc">
+				<div v-if="tab === 'local'" class="local">
+					<MkInput v-model="query" :debounce="true" type="search">
+						<template #prefix><i class="fas fa-search"></i></template>
+						<template #label>{{ $ts.search }}</template>
+					</MkInput>
+					<MkSwitch v-model="selectMode" style="margin: 8px 0;">
+						<template #label>Select mode</template>
+					</MkSwitch>
+					<div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+						<MkButton inline @click="selectAll">Select all</MkButton>
+						<MkButton inline @click="setCategoryBulk">Set category</MkButton>
+						<MkButton inline @click="addTagBulk">Add tag</MkButton>
+						<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
+						<MkButton inline @click="setTagBulk">Set tag</MkButton>
+						<MkButton inline danger @click="delBulk">Delete</MkButton>
 					</div>
-				</template>
-			</MkPagination>
-		</div>
+					<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
+						<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+						<template #default="{items}">
+							<div class="ldhfsamy">
+								<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
+									<img :src="emoji.url" class="img" :alt="emoji.name"/>
+									<div class="body">
+										<div class="name _monospace">{{ emoji.name }}</div>
+										<div class="info">{{ emoji.category }}</div>
+									</div>
+								</button>
+							</div>
+						</template>
+					</MkPagination>
+				</div>
 
-		<div v-else-if="tab === 'remote'" class="remote">
-			<FormSplit>
-				<MkInput v-model="queryRemote" :debounce="true" type="search">
-					<template #prefix><i class="fas fa-search"></i></template>
-					<template #label>{{ $ts.search }}</template>
-				</MkInput>
-				<MkInput v-model="host" :debounce="true">
-					<template #label>{{ $ts.host }}</template>
-				</MkInput>
-			</FormSplit>
-			<MkPagination :pagination="remotePagination">
-				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
-				<template v-slot="{items}">
-					<div class="ldhfsamy">
-						<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
-							<img :src="emoji.url" class="img" :alt="emoji.name"/>
-							<div class="body">
-								<div class="name _monospace">{{ emoji.name }}</div>
-								<div class="info">{{ emoji.host }}</div>
+				<div v-else-if="tab === 'remote'" class="remote">
+					<FormSplit>
+						<MkInput v-model="queryRemote" :debounce="true" type="search">
+							<template #prefix><i class="fas fa-search"></i></template>
+							<template #label>{{ $ts.search }}</template>
+						</MkInput>
+						<MkInput v-model="host" :debounce="true">
+							<template #label>{{ $ts.host }}</template>
+						</MkInput>
+					</FormSplit>
+					<MkPagination :pagination="remotePagination">
+						<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+						<template #default="{items}">
+							<div class="ldhfsamy">
+								<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
+									<img :src="emoji.url" class="img" :alt="emoji.name"/>
+									<div class="body">
+										<div class="name _monospace">{{ emoji.name }}</div>
+										<div class="info">{{ emoji.host }}</div>
+									</div>
+								</div>
 							</div>
-						</div>
-					</div>
-				</template>
-			</MkPagination>
-		</div>
-	</div>
-</MkSpacer>
+						</template>
+					</MkPagination>
+				</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
+</div>
 </template>
 
 <script lang="ts" setup>
 import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkPagination from '@/components/ui/pagination.vue';
@@ -72,8 +78,8 @@ import MkSwitch from '@/components/form/switch.vue';
 import FormSplit from '@/components/form/split.vue';
 import { selectFile, selectFiles } from '@/scripts/select-file';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>();
 
@@ -131,13 +137,13 @@ const add = async (ev: MouseEvent) => {
 
 const edit = (emoji) => {
 	os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
-		emoji: emoji
+		emoji: emoji,
 	}, {
 		done: result => {
 			if (result.updated) {
 				emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
 					...oldEmoji,
-					...result.updated
+					...result.updated,
 				}));
 			} else if (result.deleted) {
 				emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
@@ -159,7 +165,7 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
 	}, {
 		text: i18n.ts.import,
 		icon: 'fas fa-plus',
-		action: () => { im(emoji); }
+		action: () => { im(emoji); },
 	}], ev.currentTarget ?? ev.target);
 };
 
@@ -181,7 +187,7 @@ const menu = (ev: MouseEvent) => {
 					text: err.message,
 				});
 			});
-		}
+		},
 	}, {
 		icon: 'fas fa-upload',
 		text: i18n.ts.import,
@@ -201,7 +207,7 @@ const menu = (ev: MouseEvent) => {
 					text: err.message,
 				});
 			});
-		}
+		},
 	}], ev.currentTarget ?? ev.target);
 };
 
@@ -265,31 +271,31 @@ const delBulk = async () => {
 	emojisPaginationComponent.value.reload();
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.customEmojis,
-		icon: 'fas fa-laugh',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.addEmoji,
-			handler: add,
-		}, {
-			icon: 'fas fa-ellipsis-h',
-			handler: menu,
-		}],
-		tabs: [{
-			active: tab.value === 'local',
-			title: i18n.ts.local,
-			onClick: () => { tab.value = 'local'; },
-		}, {
-			active: tab.value === 'remote',
-			title: i18n.ts.remote,
-			onClick: () => { tab.value = 'remote'; },
-		},]
-	})),
-});
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.addEmoji,
+	handler: add,
+}, {
+	icon: 'fas fa-ellipsis-h',
+	handler: menu,
+}]);
+
+const headerTabs = $computed(() => [{
+	active: tab.value === 'local',
+	title: i18n.ts.local,
+	onClick: () => { tab.value = 'local'; },
+}, {
+	active: tab.value === 'remote',
+	title: i18n.ts.remote,
+	onClick: () => { tab.value = 'remote'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.customEmojis,
+	icon: 'fas fa-laugh',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
index 9350911b60..18bf4f9a8c 100644
--- a/packages/client/src/pages/admin/files.vue
+++ b/packages/client/src/pages/admin/files.vue
@@ -1,50 +1,58 @@
 <template>
-<div class="xrmjdkdw">
-	<div>
-		<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-			<MkSelect v-model="origin" style="margin: 0; flex: 1;">
-				<template #label>{{ $ts.instance }}</template>
-				<option value="combined">{{ $ts.all }}</option>
-				<option value="local">{{ $ts.local }}</option>
-				<option value="remote">{{ $ts.remote }}</option>
-			</MkSelect>
-			<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
-				<template #label>{{ $ts.host }}</template>
-			</MkInput>
-		</div>
-		<div class="inputs" style="display: flex; padding-top: 1.2em;">
-			<MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
-				<template #label>MIME type</template>
-			</MkInput>
-		</div>
-		<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
-			<button v-for="file in items" :key="file.id" v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" class="file _panel _button" @click="show(file, $event)">
-				<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
-				<div v-if="viewMode === 'list'" class="body">
-					<div>
-						<small style="opacity: 0.7;">{{ file.name }}</small>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions"/></template>
+		<MkSpacer :content-max="900">
+			<div class="xrmjdkdw">
+				<div>
+					<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+						<MkSelect v-model="origin" style="margin: 0; flex: 1;">
+							<template #label>{{ $ts.instance }}</template>
+							<option value="combined">{{ $ts.all }}</option>
+							<option value="local">{{ $ts.local }}</option>
+							<option value="remote">{{ $ts.remote }}</option>
+						</MkSelect>
+						<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
+							<template #label>{{ $ts.host }}</template>
+						</MkInput>
 					</div>
-					<div>
-						<MkAcct v-if="file.user" :user="file.user"/>
-						<div v-else>{{ $ts.system }}</div>
-					</div>
-					<div>
-						<span style="margin-right: 1em;">{{ file.type }}</span>
-						<span>{{ bytes(file.size) }}</span>
-					</div>
-					<div>
-						<span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+					<div class="inputs" style="display: flex; padding-top: 1.2em;">
+						<MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+							<template #label>MIME type</template>
+						</MkInput>
 					</div>
+					<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
+						<button v-for="file in items" :key="file.id" v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" class="file _button" @click="show(file, $event)">
+							<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+							<div v-if="viewMode === 'list'" class="body">
+								<div>
+									<small style="opacity: 0.7;">{{ file.name }}</small>
+								</div>
+								<div>
+									<MkAcct v-if="file.user" :user="file.user"/>
+									<div v-else>{{ $ts.system }}</div>
+								</div>
+								<div>
+									<span style="margin-right: 1em;">{{ file.type }}</span>
+									<span>{{ bytes(file.size) }}</span>
+								</div>
+								<div>
+									<span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+								</div>
+							</div>
+						</button>
+					</MkPagination>
 				</div>
-			</button>
-		</MkPagination>
-	</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
 </div>
 </template>
 
 <script lang="ts" setup>
 import { computed, defineAsyncComponent } from 'vue';
 import * as Acct from 'misskey-js/built/acct';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkSelect from '@/components/form/select.vue';
@@ -53,8 +61,8 @@ import MkContainer from '@/components/ui/container.vue';
 import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
 import bytes from '@/filters/bytes';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let origin = $ref('local');
 let type = $ref(null);
@@ -82,7 +90,7 @@ function clear() {
 }
 
 function show(file) {
-	os.pageWindow(`/admin-file/${file.id}`);
+	os.pageWindow(`/admin/file/${file.id}`);
 }
 
 async function find() {
@@ -104,22 +112,23 @@ async function find() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.files,
-		icon: 'fas fa-cloud',
-		bg: 'var(--bg)',
-		actions: [{
-			text: i18n.ts.lookup,
-			icon: 'fas fa-search',
-			handler: find,
-		}, {
-			text: i18n.ts.clearCachedFiles,
-			icon: 'fas fa-trash-alt',
-			handler: clear,
-		}],
-	})),
-});
+const headerActions = $computed(() => [{
+	text: i18n.ts.lookup,
+	icon: 'fas fa-search',
+	handler: find,
+}, {
+	text: i18n.ts.clearCachedFiles,
+	icon: 'fas fa-trash-alt',
+	handler: clear,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.files,
+	icon: 'fas fa-cloud',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
index 9b7fa5678e..5db91101d7 100644
--- a/packages/client/src/pages/admin/index.vue
+++ b/packages/client/src/pages/admin/index.vue
@@ -1,8 +1,6 @@
 <template>
 <div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
-	<div v-if="!narrow || initialPage == null" class="nav">
-		<MkHeader :info="header"></MkHeader>
-	
+	<div v-if="!narrow || initialPage == null" class="nav">	
 		<MkSpacer :content-max="700" :margin-min="16">
 			<div class="lxpfedzu">
 				<div class="banner">
@@ -17,29 +15,26 @@
 		</MkSpacer>
 	</div>
 	<div v-if="!(narrow && initialPage == null)" class="main">
-		<MkStickyContainer>
-			<template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template>
-			<component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/>
-		</MkStickyContainer>
+		<component :is="component" :key="initialPage" v-bind="pageProps"/>
 	</div>
 </div>
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
+import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
 import { i18n } from '@/i18n';
 import MkSuperMenu from '@/components/ui/super-menu.vue';
 import MkInfo from '@/components/ui/info.vue';
 import { scroll } from '@/scripts/scroll';
 import { instance } from '@/instance';
-import * as symbols from '@/symbols';
 import * as os from '@/os';
 import { lookupUser } from '@/scripts/lookup-user';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
 const isEmpty = (x: string | null) => x == null || x === '';
 
-const nav = new MisskeyNavigator();
+const router = useRouter();
 
 const indexInfo = {
 	title: i18n.ts.controlPanel,
@@ -224,7 +219,7 @@ watch(component, () => {
 
 watch(() => props.initialPage, () => {
 	if (props.initialPage == null && !narrow) {
-		nav.push('/admin/overview');
+		router.push('/admin/overview');
 	} else {
 		if (props.initialPage == null) {
 			INFO = indexInfo;
@@ -234,7 +229,7 @@ watch(() => props.initialPage, () => {
 
 watch(narrow, () => {
 	if (props.initialPage == null && !narrow) {
-		nav.push('/admin/overview');
+		router.push('/admin/overview');
 	}
 });
 
@@ -243,7 +238,7 @@ onMounted(() => {
 
 	narrow = el.offsetWidth < NARROW_THRESHOLD;
 	if (props.initialPage == null && !narrow) {
-		nav.push('/admin/overview');
+		router.push('/admin/overview');
 	}
 });
 
@@ -251,19 +246,19 @@ onUnmounted(() => {
 	ro.disconnect();
 });
 
-const pageChanged = (page) => {
-	if (page == null) {
+provideMetadataReceiver((info) => {
+	if (info == null) {
 		childInfo = null;
 	} else {
-		childInfo = page[symbols.PAGE_INFO];
+		childInfo = info;
 	}
-};
+});
 
 const invite = () => {
 	os.api('admin/invite').then(x => {
 		os.alert({
 			type: 'info',
-			text: x.code
+			text: x.code,
 		});
 	}).catch(err => {
 		os.alert({
@@ -279,33 +274,38 @@ const lookup = (ev) => {
 		icon: 'fas fa-user',
 		action: () => {
 			lookupUser();
-		}
+		},
 	}, {
 		text: i18n.ts.note,
 		icon: 'fas fa-pencil-alt',
 		action: () => {
 			alert('TODO');
-		}
+		},
 	}, {
 		text: i18n.ts.file,
 		icon: 'fas fa-cloud',
 		action: () => {
 			alert('TODO');
-		}
+		},
 	}, {
 		text: i18n.ts.instance,
 		icon: 'fas fa-globe',
 		action: () => {
 			alert('TODO');
-		}
+		},
 	}], ev.currentTarget ?? ev.target);
 };
 
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
+
 defineExpose({
-	[symbols.PAGE_INFO]: INFO,
 	header: {
 		title: i18n.ts.controlPanel,
-	}
+	},
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue
index 3347846a80..1aec151abb 100644
--- a/packages/client/src/pages/admin/instance-block.vue
+++ b/packages/client/src/pages/admin/instance-block.vue
@@ -1,25 +1,29 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<FormTextarea v-model="blockedHosts" class="_formBlock">
-			<span>{{ i18n.ts.blockedInstances }}</span>
-			<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
-		</FormTextarea>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<FormTextarea v-model="blockedHosts" class="_formBlock">
+				<span>{{ i18n.ts.blockedInstances }}</span>
+				<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
+			</FormTextarea>
 
-		<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
-	</FormSuspense>
-</MkSpacer>
+			<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormButton from '@/components/ui/button.vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let blockedHosts: string = $ref('');
 
@@ -36,11 +40,13 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.instanceBlocking,
-		icon: 'fas fa-ban',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.instanceBlocking,
+	icon: 'fas fa-ban',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue
index d6061d0e51..d407d440b9 100644
--- a/packages/client/src/pages/admin/integrations.vue
+++ b/packages/client/src/pages/admin/integrations.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
 	<FormSuspense :p="init">
 		<FormFolder class="_formBlock">
 			<template #icon><i class="fab fa-twitter"></i></template>
@@ -20,19 +21,19 @@
 			<XDiscord/>
 		</FormFolder>
 	</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
-import FormFolder from '@/components/form/folder.vue';
-import FormSuspense from '@/components/form/suspense.vue';
 import XTwitter from './integrations.twitter.vue';
 import XGithub from './integrations.github.vue';
 import XDiscord from './integrations.discord.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormFolder from '@/components/form/folder.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let enableTwitterIntegration: boolean = $ref(false);
 let enableGithubIntegration: boolean = $ref(false);
@@ -45,11 +46,13 @@ async function init() {
 	enableDiscordIntegration = meta.enableDiscordIntegration;
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.integration,
-		icon: 'fas fa-share-alt',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.integration,
+	icon: 'fas fa-share-alt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue
index d109db9c38..bae5277f49 100644
--- a/packages/client/src/pages/admin/object-storage.vue
+++ b/packages/client/src/pages/admin/object-storage.vue
@@ -1,72 +1,76 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
 
-			<template v-if="useObjectStorage">
-				<FormInput v-model="objectStorageBaseUrl" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
-					<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
-				</FormInput>
-
-				<FormInput v-model="objectStorageBucket" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageBucket }}</template>
-					<template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
-				</FormInput>
-
-				<FormInput v-model="objectStoragePrefix" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStoragePrefix }}</template>
-					<template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
-				</FormInput>
-
-				<FormInput v-model="objectStorageEndpoint" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
-					<template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
-				</FormInput>
-
-				<FormInput v-model="objectStorageRegion" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageRegion }}</template>
-					<template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
-				</FormInput>
-
-				<FormSplit :min-width="280">
-					<FormInput v-model="objectStorageAccessKey" class="_formBlock">
-						<template #prefix><i class="fas fa-key"></i></template>
-						<template #label>Access key</template>
+				<template v-if="useObjectStorage">
+					<FormInput v-model="objectStorageBaseUrl" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
+						<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
 					</FormInput>
 
-					<FormInput v-model="objectStorageSecretKey" class="_formBlock">
-						<template #prefix><i class="fas fa-key"></i></template>
-						<template #label>Secret key</template>
+					<FormInput v-model="objectStorageBucket" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageBucket }}</template>
+						<template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
 					</FormInput>
-				</FormSplit>
 
-				<FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
-					<template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
-				</FormSwitch>
+					<FormInput v-model="objectStoragePrefix" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStoragePrefix }}</template>
+						<template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
+					</FormInput>
 
-				<FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
-					<template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
-				</FormSwitch>
+					<FormInput v-model="objectStorageEndpoint" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
+						<template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
+					</FormInput>
 
-				<FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
-				</FormSwitch>
+					<FormInput v-model="objectStorageRegion" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageRegion }}</template>
+						<template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
+					</FormInput>
 
-				<FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
-					<template #label>s3ForcePathStyle</template>
-				</FormSwitch>
-			</template>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+					<FormSplit :min-width="280">
+						<FormInput v-model="objectStorageAccessKey" class="_formBlock">
+							<template #prefix><i class="fas fa-key"></i></template>
+							<template #label>Access key</template>
+						</FormInput>
+
+						<FormInput v-model="objectStorageSecretKey" class="_formBlock">
+							<template #prefix><i class="fas fa-key"></i></template>
+							<template #label>Secret key</template>
+						</FormInput>
+					</FormSplit>
+
+					<FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
+						<template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
+						<template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
+						<template #label>s3ForcePathStyle</template>
+					</FormSwitch>
+				</template>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormInput from '@/components/form/input.vue';
 import FormGroup from '@/components/form/group.vue';
@@ -74,9 +78,9 @@ import FormSuspense from '@/components/form/suspense.vue';
 import FormSplit from '@/components/form/split.vue';
 import FormSection from '@/components/form/section.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let useObjectStorage: boolean = $ref(false);
 let objectStorageBaseUrl: string | null = $ref(null);
@@ -129,17 +133,18 @@ function save() {
 	});
 }
 
-defineExpose({
-  [symbols.PAGE_INFO]: {
-		title: i18n.ts.objectStorage,
-		icon: 'fas fa-cloud',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.save,
-			handler: save,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.objectStorage,
+	icon: 'fas fa-cloud',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue
index 552b05f347..59b3503c3c 100644
--- a/packages/client/src/pages/admin/other-settings.vue
+++ b/packages/client/src/pages/admin/other-settings.vue
@@ -1,18 +1,22 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		none
-	</FormSuspense>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			none
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 async function init() {
 	await os.api('admin/meta');
@@ -24,17 +28,18 @@ function save() {
 	});
 }
 
-defineExpose({
-  [symbols.PAGE_INFO]: {
-		title: i18n.ts.other,
-		icon: 'fas fa-cogs',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.save,
-			handler: save,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.other,
+	icon: 'fas fa-cogs',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
index cc69424c3b..82b3c33852 100644
--- a/packages/client/src/pages/admin/overview.vue
+++ b/packages/client/src/pages/admin/overview.vue
@@ -35,7 +35,7 @@
 		</MkContainer>
 	</div>
 
-		<!--<XMetrics/>-->
+	<!--<XMetrics/>-->
 
 	<MkFolder style="margin: var(--margin)">
 		<template #header><i class="fas fa-info-circle"></i> {{ i18n.ts.info }}</template>
@@ -67,6 +67,7 @@
 
 <script lang="ts" setup>
 import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import XMetrics from './metrics.vue';
 import MkInstanceStats from '@/components/instance-stats.vue';
 import MkNumberDiff from '@/components/number-diff.vue';
 import MkContainer from '@/components/ui/container.vue';
@@ -74,11 +75,10 @@ import MkFolder from '@/components/ui/folder.vue';
 import MkQueueChart from '@/components/queue-chart.vue';
 import { version, url } from '@/config';
 import number from '@/filters/number';
-import XMetrics from './metrics.vue';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let stats: any = $ref(null);
 let serverInfo: any = $ref(null);
@@ -106,7 +106,7 @@ onMounted(async () => {
 	nextTick(() => {
 		queueStatsConnection.send('requestLog', {
 			id: Math.random().toString().substr(2, 8),
-			length: 200
+			length: 200,
 		});
 	});
 });
@@ -115,12 +115,14 @@ onBeforeUnmount(() => {
 	queueStatsConnection.dispose();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.dashboard,
-		icon: 'fas fa-tachometer-alt',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.dashboard,
+	icon: 'fas fa-tachometer-alt',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue
index 727e20e7e5..0c5bb1bc9f 100644
--- a/packages/client/src/pages/admin/proxy-account.vue
+++ b/packages/client/src/pages/admin/proxy-account.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
 	<FormSuspense :p="init">
 		<MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo>
 		<MkKeyValue class="_formBlock">
@@ -9,7 +10,7 @@
 
 		<FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
 	</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -19,9 +20,9 @@ import FormButton from '@/components/ui/button.vue';
 import MkInfo from '@/components/ui/info.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let proxyAccount: any = $ref(null);
 let proxyAccountId: any = $ref(null);
@@ -50,11 +51,13 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.proxyAccount,
-		icon: 'fas fa-ghost',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.proxyAccount,
+	icon: 'fas fa-ghost',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
index 656b18199f..c2865525ab 100644
--- a/packages/client/src/pages/admin/queue.vue
+++ b/packages/client/src/pages/admin/queue.vue
@@ -1,24 +1,28 @@
 <template>
-<MkSpacer :content-max="800">
-	<XQueue :connection="connection" domain="inbox">
-		<template #title>In</template>
-	</XQueue>
-	<XQueue :connection="connection" domain="deliver">
-		<template #title>Out</template>
-	</XQueue>
-	<MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<XQueue :connection="connection" domain="inbox">
+			<template #title>In</template>
+		</XQueue>
+		<XQueue :connection="connection" domain="deliver">
+			<template #title>Out</template>
+		</XQueue>
+		<MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import MkButton from '@/components/ui/button.vue';
 import XQueue from './queue.chart.vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import * as symbols from '@/symbols';
 import * as config from '@/config';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const connection = markRaw(stream.useChannel('queueStats'));
 
@@ -38,7 +42,7 @@ onMounted(() => {
 	nextTick(() => {
 		connection.send('requestLog', {
 			id: Math.random().toString().substr(2, 8),
-			length: 200
+			length: 200,
 		});
 	});
 });
@@ -47,19 +51,20 @@ onBeforeUnmount(() => {
 	connection.dispose();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.jobQueue,
-		icon: 'fas fa-clipboard-list',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-up-right-from-square',
-			text: i18n.ts.dashboard,
-			handler: () => {
-				window.open(config.url + '/queue', '_blank');
-			},
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-up-right-from-square',
+	text: i18n.ts.dashboard,
+	handler: () => {
+		window.open(config.url + '/queue', '_blank');
+	},
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.jobQueue,
+	icon: 'fas fa-clipboard-list',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue
index 1a36bb4753..1ca4f2df09 100644
--- a/packages/client/src/pages/admin/relays.vue
+++ b/packages/client/src/pages/admin/relays.vue
@@ -1,24 +1,28 @@
 <template>
-<MkSpacer :content-max="800">
-	<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
-		<div>{{ relay.inbox }}</div>
-		<div class="status">
-			<i v-if="relay.status === 'accepted'" class="fas fa-check icon accepted"></i>
-			<i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i>
-			<i v-else class="fas fa-clock icon requesting"></i>
-			<span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
+			<div>{{ relay.inbox }}</div>
+			<div class="status">
+				<i v-if="relay.status === 'accepted'" class="fas fa-check icon accepted"></i>
+				<i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i>
+				<i v-else class="fas fa-clock icon requesting"></i>
+				<span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+			</div>
+			<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
 		</div>
-		<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let relays: any[] = $ref([]);
 
@@ -26,30 +30,30 @@ async function addRelay() {
 	const { canceled, result: inbox } = await os.inputText({
 		title: i18n.ts.addRelay,
 		type: 'url',
-		placeholder: i18n.ts.inboxUrl
+		placeholder: i18n.ts.inboxUrl,
 	});
 	if (canceled) return;
 	os.api('admin/relays/add', {
-		inbox
+		inbox,
 	}).then((relay: any) => {
 		refresh();
 	}).catch((err: any) => {
 		os.alert({
 			type: 'error',
-			text: err.message || err
+			text: err.message || err,
 		});
 	});
 }
 
 function remove(inbox: string) {
 	os.api('admin/relays/remove', {
-		inbox
+		inbox,
 	}).then(() => {
 		refresh();
 	}).catch((err: any) => {
 		os.alert({
 			type: 'error',
-			text: err.message || err
+			text: err.message || err,
 		});
 	});
 }
@@ -62,18 +66,19 @@ function refresh() {
 
 refresh();
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.relays,
-		icon: 'fas fa-globe',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.addRelay,
-			handler: addRelay,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.addRelay,
+	handler: addRelay,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.relays,
+	icon: 'fas fa-globe',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue
index 6b8f70cca5..65b08565cd 100644
--- a/packages/client/src/pages/admin/security.vue
+++ b/packages/client/src/pages/admin/security.vue
@@ -1,36 +1,41 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormFolder class="_formBlock">
-				<template #icon><i class="fas fa-shield-alt"></i></template>
-				<template #label>{{ i18n.ts.botProtection }}</template>
-				<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
-				<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
-				<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormFolder class="_formBlock">
+					<template #icon><i class="fas fa-shield-alt"></i></template>
+					<template #label>{{ i18n.ts.botProtection }}</template>
+					<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
+					<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
+					<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
 
-				<XBotProtection/>
-			</FormFolder>
+					<XBotProtection/>
+				</FormFolder>
 
-			<FormFolder class="_formBlock">
-				<template #label>Summaly Proxy</template>
+				<FormFolder class="_formBlock">
+					<template #label>Summaly Proxy</template>
 
-				<div class="_formRoot">
-					<FormInput v-model="summalyProxy" class="_formBlock">
-						<template #prefix><i class="fas fa-link"></i></template>
-						<template #label>Summaly Proxy URL</template>
-					</FormInput>
+					<div class="_formRoot">
+						<FormInput v-model="summalyProxy" class="_formBlock">
+							<template #prefix><i class="fas fa-link"></i></template>
+							<template #label>Summaly Proxy URL</template>
+						</FormInput>
 
-					<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
-				</div>
-			</FormFolder>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+						<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
+					</div>
+				</FormFolder>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XBotProtection from './bot-protection.vue';
+import XHeader from './_header_.vue';
 import FormFolder from '@/components/form/folder.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormInfo from '@/components/ui/info.vue';
@@ -38,11 +43,10 @@ import FormSuspense from '@/components/form/suspense.vue';
 import FormSection from '@/components/form/section.vue';
 import FormInput from '@/components/form/input.vue';
 import FormButton from '@/components/ui/button.vue';
-import XBotProtection from './bot-protection.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let summalyProxy: string = $ref('');
 let enableHcaptcha: boolean = $ref(false);
@@ -63,11 +67,13 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.security,
-		icon: 'fas fa-lock',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.security,
+	icon: 'fas fa-lock',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
index 6dc30fe50b..a5767cc2c2 100644
--- a/packages/client/src/pages/admin/settings.vue
+++ b/packages/client/src/pages/admin/settings.vue
@@ -1,149 +1,155 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormInput v-model="name" class="_formBlock">
-				<template #label>{{ i18n.ts.instanceName }}</template>
-			</FormInput>
-
-			<FormTextarea v-model="description" class="_formBlock">
-				<template #label>{{ i18n.ts.instanceDescription }}</template>
-			</FormTextarea>
-
-			<FormInput v-model="tosUrl" class="_formBlock">
-				<template #prefix><i class="fas fa-link"></i></template>
-				<template #label>{{ i18n.ts.tosUrl }}</template>
-			</FormInput>
-
-			<FormSplit :min-width="300">
-				<FormInput v-model="maintainerName" class="_formBlock">
-					<template #label>{{ i18n.ts.maintainerName }}</template>
-				</FormInput>
-
-				<FormInput v-model="maintainerEmail" type="email" class="_formBlock">
-					<template #prefix><i class="fas fa-envelope"></i></template>
-					<template #label>{{ i18n.ts.maintainerEmail }}</template>
-				</FormInput>
-			</FormSplit>
-
-			<FormTextarea v-model="pinnedUsers" class="_formBlock">
-				<template #label>{{ i18n.ts.pinnedUsers }}</template>
-				<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
-			</FormTextarea>
-
-			<FormSection>
-				<FormSwitch v-model="enableRegistration" class="_formBlock">
-					<template #label>{{ i18n.ts.enableRegistration }}</template>
-				</FormSwitch>
-
-				<FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
-					<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
-				</FormSwitch>
-			</FormSection>
-
-			<FormSection>
-				<FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
-				<FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
-				<FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
-			</FormSection>
-
-			<FormSection>
-				<template #label>{{ i18n.ts.theme }}</template>
-
-				<FormInput v-model="iconUrl" class="_formBlock">
-					<template #prefix><i class="fas fa-link"></i></template>
-					<template #label>{{ i18n.ts.iconUrl }}</template>
-				</FormInput>
-
-				<FormInput v-model="bannerUrl" class="_formBlock">
-					<template #prefix><i class="fas fa-link"></i></template>
-					<template #label>{{ i18n.ts.bannerUrl }}</template>
-				</FormInput>
-
-				<FormInput v-model="backgroundImageUrl" class="_formBlock">
-					<template #prefix><i class="fas fa-link"></i></template>
-					<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
-				</FormInput>
-
-				<FormInput v-model="themeColor" class="_formBlock">
-					<template #prefix><i class="fas fa-palette"></i></template>
-					<template #label>{{ i18n.ts.themeColor }}</template>
-					<template #caption>#RRGGBB</template>
-				</FormInput>
-
-				<FormTextarea v-model="defaultLightTheme" class="_formBlock">
-					<template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
-					<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
-				</FormTextarea>
-
-				<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
-					<template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
-					<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
-				</FormTextarea>
-			</FormSection>
-
-			<FormSection>
-				<template #label>{{ i18n.ts.files }}</template>
-
-				<FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
-					<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
-					<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
-				</FormSwitch>
-
-				<FormSplit :min-width="280">
-					<FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
-						<template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
-						<template #suffix>MB</template>
-						<template #caption>{{ i18n.ts.inMb }}</template>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+			<FormSuspense :p="init">
+				<div class="_formRoot">
+					<FormInput v-model="name" class="_formBlock">
+						<template #label>{{ i18n.ts.instanceName }}</template>
 					</FormInput>
 
-					<FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
-						<template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
-						<template #suffix>MB</template>
-						<template #caption>{{ i18n.ts.inMb }}</template>
-					</FormInput>
-				</FormSplit>
-			</FormSection>
+					<FormTextarea v-model="description" class="_formBlock">
+						<template #label>{{ i18n.ts.instanceDescription }}</template>
+					</FormTextarea>
 
-			<FormSection>
-				<template #label>ServiceWorker</template>
-
-				<FormSwitch v-model="enableServiceWorker" class="_formBlock">
-					<template #label>{{ i18n.ts.enableServiceworker }}</template>
-					<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
-				</FormSwitch>
-
-				<template v-if="enableServiceWorker">
-					<FormInput v-model="swPublicKey" class="_formBlock">
-						<template #prefix><i class="fas fa-key"></i></template>
-						<template #label>Public key</template>
+					<FormInput v-model="tosUrl" class="_formBlock">
+						<template #prefix><i class="fas fa-link"></i></template>
+						<template #label>{{ i18n.ts.tosUrl }}</template>
 					</FormInput>
 
-					<FormInput v-model="swPrivateKey" class="_formBlock">
-						<template #prefix><i class="fas fa-key"></i></template>
-						<template #label>Private key</template>
-					</FormInput>
-				</template>
-			</FormSection>
+					<FormSplit :min-width="300">
+						<FormInput v-model="maintainerName" class="_formBlock">
+							<template #label>{{ i18n.ts.maintainerName }}</template>
+						</FormInput>
 
-			<FormSection>
-				<template #label>DeepL Translation</template>
+						<FormInput v-model="maintainerEmail" type="email" class="_formBlock">
+							<template #prefix><i class="fas fa-envelope"></i></template>
+							<template #label>{{ i18n.ts.maintainerEmail }}</template>
+						</FormInput>
+					</FormSplit>
 
-				<FormInput v-model="deeplAuthKey" class="_formBlock">
-					<template #prefix><i class="fas fa-key"></i></template>
-					<template #label>DeepL Auth Key</template>
-				</FormInput>
-				<FormSwitch v-model="deeplIsPro" class="_formBlock">
-					<template #label>Pro account</template>
-				</FormSwitch>
-			</FormSection>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+					<FormTextarea v-model="pinnedUsers" class="_formBlock">
+						<template #label>{{ i18n.ts.pinnedUsers }}</template>
+						<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
+					</FormTextarea>
+
+					<FormSection>
+						<FormSwitch v-model="enableRegistration" class="_formBlock">
+							<template #label>{{ i18n.ts.enableRegistration }}</template>
+						</FormSwitch>
+
+						<FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
+							<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
+						</FormSwitch>
+					</FormSection>
+
+					<FormSection>
+						<FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
+						<FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
+						<FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
+					</FormSection>
+
+					<FormSection>
+						<template #label>{{ i18n.ts.theme }}</template>
+
+						<FormInput v-model="iconUrl" class="_formBlock">
+							<template #prefix><i class="fas fa-link"></i></template>
+							<template #label>{{ i18n.ts.iconUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="bannerUrl" class="_formBlock">
+							<template #prefix><i class="fas fa-link"></i></template>
+							<template #label>{{ i18n.ts.bannerUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="backgroundImageUrl" class="_formBlock">
+							<template #prefix><i class="fas fa-link"></i></template>
+							<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="themeColor" class="_formBlock">
+							<template #prefix><i class="fas fa-palette"></i></template>
+							<template #label>{{ i18n.ts.themeColor }}</template>
+							<template #caption>#RRGGBB</template>
+						</FormInput>
+
+						<FormTextarea v-model="defaultLightTheme" class="_formBlock">
+							<template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
+							<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+						</FormTextarea>
+
+						<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
+							<template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
+							<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+						</FormTextarea>
+					</FormSection>
+
+					<FormSection>
+						<template #label>{{ i18n.ts.files }}</template>
+
+						<FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
+							<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
+							<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
+						</FormSwitch>
+
+						<FormSplit :min-width="280">
+							<FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
+								<template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
+								<template #suffix>MB</template>
+								<template #caption>{{ i18n.ts.inMb }}</template>
+							</FormInput>
+
+							<FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
+								<template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
+								<template #suffix>MB</template>
+								<template #caption>{{ i18n.ts.inMb }}</template>
+							</FormInput>
+						</FormSplit>
+					</FormSection>
+
+					<FormSection>
+						<template #label>ServiceWorker</template>
+
+						<FormSwitch v-model="enableServiceWorker" class="_formBlock">
+							<template #label>{{ i18n.ts.enableServiceworker }}</template>
+							<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
+						</FormSwitch>
+
+						<template v-if="enableServiceWorker">
+							<FormInput v-model="swPublicKey" class="_formBlock">
+								<template #prefix><i class="fas fa-key"></i></template>
+								<template #label>Public key</template>
+							</FormInput>
+
+							<FormInput v-model="swPrivateKey" class="_formBlock">
+								<template #prefix><i class="fas fa-key"></i></template>
+								<template #label>Private key</template>
+							</FormInput>
+						</template>
+					</FormSection>
+
+					<FormSection>
+						<template #label>DeepL Translation</template>
+
+						<FormInput v-model="deeplAuthKey" class="_formBlock">
+							<template #prefix><i class="fas fa-key"></i></template>
+							<template #label>DeepL Auth Key</template>
+						</FormInput>
+						<FormSwitch v-model="deeplIsPro" class="_formBlock">
+							<template #label>Pro account</template>
+						</FormSwitch>
+					</FormSection>
+				</div>
+			</FormSuspense>
+		</MkSpacer>
+	</MkStickyContainer>
+</div>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormInput from '@/components/form/input.vue';
 import FormTextarea from '@/components/form/textarea.vue';
@@ -152,9 +158,9 @@ import FormSection from '@/components/form/section.vue';
 import FormSplit from '@/components/form/split.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let name: string | null = $ref(null);
 let description: string | null = $ref(null);
@@ -240,17 +246,18 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.general,
-		icon: 'fas fa-cog',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.save,
-			handler: save,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.general,
+	icon: 'fas fa-cog',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
index f05aa5ff45..dccf952ba9 100644
--- a/packages/client/src/pages/admin/users.vue
+++ b/packages/client/src/pages/admin/users.vue
@@ -1,76 +1,84 @@
 <template>
-<div class="lknzcolw">
-	<div class="users">
-		<div class="inputs">
-			<MkSelect v-model="sort" style="flex: 1;">
-				<template #label>{{ $ts.sort }}</template>
-				<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
-				<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
-				<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
-				<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
-			</MkSelect>
-			<MkSelect v-model="state" style="flex: 1;">
-				<template #label>{{ $ts.state }}</template>
-				<option value="all">{{ $ts.all }}</option>
-				<option value="available">{{ $ts.normal }}</option>
-				<option value="admin">{{ $ts.administrator }}</option>
-				<option value="moderator">{{ $ts.moderator }}</option>
-				<option value="silenced">{{ $ts.silence }}</option>
-				<option value="suspended">{{ $ts.suspend }}</option>
-			</MkSelect>
-			<MkSelect v-model="origin" style="flex: 1;">
-				<template #label>{{ $ts.instance }}</template>
-				<option value="combined">{{ $ts.all }}</option>
-				<option value="local">{{ $ts.local }}</option>
-				<option value="remote">{{ $ts.remote }}</option>
-			</MkSelect>
-		</div>
-		<div class="inputs">
-			<MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
-				<template #prefix>@</template>
-				<template #label>{{ $ts.username }}</template>
-			</MkInput>
-			<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
-				<template #prefix>@</template>
-				<template #label>{{ $ts.host }}</template>
-			</MkInput>
-		</div>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="900">
+			<div class="lknzcolw">
+				<div class="users">
+					<div class="inputs">
+						<MkSelect v-model="sort" style="flex: 1;">
+							<template #label>{{ $ts.sort }}</template>
+							<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
+							<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
+							<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
+							<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
+						</MkSelect>
+						<MkSelect v-model="state" style="flex: 1;">
+							<template #label>{{ $ts.state }}</template>
+							<option value="all">{{ $ts.all }}</option>
+							<option value="available">{{ $ts.normal }}</option>
+							<option value="admin">{{ $ts.administrator }}</option>
+							<option value="moderator">{{ $ts.moderator }}</option>
+							<option value="silenced">{{ $ts.silence }}</option>
+							<option value="suspended">{{ $ts.suspend }}</option>
+						</MkSelect>
+						<MkSelect v-model="origin" style="flex: 1;">
+							<template #label>{{ $ts.instance }}</template>
+							<option value="combined">{{ $ts.all }}</option>
+							<option value="local">{{ $ts.local }}</option>
+							<option value="remote">{{ $ts.remote }}</option>
+						</MkSelect>
+					</div>
+					<div class="inputs">
+						<MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
+							<template #prefix>@</template>
+							<template #label>{{ $ts.username }}</template>
+						</MkInput>
+						<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
+							<template #prefix>@</template>
+							<template #label>{{ $ts.host }}</template>
+						</MkInput>
+					</div>
 
-		<MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
-			<button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
-				<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
-				<div class="body">
-					<header>
-						<MkUserName class="name" :user="user"/>
-						<span class="acct">@{{ acct(user) }}</span>
-						<span v-if="user.isAdmin" class="staff"><i class="fas fa-bookmark"></i></span>
-						<span v-if="user.isModerator" class="staff"><i class="far fa-bookmark"></i></span>
-						<span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span>
-						<span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span>
-					</header>
-					<div>
-						<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
-					</div>
-					<div>
-						<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
-					</div>
+					<MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
+						<button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
+							<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
+							<div class="body">
+								<header>
+									<MkUserName class="name" :user="user"/>
+									<span class="acct">@{{ acct(user) }}</span>
+									<span v-if="user.isAdmin" class="staff"><i class="fas fa-bookmark"></i></span>
+									<span v-if="user.isModerator" class="staff"><i class="far fa-bookmark"></i></span>
+									<span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span>
+									<span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span>
+								</header>
+								<div>
+									<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
+								</div>
+								<div>
+									<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
+								</div>
+							</div>
+						</button>
+					</MkPagination>
 				</div>
-			</button>
-		</MkPagination>
-	</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
 </div>
 </template>
 
 <script lang="ts" setup>
 import { computed } from 'vue';
+import XHeader from './_header_.vue';
 import MkInput from '@/components/form/input.vue';
 import MkSelect from '@/components/form/select.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import { acct } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { lookupUser } from '@/scripts/lookup-user';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
 
@@ -89,7 +97,7 @@ const pagination = {
 		username: searchUsername,
 		hostname: searchHost,
 	})),
-	offsetMode: true
+	offsetMode: true,
 };
 
 function searchUser() {
@@ -106,7 +114,7 @@ async function addUser() {
 
 	const { canceled: canceled2, result: password } = await os.inputText({
 		title: i18n.ts.password,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled2) return;
 
@@ -122,34 +130,34 @@ function show(user) {
 	os.pageWindow(`/user-info/${user.id}`);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.users,
-		icon: 'fas fa-users',
-		bg: 'var(--bg)',
-		actions: [{
-			icon: 'fas fa-search',
-			text: i18n.ts.search,
-			handler: searchUser
-		}, {
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.addUser,
-			handler: addUser
-		}, {
-			asFullButton: true,
-			icon: 'fas fa-search',
-			text: i18n.ts.lookup,
-			handler: lookupUser
-		}],
-	})),
-});
+const headerActions = $computed(() => [{
+	icon: 'fas fa-search',
+	text: i18n.ts.search,
+	handler: searchUser,
+}, {
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.addUser,
+	handler: addUser,
+}, {
+	asFullButton: true,
+	icon: 'fas fa-search',
+	text: i18n.ts.lookup,
+	handler: lookupUser,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.users,
+	icon: 'fas fa-users',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
 .lknzcolw {
 	> .users {
-		margin: var(--margin);
 
 		> .inputs {
 			display: flex;
diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue
index 53727823a4..9afaa7fd1f 100644
--- a/packages/client/src/pages/announcements.vue
+++ b/packages/client/src/pages/announcements.vue
@@ -1,57 +1,53 @@
 <template>
-<MkSpacer :content-max="800">
-	<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content">
-		<section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement">
-			<div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
-			<div class="_content">
-				<Mfm :text="announcement.text"/>
-				<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
-			</div>
-			<div v-if="$i && !announcement.isRead" class="_footer">
-				<MkButton primary @click="read(items, announcement, i)"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
-			</div>
-		</section>
-	</MkPagination>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content">
+			<section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement">
+				<div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
+				<div class="_content">
+					<Mfm :text="announcement.text"/>
+					<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+				</div>
+				<div v-if="$i && !announcement.isRead" class="_footer">
+					<MkButton primary @click="read(items, announcement, i)"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
+				</div>
+			</section>
+		</MkPagination>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		MkPagination,
-		MkButton
-	},
+const pagination = {
+	endpoint: 'announcements' as const,
+	limit: 10,
+};
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.announcements,
-				icon: 'fas fa-broadcast-tower',
-				bg: 'var(--bg)',
-			},
-			pagination: {
-				endpoint: 'announcements' as const,
-				limit: 10,
-			},
-		};
-	},
+// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
+function read(items, announcement, i) {
+	items[i] = {
+		...announcement,
+		isRead: true,
+	};
+	os.api('i/read-announcement', { announcementId: announcement.id });
+}
 
-	methods: {
-		// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
-		read(items, announcement, i) {
-			items[i] = {
-				...announcement,
-				isRead: true,
-			};
-			os.api('i/read-announcement', { announcementId: announcement.id });
-		},
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.announcements,
+	icon: 'fas fa-broadcast-tower',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue
index c38f285725..29b6066fc9 100644
--- a/packages/client/src/pages/antenna-timeline.vue
+++ b/packages/client/src/pages/antenna-timeline.vue
@@ -1,8 +1,9 @@
 <template>
-<div v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
+<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
 	<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
 	<div class="tl _block">
-		<XTimeline ref="tl" :key="antennaId"
+		<XTimeline
+			ref="tlEl" :key="antennaId"
 			class="tl"
 			src="antenna"
 			:antenna="antennaId"
@@ -13,92 +14,78 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
 import XTimeline from '@/components/timeline.vue';
 import { scroll } from '@/scripts/scroll';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import i18n from '@/components/global/i18n';
 
-export default defineComponent({
-	components: {
-		XTimeline,
-	},
+const router = useRouter();
 
-	props: {
-		antennaId: {
-			type: String,
-			required: true
-		}
-	},
+const props = defineProps<{
+	antennaId: string;
+}>();
 
-	data() {
-		return {
-			antenna: null,
-			queue: 0,
-			[symbols.PAGE_INFO]: computed(() => this.antenna ? {
-				title: this.antenna.name,
-				icon: 'fas fa-satellite',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-calendar-alt',
-					text: this.$ts.jumpToSpecifiedDate,
-					handler: this.timetravel
-				}, {
-					icon: 'fas fa-cog',
-					text: this.$ts.settings,
-					handler: this.settings
-				}],
-			} : null),
-		};
-	},
+let antenna = $ref(null);
+let queue = $ref(0);
+let rootEl = $ref<HTMLElement>();
+let tlEl = $ref<InstanceType<typeof XTimeline>>();
+const keymap = $computed(() => ({
+	't': focus,
+}));
 
-	computed: {
-		keymap(): any {
-			return {
-				't': this.focus
-			};
-		},
-	},
+function queueUpdated(q) {
+	queue = q;
+}
 
-	watch: {
-		antennaId: {
-			async handler() {
-				this.antenna = await os.api('antennas/show', {
-					antennaId: this.antennaId
-				});
-			},
-			immediate: true
-		}
-	},
+function top() {
+	scroll(rootEl, { top: 0 });
+}
 
-	methods: {
-		queueUpdated(q) {
-			this.queue = q;
-		},
+async function timetravel() {
+	const { canceled, result: date } = await os.inputDate({
+		title: i18n.ts.date,
+	});
+	if (canceled) return;
 
-		top() {
-			scroll(this.$el, { top: 0 });
-		},
+	tlEl.timetravel(date);
+}
 
-		async timetravel() {
-			const { canceled, result: date } = await os.inputDate({
-				title: this.$ts.date,
-			});
-			if (canceled) return;
+function settings() {
+	router.push(`/my/antennas/${props.antennaId}`);
+}
 
-			this.$refs.tl.timetravel(date);
-		},
+function focus() {
+	tlEl.focus();
+}
 
-		settings() {
-			this.$router.push(`/my/antennas/${this.antennaId}`);
-		},
+watch(() => props.antennaId, async () => {
+	antenna = await os.api('antennas/show', {
+		antennaId: props.antennaId,
+	});
+}, { immediate: true });
 
-		focus() {
-			(this.$refs.tl as any).focus();
-		}
-	}
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => antenna ? {
+	title: antenna.name,
+	icon: 'fas fa-satellite',
+	bg: 'var(--bg)',
+	actions: [{
+		icon: 'fas fa-calendar-alt',
+		text: i18n.ts.jumpToSpecifiedDate,
+		handler: timetravel,
+	}, {
+		icon: 'fas fa-cog',
+		text: i18n.ts.settings,
+		handler: settings,
+	}],
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue
index 88acbcd3a3..2f8eeadff1 100644
--- a/packages/client/src/pages/api-console.vue
+++ b/packages/client/src/pages/api-console.vue
@@ -1,40 +1,43 @@
 <template>
-<MkSpacer :content-max="700">
-	<div class="_formRoot">
-		<div class="_formBlock">
-			<MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:modelValue="onEndpointChange()">
-				<template #label>Endpoint</template>
-			</MkInput>
-			<MkTextarea v-model="body" class="_formBlock" code>
-				<template #label>Params (JSON or JSON5)</template>
-			</MkTextarea>
-			<MkSwitch v-model="withCredential" class="_formBlock">
-				With credential
-			</MkSwitch>
-			<MkButton class="_formBlock" primary :disabled="sending" @click="send">
-				<template v-if="sending"><MkEllipsis/></template>
-				<template v-else><i class="fas fa-paper-plane"></i> Send</template>
-			</MkButton>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="_formRoot">
+			<div class="_formBlock">
+				<MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:modelValue="onEndpointChange()">
+					<template #label>Endpoint</template>
+				</MkInput>
+				<MkTextarea v-model="body" class="_formBlock" code>
+					<template #label>Params (JSON or JSON5)</template>
+				</MkTextarea>
+				<MkSwitch v-model="withCredential" class="_formBlock">
+					With credential
+				</MkSwitch>
+				<MkButton class="_formBlock" primary :disabled="sending" @click="send">
+					<template v-if="sending"><MkEllipsis/></template>
+					<template v-else><i class="fas fa-paper-plane"></i> Send</template>
+				</MkButton>
+			</div>
+			<div v-if="res" class="_formBlock">
+				<MkTextarea v-model="res" code readonly tall>
+					<template #label>Response</template>
+				</MkTextarea>
+			</div>
 		</div>
-		<div v-if="res" class="_formBlock">
-			<MkTextarea v-model="res" code readonly tall>
-				<template #label>Response</template>
-			</MkTextarea>
-		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { ref } from 'vue';
 import JSON5 from 'json5';
+import { Endpoints } from 'misskey-js';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import MkSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { Endpoints } from 'misskey-js';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const body = ref('{}');
 const endpoint = ref('');
@@ -75,10 +78,12 @@ function onEndpointChange() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'API console',
-		icon: 'fas fa-terminal'
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'API console',
+	icon: 'fas fa-terminal',
 });
 </script>
diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue
index e65161dd2b..9457cd6b2f 100644
--- a/packages/client/src/pages/auth.vue
+++ b/packages/client/src/pages/auth.vue
@@ -15,7 +15,7 @@
 		<h1>{{ $ts._auth.denied }}</h1>
 	</div>
 	<div v-if="state == 'accepted'" class="accepted">
-		<h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$ts.allowed }}</h1>
+		<h1>{{ session.app.isAuthorized ? $t('already-authorized') : $ts.allowed }}</h1>
 		<p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p>
 		<p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p>
 	</div>
@@ -40,24 +40,20 @@ export default defineComponent({
 		XForm,
 		MkSignin,
 	},
+	props: ['token'],
 	data() {
 		return {
 			state: null,
 			session: null,
-			fetching: true
+			fetching: true,
 		};
 	},
-	computed: {
-		token(): string {
-			return this.$route.params.token;
-		}
-	},
 	mounted() {
 		if (!this.$i) return;
 
 		// Fetch session
 		os.api('auth/session/show', {
-			token: this.token
+			token: this.token,
 		}).then(session => {
 			this.session = session;
 			this.fetching = false;
@@ -65,7 +61,7 @@ export default defineComponent({
 			// 既に連携していた場合
 			if (this.session.app.isAuthorized) {
 				os.api('auth/accept', {
-					token: this.session.token
+					token: this.session.token,
 				}).then(() => {
 					this.accepted();
 				});
@@ -85,8 +81,8 @@ export default defineComponent({
 			}
 		}, onLogin(res) {
 			login(res.i);
-		}
-	}
+		},
+	},
 });
 </script>
 
diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue
index ea3a5dab76..2065bd6689 100644
--- a/packages/client/src/pages/channel-editor.vue
+++ b/packages/client/src/pages/channel-editor.vue
@@ -1,127 +1,122 @@
 <template>
-<MkSpacer :content-max="700">
-	<div class="_formRoot">
-		<MkInput v-model="name" class="_formBlock">
-			<template #label>{{ $ts.name }}</template>
-		</MkInput>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="_formRoot">
+			<MkInput v-model="name" class="_formBlock">
+				<template #label>{{ $ts.name }}</template>
+			</MkInput>
 
-		<MkTextarea v-model="description" class="_formBlock">
-			<template #label>{{ $ts.description }}</template>
-		</MkTextarea>
+			<MkTextarea v-model="description" class="_formBlock">
+				<template #label>{{ $ts.description }}</template>
+			</MkTextarea>
 
-		<div class="banner">
-			<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton>
-			<div v-else-if="bannerUrl">
-				<img :src="bannerUrl" style="width: 100%;"/>
-				<MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton>
+			<div class="banner">
+				<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton>
+				<div v-else-if="bannerUrl">
+					<img :src="bannerUrl" style="width: 100%;"/>
+					<MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton>
+				</div>
+			</div>
+			<div class="_formBlock">
+				<MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton>
 			</div>
 		</div>
-		<div class="_formBlock">
-			<MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton>
-		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import { selectFile } from '@/scripts/select-file';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		MkTextarea, MkButton, MkInput,
-	},
+const router = useRouter();
 
-	props: {
-		channelId: {
-			type: String,
-			required: false
-		},
-	},
+const props = defineProps<{
+	channelId?: string;
+}>();
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.channelId ? {
-				title: this.$ts._channel.edit,
-				icon: 'fas fa-satellite-dish',
-				bg: 'var(--bg)',
-			} : {
-				title: this.$ts._channel.create,
-				icon: 'fas fa-satellite-dish',
-				bg: 'var(--bg)',
-			}),
-			channel: null,
-			name: null,
-			description: null,
-			bannerUrl: null,
-			bannerId: null,
-		};
-	},
+let channel = $ref(null);
+let name = $ref(null);
+let description = $ref(null);
+let bannerUrl = $ref<string | null>(null);
+let bannerId = $ref<string | null>(null);
 
-	watch: {
-		async bannerId() {
-			if (this.bannerId == null) {
-				this.bannerUrl = null;
-			} else {
-				this.bannerUrl = (await os.api('drive/files/show', {
-					fileId: this.bannerId,
-				})).url;
-			}
-		},
-	},
-
-	async created() {
-		if (this.channelId) {
-			this.channel = await os.api('channels/show', {
-				channelId: this.channelId,
-			});
-
-			this.name = this.channel.name;
-			this.description = this.channel.description;
-			this.bannerId = this.channel.bannerId;
-			this.bannerUrl = this.channel.bannerUrl;
-		}
-	},
-
-	methods: {
-		save() {
-			const params = {
-				name: this.name,
-				description: this.description,
-				bannerId: this.bannerId,
-			};
-
-			if (this.channelId) {
-				params.channelId = this.channelId;
-				os.api('channels/update', params)
-				.then(channel => {
-					os.success();
-				});
-			} else {
-				os.api('channels/create', params)
-				.then(channel => {
-					os.success();
-					this.$router.push(`/channels/${channel.id}`);
-				});
-			}
-		},
-
-		setBannerImage(evt) {
-			selectFile(evt.currentTarget ?? evt.target, null).then(file => {
-				this.bannerId = file.id;
-			});
-		},
-
-		removeBannerImage() {
-			this.bannerId = null;
-		}
+watch(() => bannerId, async () => {
+	if (bannerId == null) {
+		bannerUrl = null;
+	} else {
+		bannerUrl = (await os.api('drive/files/show', {
+			fileId: bannerId,
+		})).url;
 	}
 });
+
+async function fetchChannel() {
+	if (props.channelId == null) return;
+
+	channel = await os.api('channels/show', {
+		channelId: props.channelId,
+	});
+
+	name = channel.name;
+	description = channel.description;
+	bannerId = channel.bannerId;
+	bannerUrl = channel.bannerUrl;
+}
+
+fetchChannel();
+
+function save() {
+	const params = {
+		name: name,
+		description: description,
+		bannerId: bannerId,
+	};
+
+	if (props.channelId) {
+		params.channelId = props.channelId;
+		os.api('channels/update', params).then(() => {
+			os.success();
+		});
+	} else {
+		os.api('channels/create', params).then(created => {
+			os.success();
+			router.push(`/channels/${created.id}`);
+		});
+	}
+}
+
+function setBannerImage(evt) {
+	selectFile(evt.currentTarget ?? evt.target, null).then(file => {
+		bannerId = file.id;
+	});
+}
+
+function removeBannerImage() {
+	bannerId = null;
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => props.channelId ? {
+	title: i18n.ts._channel.edit,
+	icon: 'fas fa-satellite-dish',
+	bg: 'var(--bg)',
+} : {
+	title: i18n.ts._channel.create,
+	icon: 'fas fa-satellite-dish',
+	bg: 'var(--bg)',
+}));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue
index c9a8f36844..003ad5cce9 100644
--- a/packages/client/src/pages/channel.vue
+++ b/packages/client/src/pages/channel.vue
@@ -1,98 +1,87 @@
 <template>
-<MkSpacer :content-max="700">
-	<div v-if="channel">
-		<div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }">
-			<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
-			<button class="_button toggle" @click="() => showBanner = !showBanner">
-				<template v-if="showBanner"><i class="fas fa-angle-up"></i></template>
-				<template v-else><i class="fas fa-angle-down"></i></template>
-			</button>
-			<div v-if="!showBanner" class="hideOverlay">
-			</div>
-			<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
-				<div class="status">
-					<div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
-					<div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="channel">
+			<div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }">
+				<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
+				<button class="_button toggle" @click="() => showBanner = !showBanner">
+					<template v-if="showBanner"><i class="fas fa-angle-up"></i></template>
+					<template v-else><i class="fas fa-angle-down"></i></template>
+				</button>
+				<div v-if="!showBanner" class="hideOverlay">
+				</div>
+				<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
+					<div class="status">
+						<div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
+						<div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+					</div>
+					<div class="fade"></div>
+				</div>
+				<div v-if="channel.description" class="description">
+					<Mfm :text="channel.description" :is-note="false" :i="$i"/>
 				</div>
-				<div class="fade"></div>
-			</div>
-			<div v-if="channel.description" class="description">
-				<Mfm :text="channel.description" :is-note="false" :i="$i"/>
 			</div>
+
+			<XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
+
+			<XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
 		</div>
-
-		<XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
-
-		<XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
 import MkContainer from '@/components/ui/container.vue';
 import XPostForm from '@/components/post-form.vue';
 import XTimeline from '@/components/timeline.vue';
 import XChannelFollowButton from '@/components/channel-follow-button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		MkContainer,
-		XPostForm,
-		XTimeline,
-		XChannelFollowButton
-	},
+const router = useRouter();
 
-	props: {
-		channelId: {
-			type: String,
-			required: true
-		}
-	},
+const props = defineProps<{
+	channelId: string;
+}>();
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.channel ? {
-				title: this.channel.name,
-				icon: 'fas fa-satellite-dish',
-				bg: 'var(--bg)',
-				actions: [...(this.$i && this.$i.id === this.channel.userId ? [{
-					icon: 'fas fa-cog',
-					text: this.$ts.edit,
-					handler: this.edit,
-				}] : [])],
-			} : null),
-			channel: null,
-			showBanner: true,
-			pagination: {
-				endpoint: 'channels/timeline' as const,
-				limit: 10,
-				params: computed(() => ({
-					channelId: this.channelId,
-				}))
-			},
-		};
-	},
+let channel = $ref(null);
+let showBanner = $ref(true);
+const pagination = {
+	endpoint: 'channels/timeline' as const,
+	limit: 10,
+	params: computed(() => ({
+		channelId: props.channelId,
+	})),
+};
 
-	watch: {
-		channelId: {
-			async handler() {
-				this.channel = await os.api('channels/show', {
-					channelId: this.channelId,
-				});
-			},
-			immediate: true
-		}
-	},
+watch(() => props.channelId, async () => {
+	channel = await os.api('channels/show', {
+		channelId: props.channelId,
+	});
+}, { immediate: true });
 
-	methods: {
-		edit() {
-			this.$router.push(`/channels/${this.channel.id}/edit`);
-		}
-	},
-});
+function edit() {
+	router.push(`/channels/${channel.id}/edit`);
+}
+
+const headerActions = $computed(() => channel && channel.userId ? [{
+	icon: 'fas fa-cog',
+	text: i18n.ts.edit,
+	handler: edit,
+}] : null);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => channel ? {
+	title: channel.name,
+	icon: 'fas fa-satellite-dish',
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue
index 4e538a6da3..89d23350f2 100644
--- a/packages/client/src/pages/channels.vue
+++ b/packages/client/src/pages/channels.vue
@@ -1,82 +1,83 @@
 <template>
-<MkSpacer :content-max="700">
-	<div v-if="tab === 'featured'" class="_content grwlizim featured">
-		<MkPagination v-slot="{items}" :pagination="featuredPagination">
-			<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-		</MkPagination>
-	</div>
-	<div v-else-if="tab === 'following'" class="_content grwlizim following">
-		<MkPagination v-slot="{items}" :pagination="followingPagination">
-			<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-		</MkPagination>
-	</div>
-	<div v-else-if="tab === 'owned'" class="_content grwlizim owned">
-		<MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
-		<MkPagination v-slot="{items}" :pagination="ownedPagination">
-			<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-		</MkPagination>
-	</div>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="tab === 'featured'" class="_content grwlizim featured">
+			<MkPagination v-slot="{items}" :pagination="featuredPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+		<div v-else-if="tab === 'following'" class="_content grwlizim following">
+			<MkPagination v-slot="{items}" :pagination="followingPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+		<div v-else-if="tab === 'owned'" class="_content grwlizim owned">
+			<MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+			<MkPagination v-slot="{items}" :pagination="ownedPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, inject } from 'vue';
 import MkChannelPreview from '@/components/channel-preview.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		MkChannelPreview, MkPagination, MkButton,
-	},
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.$ts.channel,
-				icon: 'fas fa-satellite-dish',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-plus',
-					text: this.$ts.create,
-					handler: this.create,
-				}],
-				tabs: [{
-					active: this.tab === 'featured',
-					title: this.$ts._channel.featured,
-					icon: 'fas fa-fire-alt',
-					onClick: () => { this.tab = 'featured'; },
-				}, {
-					active: this.tab === 'following',
-					title: this.$ts._channel.following,
-					icon: 'fas fa-heart',
-					onClick: () => { this.tab = 'following'; },
-				}, {
-					active: this.tab === 'owned',
-					title: this.$ts._channel.owned,
-					icon: 'fas fa-edit',
-					onClick: () => { this.tab = 'owned'; },
-				},]
-			})),
-			tab: 'featured',
-			featuredPagination: {
-				endpoint: 'channels/featured' as const,
-				noPaging: true,
-			},
-			followingPagination: {
-				endpoint: 'channels/followed' as const,
-				limit: 5,
-			},
-			ownedPagination: {
-				endpoint: 'channels/owned' as const,
-				limit: 5,
-			},
-		};
-	},
-	methods: {
-		create() {
-			this.$router.push(`/channels/new`);
-		}
-	}
-});
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredPagination = {
+	endpoint: 'channels/featured' as const,
+	noPaging: true,
+};
+const followingPagination = {
+	endpoint: 'channels/followed' as const,
+	limit: 5,
+};
+const ownedPagination = {
+	endpoint: 'channels/owned' as const,
+	limit: 5,
+};
+
+function create() {
+	router.push('/channels/new');
+}
+
+const headerActions = $computed(() => [{
+	icon: 'fas fa-plus',
+	text: i18n.ts.create,
+	handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'featured',
+	title: i18n.ts._channel.featured,
+	icon: 'fas fa-fire-alt',
+	onClick: () => { tab = 'featured'; },
+}, {
+	active: tab === 'following',
+	title: i18n.ts._channel.following,
+	icon: 'fas fa-heart',
+	onClick: () => { tab = 'following'; },
+}, {
+	active: tab === 'owned',
+	title: i18n.ts._channel.owned,
+	icon: 'fas fa-edit',
+	onClick: () => { tab = 'owned'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.channel,
+	icon: 'fas fa-satellite-dish',
+	bg: 'var(--bg)',
+})));
 </script>
diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue
index 163384337e..ce21b4c809 100644
--- a/packages/client/src/pages/clip.vue
+++ b/packages/client/src/pages/clip.vue
@@ -1,18 +1,21 @@
 <template>
-<MkSpacer :content-max="800">
-	<div v-if="clip">
-		<div class="okzinsic _panel">
-			<div v-if="clip.description" class="description">
-				<Mfm :text="clip.description" :is-note="false" :i="$i"/>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions"/></template>
+		<MkSpacer :content-max="800">
+		<div v-if="clip">
+			<div class="okzinsic _panel">
+				<div v-if="clip.description" class="description">
+					<Mfm :text="clip.description" :is-note="false" :i="$i"/>
+				</div>
+				<div class="user">
+					<MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
+				</div>
 			</div>
-			<div class="user">
-				<MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
-			</div>
-		</div>
 
-		<XNotes :pagination="pagination" :detail="true"/>
-	</div>
-</MkSpacer>
+			<XNotes :pagination="pagination" :detail="true"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -22,7 +25,7 @@ import XNotes from '@/components/notes.vue';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	clipId: string,
@@ -49,59 +52,58 @@ watch(() => props.clipId, async () => {
 
 provide('currentClipPage', $$(clip));
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => clip ? {
-		title: clip.name,
-		icon: 'fas fa-paperclip',
-		bg: 'var(--bg)',
-		actions: isOwned ? [{
-			icon: 'fas fa-pencil-alt',
-			text: i18n.ts.edit,
-			handler: async (): Promise<void> => {
-				const { canceled, result } = await os.form(clip.name, {
-					name: {
-						type: 'string',
-						label: i18n.ts.name,
-						default: clip.name,
-					},
-					description: {
-						type: 'string',
-						required: false,
-						multiline: true,
-						label: i18n.ts.description,
-						default: clip.description,
-					},
-					isPublic: {
-						type: 'boolean',
-						label: i18n.ts.public,
-						default: clip.isPublic,
-					},
-				});
-				if (canceled) return;
-
-				os.apiWithDialog('clips/update', {
-					clipId: clip.id,
-					...result,
-				});
+const headerActions = $computed(() => clip && isOwned ? [{
+	icon: 'fas fa-pencil-alt',
+	text: i18n.ts.edit,
+	handler: async (): Promise<void> => {
+		const { canceled, result } = await os.form(clip.name, {
+			name: {
+				type: 'string',
+				label: i18n.ts.name,
+				default: clip.name,
 			},
-		}, {
-			icon: 'fas fa-trash-alt',
-			text: i18n.ts.delete,
-			danger: true,
-			handler: async (): Promise<void> => {
-				const { canceled } = await os.confirm({
-					type: 'warning',
-					text: i18n.t('deleteAreYouSure', { x: clip.name }),
-				});
-				if (canceled) return;
-
-				await os.apiWithDialog('clips/delete', {
-					clipId: clip.id,
-				});
+			description: {
+				type: 'string',
+				required: false,
+				multiline: true,
+				label: i18n.ts.description,
+				default: clip.description,
 			},
-		}] : [],
-	} : null),
-});
+			isPublic: {
+				type: 'boolean',
+				label: i18n.ts.public,
+				default: clip.isPublic,
+			},
+		});
+		if (canceled) return;
+
+		os.apiWithDialog('clips/update', {
+			clipId: clip.id,
+			...result,
+		});
+	},
+}, {
+	icon: 'fas fa-trash-alt',
+	text: i18n.ts.delete,
+	danger: true,
+	handler: async (): Promise<void> => {
+		const { canceled } = await os.confirm({
+			type: 'warning',
+			text: i18n.t('deleteAreYouSure', { x: clip.name }),
+		});
+		if (canceled) return;
+
+		await os.apiWithDialog('clips/delete', {
+			clipId: clip.id,
+		});
+	},
+}] : null);
+
+definePageMetadata(computed(() => clip ? {
+	title: clip.name,
+	icon: 'fas fa-paperclip',
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue
index 68777bb083..c7bc31135c 100644
--- a/packages/client/src/pages/drive.vue
+++ b/packages/client/src/pages/drive.vue
@@ -8,17 +8,19 @@
 import { computed } from 'vue';
 import XDrive from '@/components/drive.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let folder = $ref(null);
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: folder ? folder.name : i18n.ts.drive,
-		icon: 'fas fa-cloud',
-		bg: 'var(--bg)',
-		hideHeader: true,
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: folder ? folder.name : i18n.ts.drive,
+	icon: 'fas fa-cloud',
+	bg: 'var(--bg)',
+	hideHeader: true,
+})));
 </script>
diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/emojis.category.vue
index c47870f4d4..6d915c5843 100644
--- a/packages/client/src/pages/emojis.category.vue
+++ b/packages/client/src/pages/emojis.category.vue
@@ -36,7 +36,6 @@ import MkSelect from '@/components/form/select.vue';
 import MkFolder from '@/components/ui/folder.vue';
 import MkTab from '@/components/tab.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { emojiCategories, emojiTags } from '@/instance';
 import XEmoji from './emojis.emoji.vue';
 
diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue
index f44b29df04..1592995844 100644
--- a/packages/client/src/pages/emojis.vue
+++ b/packages/client/src/pages/emojis.vue
@@ -1,15 +1,18 @@
 <template>
-<div :class="$style.root">
-	<XCategory v-if="tab === 'category'"/>
-</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div :class="$style.root">
+		<XCategory v-if="tab === 'category'"/>
+	</div>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { ref, computed } from 'vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
 import XCategory from './emojis.category.vue';
+import * as os from '@/os';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const tab = ref('category');
 
@@ -31,20 +34,21 @@ function menu(ev) {
 					text: err.message,
 				});
 			});
-		}
+		},
 	}], ev.currentTarget ?? ev.target);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.customEmojis,
-		icon: 'fas fa-laugh',
-		bg: 'var(--bg)',
-		actions: [{
-			icon: 'fas fa-ellipsis-h',
-			handler: menu,
-		}],
-	},
+const headerActions = $computed(() => [{
+	icon: 'fas fa-ellipsis-h',
+	handler: menu,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.customEmojis,
+	icon: 'fas fa-laugh',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue
index 04cc3662a7..26e201cd99 100644
--- a/packages/client/src/pages/explore.vue
+++ b/packages/client/src/pages/explore.vue
@@ -1,11 +1,12 @@
 <template>
-<div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="1200">
 		<div class="lznhrdub">
 			<div v-if="tab === 'local'">
-				<div v-if="meta && stats && tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }">
-					<header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header>
-					<div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div>
+				<div v-if="instance && stats && tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: instance.bannerUrl ? `url(${instance.bannerUrl})` : null }">
+					<header><span>{{ $t('explore', { host: instance.name || 'Misskey' }) }}</span></header>
+					<div><span>{{ $t('exploreUsersCount', { count: number(stats.originalUsersCount) }) }}</span></div>
 				</div>
 
 				<template v-if="tag == null">
@@ -32,7 +33,7 @@
 					<header><span>{{ $ts.exploreFediverse }}</span></header>
 				</div>
 
-				<MkFolder ref="tags" :foldable="true" :expanded="false" class="_gap">
+				<MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap">
 					<template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template>
 
 					<div class="vxjfqztj">
@@ -74,147 +75,127 @@
 					</MkRadios>
 				</div>
 
-				<XUserList v-if="searchQuery" ref="search" class="_gap" :pagination="searchPagination"/>
+				<XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/>
 			</div>
 		</div>
 	</MkSpacer>
-</div>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
 import XUserList from '@/components/user-list.vue';
 import MkFolder from '@/components/ui/folder.vue';
 import MkInput from '@/components/form/input.vue';
 import MkRadios from '@/components/form/radios.vue';
 import number from '@/filters/number';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
 
-export default defineComponent({
-	components: {
-		XUserList,
-		MkFolder,
-		MkInput,
-		MkRadios,
-	},
+const props = defineProps<{
+	tag?: string;
+}>();
 
-	props: {
-		tag: {
-			type: String,
-			required: false
-		}
-	},
+let tab = $ref('local');
+let tagsEl = $ref<InstanceType<typeof MkFolder>>();
+let tagsLocal = $ref([]);
+let tagsRemote = $ref([]);
+let stats = $ref(null);
+let searchQuery = $ref(null);
+let searchOrigin = $ref('combined');
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.$ts.explore,
-				icon: 'fas fa-hashtag',
-				bg: 'var(--bg)',
-				tabs: [{
-					active: this.tab === 'local',
-					title: this.$ts.local,
-					onClick: () => { this.tab = 'local'; },
-				}, {
-					active: this.tab === 'remote',
-					title: this.$ts.remote,
-					onClick: () => { this.tab = 'remote'; },
-				}, {
-					active: this.tab === 'search',
-					title: this.$ts.search,
-					onClick: () => { this.tab = 'search'; },
-				},]
-			})),
-			tab: 'local',
-			pinnedUsers: { endpoint: 'pinned-users' },
-			popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				state: 'alive',
-				origin: 'local',
-				sort: '+follower',
-			} },
-			recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				origin: 'local',
-				sort: '+updatedAt',
-			} },
-			recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				origin: 'local',
-				state: 'alive',
-				sort: '+createdAt',
-			} },
-			popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				state: 'alive',
-				origin: 'remote',
-				sort: '+follower',
-			} },
-			recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				origin: 'combined',
-				sort: '+updatedAt',
-			} },
-			recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				origin: 'combined',
-				sort: '+createdAt',
-			} },
-			searchPagination: {
-				endpoint: 'users/search' as const,
-				limit: 10,
-				params: computed(() => (this.searchQuery && this.searchQuery !== '') ? {
-					query: this.searchQuery,
-					origin: this.searchOrigin,
-				} : null)
-			},
-			tagsLocal: [],
-			tagsRemote: [],
-			stats: null,
-			searchQuery: null,
-			searchOrigin: 'combined',
-			num: number,
-		};
-	},
-
-	computed: {
-		meta() {
-			return this.$instance;
-		},
-		tagUsers(): any {
-			return {
-				endpoint: 'hashtags/users' as const,
-				limit: 30,
-				params: {
-					tag: this.tag,
-					origin: 'combined',
-					sort: '+follower',
-				}
-			};
-		},
-	},
-
-	watch: {
-		tag() {
-			if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
-		},
-	},
-
-	created() {
-		os.api('hashtags/list', {
-			sort: '+attachedLocalUsers',
-			attachedToLocalUserOnly: true,
-			limit: 30
-		}).then(tags => {
-			this.tagsLocal = tags;
-		});
-		os.api('hashtags/list', {
-			sort: '+attachedRemoteUsers',
-			attachedToRemoteUserOnly: true,
-			limit: 30
-		}).then(tags => {
-			this.tagsRemote = tags;
-		});
-		os.api('stats').then(stats => {
-			this.stats = stats;
-		});
-	},
+watch(() => props.tag, () => {
+	if (tagsEl) tagsEl.toggleContent(props.tag == null);
 });
+
+const tagUsers = $computed(() => ({
+	endpoint: 'hashtags/users' as const,
+	limit: 30,
+	params: {
+		tag: props.tag,
+		origin: 'combined',
+		sort: '+follower',
+	},
+}));
+
+const pinnedUsers = { endpoint: 'pinned-users' };
+const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	state: 'alive',
+	origin: 'local',
+	sort: '+follower',
+} };
+const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'local',
+	sort: '+updatedAt',
+} };
+const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'local',
+	state: 'alive',
+	sort: '+createdAt',
+} };
+const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	state: 'alive',
+	origin: 'remote',
+	sort: '+follower',
+} };
+const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'combined',
+	sort: '+updatedAt',
+} };
+const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'combined',
+	sort: '+createdAt',
+} };
+const searchPagination = {
+	endpoint: 'users/search' as const,
+	limit: 10,
+	params: computed(() => (searchQuery && searchQuery !== '') ? {
+		query: searchQuery,
+		origin: searchOrigin,
+	} : null),
+};
+
+os.api('hashtags/list', {
+	sort: '+attachedLocalUsers',
+	attachedToLocalUserOnly: true,
+	limit: 30,
+}).then(tags => {
+	tagsLocal = tags;
+});
+os.api('hashtags/list', {
+	sort: '+attachedRemoteUsers',
+	attachedToRemoteUserOnly: true,
+	limit: 30,
+}).then(tags => {
+	tagsRemote = tags;
+});
+os.api('stats').then(_stats => {
+	stats = _stats;
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'local',
+	title: i18n.ts.local,
+	onClick: () => { tab = 'local'; },
+}, {
+	active: tab === 'remote',
+	title: i18n.ts.remote,
+	onClick: () => { tab = 'remote'; },
+}, {
+	active: tab === 'search',
+	title: i18n.ts.search,
+	onClick: () => { tab = 'search'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.explore,
+	icon: 'fas fa-hashtag',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue
index b4f6ff35bc..6efca4c221 100644
--- a/packages/client/src/pages/favorites.vue
+++ b/packages/client/src/pages/favorites.vue
@@ -1,20 +1,23 @@
 <template>
-<MkSpacer :content-max="800">
-	<MkPagination ref="pagingComponent" :pagination="pagination">
-		<template #empty>
-			<div class="_fullinfo">
-				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-				<div>{{ $ts.noNotes }}</div>
-			</div>
-		</template>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<MkSpacer :content-max="800">
+		<MkPagination ref="pagingComponent" :pagination="pagination">
+			<template #empty>
+				<div class="_fullinfo">
+					<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+					<div>{{ $ts.noNotes }}</div>
+				</div>
+			</template>
 
-		<template #default="{ items }">
-			<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
-				<XNote :key="item.id" :note="item.note" :class="$style.note"/>
-			</XList>
-		</template>
-	</MkPagination>
-</MkSpacer>
+			<template #default="{ items }">
+				<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
+					<XNote :key="item.id" :note="item.note" :class="$style.note"/>
+				</XList>
+			</template>
+		</MkPagination>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -22,8 +25,8 @@ import { ref } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import XNote from '@/components/note.vue';
 import XList from '@/components/date-separated-list.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'i/favorites' as const,
@@ -32,12 +35,10 @@ const pagination = {
 
 const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.favorites,
-		icon: 'fas fa-star',
-		bg: 'var(--bg)',
-	},
+definePageMetadata({
+	title: i18n.ts.favorites,
+	icon: 'fas fa-star',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue
index 14fe0cb740..4e3f67c76c 100644
--- a/packages/client/src/pages/featured.vue
+++ b/packages/client/src/pages/featured.vue
@@ -1,13 +1,16 @@
 <template>
-<MkSpacer :content-max="800">
-	<XNotes ref="notes" :pagination="pagination"/>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<MkSpacer :content-max="800">
+		<XNotes ref="notes" :pagination="pagination"/>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'notes/featured' as const,
@@ -15,11 +18,9 @@ const pagination = {
 	offsetMode: true,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.featured,
-		icon: 'fas fa-fire-alt',
-		bg: 'var(--bg)',
-	},
+definePageMetadata({
+	title: i18n.ts.featured,
+	icon: 'fas fa-fire-alt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue
index 447918905b..eda46b9aa3 100644
--- a/packages/client/src/pages/federation.vue
+++ b/packages/client/src/pages/federation.vue
@@ -1,94 +1,97 @@
 <template>
-<MkSpacer :content-max="1000">
-	<div class="taeiyria">
-		<div class="query">
-			<MkInput v-model="host" :debounce="true" class="">
-				<template #prefix><i class="fas fa-search"></i></template>
-				<template #label>{{ $ts.host }}</template>
-			</MkInput>
-			<FormSplit style="margin-top: var(--margin);">
-				<MkSelect v-model="state">
-					<template #label>{{ $ts.state }}</template>
-					<option value="all">{{ $ts.all }}</option>
-					<option value="federating">{{ $ts.federating }}</option>
-					<option value="subscribing">{{ $ts.subscribing }}</option>
-					<option value="publishing">{{ $ts.publishing }}</option>
-					<option value="suspended">{{ $ts.suspended }}</option>
-					<option value="blocked">{{ $ts.blocked }}</option>
-					<option value="notResponding">{{ $ts.notResponding }}</option>
-				</MkSelect>
-				<MkSelect v-model="sort">
-					<template #label>{{ $ts.sort }}</template>
-					<option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
-					<option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
-					<option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
-					<option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
-					<option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
-					<option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option>
-					<option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
-					<option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
-				</MkSelect>
-			</FormSplit>
-		</div>
-
-		<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
-			<div class="dqokceoi">
-				<MkA v-for="instance in items" :key="instance.id" class="instance" :to="`/instance-info/${instance.host}`">
-					<div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div>
-					<div class="table">
-						<div class="cell">
-							<div class="key">{{ $ts.registeredAt }}</div>
-							<div class="value"><MkTime :time="instance.caughtAt"/></div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.software }}</div>
-							<div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.version }}</div>
-							<div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.users }}</div>
-							<div class="value">{{ instance.usersCount }}</div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.notes }}</div>
-							<div class="value">{{ instance.notesCount }}</div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.sent }}</div>
-							<div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.received }}</div>
-							<div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
-						</div>
-					</div>
-					<div class="footer">
-						<span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span>
-						<span class="pubSub">
-							<span v-if="instance.followersCount > 0" class="sub"><i class="fas fa-caret-down icon"></i>Sub</span>
-							<span v-else class="sub"><i class="fas fa-caret-down icon"></i>-</span>
-							<span v-if="instance.followingCount > 0" class="pub"><i class="fas fa-caret-up icon"></i>Pub</span>
-							<span v-else class="pub"><i class="fas fa-caret-up icon"></i>-</span>
-						</span>
-						<span class="right">
-							<span class="latestStatus">{{ instance.latestStatus || '-' }}</span>
-							<span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span>
-						</span>
-					</div>
-				</MkA>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="1000">
+		<div class="taeiyria">
+			<div class="query">
+				<MkInput v-model="host" :debounce="true" class="">
+					<template #prefix><i class="fas fa-search"></i></template>
+					<template #label>{{ $ts.host }}</template>
+				</MkInput>
+				<FormSplit style="margin-top: var(--margin);">
+					<MkSelect v-model="state">
+						<template #label>{{ $ts.state }}</template>
+						<option value="all">{{ $ts.all }}</option>
+						<option value="federating">{{ $ts.federating }}</option>
+						<option value="subscribing">{{ $ts.subscribing }}</option>
+						<option value="publishing">{{ $ts.publishing }}</option>
+						<option value="suspended">{{ $ts.suspended }}</option>
+						<option value="blocked">{{ $ts.blocked }}</option>
+						<option value="notResponding">{{ $ts.notResponding }}</option>
+					</MkSelect>
+					<MkSelect v-model="sort">
+						<template #label>{{ $ts.sort }}</template>
+						<option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
+						<option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
+						<option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
+						<option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
+						<option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
+						<option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option>
+						<option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
+						<option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
+					</MkSelect>
+				</FormSplit>
 			</div>
-		</MkPagination>
-	</div>
-</MkSpacer>
+
+			<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
+				<div class="dqokceoi">
+					<MkA v-for="instance in items" :key="instance.id" class="instance" :to="`/instance-info/${instance.host}`">
+						<div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div>
+						<div class="table">
+							<div class="cell">
+								<div class="key">{{ $ts.registeredAt }}</div>
+								<div class="value"><MkTime :time="instance.caughtAt"/></div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.software }}</div>
+								<div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.version }}</div>
+								<div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.users }}</div>
+								<div class="value">{{ instance.usersCount }}</div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.notes }}</div>
+								<div class="value">{{ instance.notesCount }}</div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.sent }}</div>
+								<div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.received }}</div>
+								<div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+							</div>
+						</div>
+						<div class="footer">
+							<span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span>
+							<span class="pubSub">
+								<span v-if="instance.followersCount > 0" class="sub"><i class="fas fa-caret-down icon"></i>Sub</span>
+								<span v-else class="sub"><i class="fas fa-caret-down icon"></i>-</span>
+								<span v-if="instance.followingCount > 0" class="pub"><i class="fas fa-caret-up icon"></i>Pub</span>
+								<span v-else class="pub"><i class="fas fa-caret-up icon"></i>-</span>
+							</span>
+							<span class="right">
+								<span class="latestStatus">{{ instance.latestStatus || '-' }}</span>
+								<span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span>
+							</span>
+						</div>
+					</MkA>
+				</div>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -99,8 +102,8 @@ import MkSelect from '@/components/form/select.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let host = $ref('');
 let state = $ref('federating');
@@ -119,8 +122,8 @@ const pagination = {
 			state === 'suspended' ? { suspended: true } :
 			state === 'blocked' ? { blocked: true } :
 			state === 'notResponding' ? { notResponding: true } :
-			{})
-	}))
+			{}),
+	})),
 };
 
 function getStatus(instance) {
@@ -129,12 +132,14 @@ function getStatus(instance) {
 	return 'alive';
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.federation,
-		icon: 'fas fa-globe',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.federation,
+	icon: 'fas fa-globe',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue
index 6adc1a404b..e6f9a9a5d9 100644
--- a/packages/client/src/pages/follow-requests.vue
+++ b/packages/client/src/pages/follow-requests.vue
@@ -7,7 +7,7 @@
 				<div>{{ $ts.noFollowRequests }}</div>
 			</div>
 		</template>
-		<template v-slot="{items}">
+		<template #default="{items}">
 			<div class="mk-follow-requests">
 				<div v-for="req in items" :key="req.id" class="user _panel">
 					<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
@@ -36,8 +36,8 @@ import { ref, computed } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import { userPage, acct } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const paginationComponent = ref<InstanceType<typeof MkPagination>>();
 
@@ -58,13 +58,15 @@ function reject(user) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.followRequests,
-		icon: 'fas fa-user-clock',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.followRequests,
+	icon: 'fas fa-user-clock',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue
index e69e0481e0..0c1cb7733b 100644
--- a/packages/client/src/pages/follow.vue
+++ b/packages/client/src/pages/follow.vue
@@ -5,8 +5,9 @@
 
 <script lang="ts">
 import { defineComponent } from 'vue';
-import * as os from '@/os';
 import * as Acct from 'misskey-js/built/acct';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
 
 export default defineComponent({
 	created() {
@@ -17,17 +18,17 @@ export default defineComponent({
 
 		if (acct.startsWith('https://')) {
 			promise = os.api('ap/show', {
-				uri: acct
+				uri: acct,
 			});
 			promise.then(res => {
 				if (res.type === 'User') {
 					this.follow(res.object);
 				} else if (res.type === 'Note') {
-					this.$router.push(`/notes/${res.object.id}`);
+					mainRouter.push(`/notes/${res.object.id}`);
 				} else {
 					os.alert({
 						type: 'error',
-						text: 'Not a user'
+						text: 'Not a user',
 					}).then(() => {
 						window.close();
 					});
@@ -56,9 +57,9 @@ export default defineComponent({
 			}
 			
 			os.apiWithDialog('following/create', {
-				userId: user.id
+				userId: user.id,
 			});
-		}
-	}
+		},
+	},
 });
 </script>
diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue
index bc87160c44..6d1140ba3a 100644
--- a/packages/client/src/pages/gallery/edit.vue
+++ b/packages/client/src/pages/gallery/edit.vue
@@ -27,8 +27,8 @@
 </div>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
 import FormButton from '@/components/ui/button.vue';
 import FormInput from '@/components/form/input.vue';
 import FormTextarea from '@/components/form/textarea.vue';
@@ -37,104 +37,87 @@ import FormGroup from '@/components/form/group.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import { selectFiles } from '@/scripts/select-file';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		FormButton,
-		FormInput,
-		FormTextarea,
-		FormSwitch,
-		FormGroup,
-		FormSuspense,
-	},
+const router = useRouter();
 
-	props: {
-		postId: {
-			type: String,
-			required: false,
-			default: null,
-		}
-	},
-	
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.postId ? {
-				title: this.$ts.edit,
-				icon: 'fas fa-pencil-alt'
-			} : {
-				title: this.$ts.postToGallery,
-				icon: 'fas fa-pencil-alt'
-			}),
-			init: null,
-			files: [],
-			description: null,
-			title: null,
-			isSensitive: false,
-		};
-	},
+const props = defineProps<{
+	postId?: string;
+}>();
 
-	watch: {
-		postId: {
-			handler() {
-				this.init = () => this.postId ? os.api('gallery/posts/show', {
-					postId: this.postId
-				}).then(post => {
-					this.files = post.files;
-					this.title = post.title;
-					this.description = post.description;
-					this.isSensitive = post.isSensitive;
-				}) : Promise.resolve(null);
-			},
-			immediate: true,
-		}
-	},
+let init = $ref(null);
+let files = $ref([]);
+let description = $ref(null);
+let title = $ref(null);
+let isSensitive = $ref(false);
 
-	methods: {
-		selectFile(evt) {
-			selectFiles(evt.currentTarget ?? evt.target, null).then(files => {
-				this.files = this.files.concat(files);
-			});
-		},
+function selectFile(evt) {
+	selectFiles(evt.currentTarget ?? evt.target, null).then(selected => {
+		files = files.concat(selected);
+	});
+}
 
-		remove(file) {
-			this.files = this.files.filter(f => f.id !== file.id);
-		},
+function remove(file) {
+	files = files.filter(f => f.id !== file.id);
+}
 
-		async save() {
-			if (this.postId) {
-				await os.apiWithDialog('gallery/posts/update', {
-					postId: this.postId,
-					title: this.title,
-					description: this.description,
-					fileIds: this.files.map(file => file.id),
-					isSensitive: this.isSensitive,
-				});
-				this.$router.push(`/gallery/${this.postId}`);
-			} else {
-				const post = await os.apiWithDialog('gallery/posts/create', {
-					title: this.title,
-					description: this.description,
-					fileIds: this.files.map(file => file.id),
-					isSensitive: this.isSensitive,
-				});
-				this.$router.push(`/gallery/${post.id}`);
-			}
-		},
-
-		async del() {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: this.$ts.deleteConfirm,
-			});
-			if (canceled) return;
-			await os.apiWithDialog('gallery/posts/delete', {
-				postId: this.postId,
-			});
-			this.$router.push(`/gallery`);
-		}
+async function save() {
+	if (props.postId) {
+		await os.apiWithDialog('gallery/posts/update', {
+			postId: props.postId,
+			title: title,
+			description: description,
+			fileIds: files.map(file => file.id),
+			isSensitive: isSensitive,
+		});
+		mainRouter.push(`/gallery/${props.postId}`);
+	} else {
+		const created = await os.apiWithDialog('gallery/posts/create', {
+			title: title,
+			description: description,
+			fileIds: files.map(file => file.id),
+			isSensitive: isSensitive,
+		});
+		router.push(`/gallery/${created.id}`);
 	}
-});
+}
+
+async function del() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteConfirm,
+	});
+	if (canceled) return;
+	await os.apiWithDialog('gallery/posts/delete', {
+		postId: props.postId,
+	});
+	mainRouter.push('/gallery');
+}
+
+watch(() => props.postId, () => {
+	init = () => props.postId ? os.api('gallery/posts/show', {
+		postId: props.postId,
+	}).then(post => {
+		files = post.files;
+		title = post.title;
+		description = post.description;
+		isSensitive = post.isSensitive;
+	}) : Promise.resolve(null);
+}, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => props.postId ? {
+	title: i18n.ts.edit,
+	icon: 'fas fa-pencil-alt',
+} : {
+	title: i18n.ts.postToGallery,
+	icon: 'fas fa-pencil-alt',
+}));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue
index a19d69d5c2..b26470dbe9 100644
--- a/packages/client/src/pages/gallery/index.vue
+++ b/packages/client/src/pages/gallery/index.vue
@@ -1,49 +1,54 @@
 <template>
-<div class="xprsixdl _root">
-	<MkTab v-if="$i" v-model="tab">
-		<option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option>
-		<option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option>
-		<option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option>
-	</MkTab>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="1400">
+		<div class="_root">
+			<MkTab v-if="$i" v-model="tab">
+				<option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option>
+				<option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option>
+				<option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option>
+			</MkTab>
 
-	<div v-if="tab === 'explore'">
-		<MkFolder class="_gap">
-			<template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
-			<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
-				<div class="vfpdbgtk">
-					<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-				</div>
-			</MkPagination>
-		</MkFolder>
-		<MkFolder class="_gap">
-			<template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
-			<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
-				<div class="vfpdbgtk">
-					<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-				</div>
-			</MkPagination>
-		</MkFolder>
-	</div>
-	<div v-else-if="tab === 'liked'">
-		<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
-			<div class="vfpdbgtk">
-				<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
+			<div v-if="tab === 'explore'">
+				<MkFolder class="_gap">
+					<template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
+					<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
+						<div class="vfpdbgtk">
+							<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+						</div>
+					</MkPagination>
+				</MkFolder>
+				<MkFolder class="_gap">
+					<template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
+					<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
+						<div class="vfpdbgtk">
+							<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+						</div>
+					</MkPagination>
+				</MkFolder>
 			</div>
-		</MkPagination>
-	</div>
-	<div v-else-if="tab === 'my'">
-		<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
-		<MkPagination v-slot="{items}" :pagination="myPostsPagination">
-			<div class="vfpdbgtk">
-				<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+			<div v-else-if="tab === 'liked'">
+				<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
+					<div class="vfpdbgtk">
+						<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
+					</div>
+				</MkPagination>
 			</div>
-		</MkPagination>
-	</div>
-</div>
+			<div v-else-if="tab === 'my'">
+				<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
+				<MkPagination v-slot="{items}" :pagination="myPostsPagination">
+					<div class="vfpdbgtk">
+						<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+					</div>
+				</MkPagination>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
 import XUserList from '@/components/user-list.vue';
 import MkFolder from '@/components/ui/folder.vue';
 import MkInput from '@/components/form/input.vue';
@@ -53,92 +58,60 @@ import MkPagination from '@/components/ui/pagination.vue';
 import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
 import number from '@/filters/number';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		XUserList,
-		MkFolder,
-		MkInput,
-		MkButton,
-		MkTab,
-		MkPagination,
-		MkGalleryPostPreview,
+const props = defineProps<{
+	tag?: string;
+}>();
+
+let tab = $ref('explore');
+let tags = $ref([]);
+let tagsRef = $ref();
+
+const recentPostsPagination = {
+	endpoint: 'gallery/posts' as const,
+	limit: 6,
+};
+const popularPostsPagination = {
+	endpoint: 'gallery/featured' as const,
+	limit: 5,
+};
+const myPostsPagination = {
+	endpoint: 'i/gallery/posts' as const,
+	limit: 5,
+};
+const likedPostsPagination = {
+	endpoint: 'i/gallery/likes' as const,
+	limit: 5,
+};
+
+const tagUsersPagination = $computed(() => ({
+	endpoint: 'hashtags/users' as const,
+	limit: 30,
+	params: {
+		tag: this.tag,
+		origin: 'combined',
+		sort: '+follower',
 	},
+}));
 
-	props: {
-		tag: {
-			type: String,
-			required: false
-		}
-	},
+watch(() => props.tag, () => {
+	if (tagsRef) tagsRef.tags.toggleContent(props.tag == null);
+});
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.gallery,
-				icon: 'fas fa-icons'
-			},
-			tab: 'explore',
-			recentPostsPagination: {
-				endpoint: 'gallery/posts' as const,
-				limit: 6,
-			},
-			popularPostsPagination: {
-				endpoint: 'gallery/featured' as const,
-				limit: 5,
-			},
-			myPostsPagination: {
-				endpoint: 'i/gallery/posts' as const,
-				limit: 5,
-			},
-			likedPostsPagination: {
-				endpoint: 'i/gallery/likes' as const,
-				limit: 5,
-			},
-			tags: [],
-		};
-	},
+const headerActions = $computed(() => []);
 
-	computed: {
-		meta() {
-			return this.$instance;
-		},
-		tagUsers(): any {
-			return {
-				endpoint: 'hashtags/users' as const,
-				limit: 30,
-				params: {
-					tag: this.tag,
-					origin: 'combined',
-					sort: '+follower',
-				}
-			};
-		},
-	},
+const headerTabs = $computed(() => []);
 
-	watch: {
-		tag() {
-			if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
-		},
-	},
-
-	created() {
-
-	},
-
-	methods: {
-
-	}
+definePageMetadata({
+	title: i18n.ts.gallery,
+	icon: 'fas fa-icons',
+	bg: 'var(--bg)',
 });
 </script>
 
 <style lang="scss" scoped>
-.xprsixdl {
-	max-width: 1400px;
-	margin: 0 auto;
-}
-
 .vfpdbgtk {
 	display: grid;
 	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue
index 1ca3443e56..d5f3253b3e 100644
--- a/packages/client/src/pages/gallery/post.vue
+++ b/packages/client/src/pages/gallery/post.vue
@@ -49,123 +49,108 @@
 </div>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, inject, watch } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import MkContainer from '@/components/ui/container.vue';
 import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
 import MkFollowButton from '@/components/follow-button.vue';
 import { url } from '@/config';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		MkContainer,
-		ImgWithBlurhash,
-		MkPagination,
-		MkGalleryPostPreview,
-		MkButton,
-		MkFollowButton,
+const router = useRouter();
+
+const props = defineProps<{
+	postId: string;
+}>();
+
+const post = $ref(null);
+const error = $ref(null);
+const otherPostsPagination = {
+	endpoint: 'users/gallery/posts' as const,
+	limit: 6,
+	params: computed(() => ({
+		userId: post.user.id,
+	})),
+};
+
+function fetchPost() {
+	post = null;
+	os.api('gallery/posts/show', {
+		postId: props.postId,
+	}).then(_post => {
+		post = _post;
+	}).catch(_error => {
+		error = _error;
+	});
+}
+
+function share() {
+	navigator.share({
+		title: post.title,
+		text: post.description,
+		url: `${url}/gallery/${post.id}`,
+	});
+}
+
+function shareWithNote() {
+	os.post({
+		initialText: `${post.title} ${url}/gallery/${post.id}`,
+	});
+}
+
+function like() {
+	os.apiWithDialog('gallery/posts/like', {
+		postId: props.postId,
+	}).then(() => {
+		post.isLiked = true;
+		post.likedCount++;
+	});
+}
+
+async function unlike() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.unlikeConfirm,
+	});
+	if (confirm.canceled) return;
+	os.apiWithDialog('gallery/posts/unlike', {
+		postId: props.postId,
+	}).then(() => {
+		post.isLiked = false;
+		post.likedCount--;
+	});
+}
+
+function edit() {
+	router.push(`/gallery/${post.id}/edit`);
+}
+
+watch(() => props.postId, fetchPost, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => post ? {
+	title: post.title,
+	avatar: post.user,
+	path: `/gallery/${post.id}`,
+	share: {
+		title: post.title,
+		text: post.description,
 	},
-	props: {
-		postId: {
-			type: String,
-			required: true
-		}
-	},
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.post ? {
-				title: this.post.title,
-				avatar: this.post.user,
-				path: `/gallery/${this.post.id}`,
-				share: {
-					title: this.post.title,
-					text: this.post.description,
-				},
-				actions: [{
-					icon: 'fas fa-pencil-alt',
-					text: this.$ts.edit,
-					handler: this.edit
-				}]
-			} : null),
-			otherPostsPagination: {
-				endpoint: 'users/gallery/posts' as const,
-				limit: 6,
-				params: computed(() => ({
-					userId: this.post.user.id
-				})),
-			},
-			post: null,
-			error: null,
-		};
-	},
-
-	watch: {
-		postId: 'fetch'
-	},
-
-	created() {
-		this.fetch();
-	},
-
-	methods: {
-		fetch() {
-			this.post = null;
-			os.api('gallery/posts/show', {
-				postId: this.postId
-			}).then(post => {
-				this.post = post;
-			}).catch(err => {
-				this.error = err;
-			});
-		},
-
-		share() {
-			navigator.share({
-				title: this.post.title,
-				text: this.post.description,
-				url: `${url}/gallery/${this.post.id}`
-			});
-		},
-
-		shareWithNote() {
-			os.post({
-				initialText: `${this.post.title} ${url}/gallery/${this.post.id}`
-			});
-		},
-
-		like() {
-			os.apiWithDialog('gallery/posts/like', {
-				postId: this.postId,
-			}).then(() => {
-				this.post.isLiked = true;
-				this.post.likedCount++;
-			});
-		},
-
-		async unlike() {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: this.$ts.unlikeConfirm,
-			});
-			if (confirm.canceled) return;
-			os.apiWithDialog('gallery/posts/unlike', {
-				postId: this.postId,
-			}).then(() => {
-				this.post.isLiked = false;
-				this.post.likedCount--;
-			});
-		},
-
-		edit() {
-			this.$router.push(`/gallery/${this.post.id}/edit`);
-		}
-	}
-});
+	actions: [{
+		icon: 'fas fa-pencil-alt',
+		text: i18n.ts.edit,
+		handler: edit,
+	}],
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue
index f19cb9d1a2..31d3d4ac83 100644
--- a/packages/client/src/pages/instance-info.vue
+++ b/packages/client/src/pages/instance-info.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
 	<div v-if="instance" class="_formRoot">
 		<div class="fnfelxur">
 			<img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/>
@@ -102,7 +103,7 @@
 			<FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink>
 		</FormSection>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -120,8 +121,8 @@ import FormSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
 import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
 import { iAmModerator } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	host: string;
@@ -146,7 +147,7 @@ async function fetch() {
 async function toggleBlock(ev) {
 	if (meta == null) return;
 	await os.api('admin/update-meta', {
-		blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host)
+		blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
 	});
 }
 
@@ -168,19 +169,21 @@ function refreshMetadata() {
 
 fetch();
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: props.host,
-		icon: 'fas fa-info-circle',
-		bg: 'var(--bg)',
-		actions: [{
-			text: `https://${props.host}`,
-			icon: 'fas fa-external-link-alt',
-			handler: () => {
-				window.open(`https://${props.host}`, '_blank');
-			}
-		}],
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: props.host,
+	icon: 'fas fa-info-circle',
+	bg: 'var(--bg)',
+	actions: [{
+		text: `https://${props.host}`,
+		icon: 'fas fa-external-link-alt',
+		handler: () => {
+			window.open(`https://${props.host}`, '_blank');
+		},
+	}],
 });
 </script>
 
diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue
index 9b57c956bf..0835f1f019 100644
--- a/packages/client/src/pages/mentions.vue
+++ b/packages/client/src/pages/mentions.vue
@@ -1,24 +1,27 @@
-<template>
-<MkSpacer :content-max="800">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800">
 	<XNotes :pagination="pagination"/>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'notes/mentions' as const,
 	limit: 10,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.mentions,
-		icon: 'fas fa-at',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.mentions,
+	icon: 'fas fa-at',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue
index 9c5fb9b341..e443b5c461 100644
--- a/packages/client/src/pages/messages.vue
+++ b/packages/client/src/pages/messages.vue
@@ -1,27 +1,30 @@
-<template>
-<MkSpacer :content-max="800">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800">
 	<XNotes :pagination="pagination"/>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'notes/mentions' as const,
 	limit: 10,
 	params: {
-		visibility: 'specified'
+		visibility: 'specified',
 	},
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.directNotes,
-		icon: 'fas fa-envelope',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.directNotes,
+	icon: 'fas fa-envelope',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue
index 7c1d3e3cbe..bf9ac056cf 100644
--- a/packages/client/src/pages/messaging/index.vue
+++ b/packages/client/src/pages/messaging/index.vue
@@ -1,165 +1,165 @@
 <template>
-<MkSpacer :content-max="800">
-	<div v-size="{ max: [400] }" class="yweeujhr">
-		<MkButton primary class="start" @click="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div v-size="{ max: [400] }" class="yweeujhr">
+			<MkButton primary class="start" @click="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
 
-		<div v-if="messages.length > 0" class="history">
-			<MkA v-for="(message, i) in messages"
-				:key="message.id"
-				v-anim="i"
-				class="message _block"
-				:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
-				:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
-				:data-index="i"
-			>
-				<div>
-					<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
-					<header v-if="message.groupId">
-						<span class="name">{{ message.group.name }}</span>
-						<MkTime :time="message.createdAt" class="time"/>
-					</header>
-					<header v-else>
-						<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
-						<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
-						<MkTime :time="message.createdAt" class="time"/>
-					</header>
-					<div class="body">
-						<p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
+			<div v-if="messages.length > 0" class="history">
+				<MkA
+					v-for="(message, i) in messages"
+					:key="message.id"
+					v-anim="i"
+					class="message _block"
+					:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
+					:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
+					:data-index="i"
+				>
+					<div>
+						<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
+						<header v-if="message.groupId">
+							<span class="name">{{ message.group.name }}</span>
+							<MkTime :time="message.createdAt" class="time"/>
+						</header>
+						<header v-else>
+							<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
+							<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
+							<MkTime :time="message.createdAt" class="time"/>
+						</header>
+						<div class="body">
+							<p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
+						</div>
 					</div>
-				</div>
-			</MkA>
+				</MkA>
+			</div>
+			<div v-if="!fetching && messages.length == 0" class="_fullinfo">
+				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+				<div>{{ $ts.noHistory }}</div>
+			</div>
+			<MkLoading v-if="fetching"/>
 		</div>
-		<div v-if="!fetching && messages.length == 0" class="_fullinfo">
-			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-			<div>{{ $ts.noHistory }}</div>
-		</div>
-		<MkLoading v-if="fetching"/>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+<script lang="ts" setup>
+import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue';
 import * as Acct from 'misskey-js/built/acct';
 import MkButton from '@/components/ui/button.vue';
 import { acct } from '@/filters/user';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
 
-export default defineComponent({
-	components: {
-		MkButton
-	},
+const router = useRouter();
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.messaging,
-				icon: 'fas fa-comments',
-				bg: 'var(--bg)',
-			},
-			fetching: true,
-			moreFetching: false,
-			messages: [],
-			connection: null,
-		};
-	},
+let fetching = $ref(true);
+let moreFetching = $ref(false);
+let messages = $ref([]);
+let connection = $ref(null);
 
-	mounted() {
-		this.connection = markRaw(stream.useChannel('messagingIndex'));
+const getAcct = Acct.toString;
 
-		this.connection.on('message', this.onMessage);
-		this.connection.on('read', this.onRead);
+function isMe(message) {
+	return message.userId === $i.id;
+}
 
-		os.api('messaging/history', { group: false }).then(userMessages => {
-			os.api('messaging/history', { group: true }).then(groupMessages => {
-				const messages = userMessages.concat(groupMessages);
-				messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
-				this.messages = messages;
-				this.fetching = false;
-			});
-		});
-	},
+function onMessage(message) {
+	if (message.recipientId) {
+		messages = messages.filter(m => !(
+			(m.recipientId === message.recipientId && m.userId === message.userId) ||
+			(m.recipientId === message.userId && m.userId === message.recipientId)));
 
-	beforeUnmount() {
-		this.connection.dispose();
-	},
-
-	methods: {
-		getAcct: Acct.toString,
-
-		isMe(message) {
-			return message.userId === this.$i.id;
-		},
-
-		onMessage(message) {
-			if (message.recipientId) {
-				this.messages = this.messages.filter(m => !(
-					(m.recipientId === message.recipientId && m.userId === message.userId) ||
-					(m.recipientId === message.userId && m.userId === message.recipientId)));
-
-				this.messages.unshift(message);
-			} else if (message.groupId) {
-				this.messages = this.messages.filter(m => m.groupId !== message.groupId);
-				this.messages.unshift(message);
-			}
-		},
-
-		onRead(ids) {
-			for (const id of ids) {
-				const found = this.messages.find(m => m.id === id);
-				if (found) {
-					if (found.recipientId) {
-						found.isRead = true;
-					} else if (found.groupId) {
-						found.reads.push(this.$i.id);
-					}
-				}
-			}
-		},
-
-		start(ev) {
-			os.popupMenu([{
-				text: this.$ts.messagingWithUser,
-				icon: 'fas fa-user',
-				action: () => { this.startUser(); }
-			}, {
-				text: this.$ts.messagingWithGroup,
-				icon: 'fas fa-users',
-				action: () => { this.startGroup(); }
-			}], ev.currentTarget ?? ev.target);
-		},
-
-		async startUser() {
-			os.selectUser().then(user => {
-				this.$router.push(`/my/messaging/${Acct.toString(user)}`);
-			});
-		},
-
-		async startGroup() {
-			const groups1 = await os.api('users/groups/owned');
-			const groups2 = await os.api('users/groups/joined');
-			if (groups1.length === 0 && groups2.length === 0) {
-				os.alert({
-					type: 'warning',
-					title: this.$ts.youHaveNoGroups,
-					text: this.$ts.joinOrCreateGroup,
-				});
-				return;
-			}
-			const { canceled, result: group } = await os.select({
-				title: this.$ts.group,
-				items: groups1.concat(groups2).map(group => ({
-					value: group, text: group.name
-				}))
-			});
-			if (canceled) return;
-			this.$router.push(`/my/messaging/group/${group.id}`);
-		},
-
-		acct
+		messages.unshift(message);
+	} else if (message.groupId) {
+		messages = messages.filter(m => m.groupId !== message.groupId);
+		messages.unshift(message);
 	}
+}
+
+function onRead(ids) {
+	for (const id of ids) {
+		const found = messages.find(m => m.id === id);
+		if (found) {
+			if (found.recipientId) {
+				found.isRead = true;
+			} else if (found.groupId) {
+				found.reads.push($i.id);
+			}
+		}
+	}
+}
+
+function start(ev) {
+	os.popupMenu([{
+		text: i18n.ts.messagingWithUser,
+		icon: 'fas fa-user',
+		action: () => { startUser(); },
+	}, {
+		text: i18n.ts.messagingWithGroup,
+		icon: 'fas fa-users',
+		action: () => { startGroup(); },
+	}], ev.currentTarget ?? ev.target);
+}
+
+async function startUser() {
+	os.selectUser().then(user => {
+		router.push(`/my/messaging/${Acct.toString(user)}`);
+	});
+}
+
+async function startGroup() {
+	const groups1 = await os.api('users/groups/owned');
+	const groups2 = await os.api('users/groups/joined');
+	if (groups1.length === 0 && groups2.length === 0) {
+		os.alert({
+			type: 'warning',
+			title: i18n.ts.youHaveNoGroups,
+			text: i18n.ts.joinOrCreateGroup,
+		});
+		return;
+	}
+	const { canceled, result: group } = await os.select({
+		title: i18n.ts.group,
+		items: groups1.concat(groups2).map(group => ({
+			value: group, text: group.name,
+		})),
+	});
+	if (canceled) return;
+	router.push(`/my/messaging/group/${group.id}`);
+}
+
+onMounted(() => {
+	connection = markRaw(stream.useChannel('messagingIndex'));
+
+	connection.on('message', onMessage);
+	connection.on('read', onRead);
+
+	os.api('messaging/history', { group: false }).then(userMessages => {
+		os.api('messaging/history', { group: true }).then(groupMessages => {
+			const _messages = userMessages.concat(groupMessages);
+			_messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+			messages = _messages;
+			fetching = false;
+		});
+	});
+});
+
+onUnmounted(() => {
+	if (connection) connection.dispose();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.messaging,
+	icon: 'fas fa-comments',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue
index 65c67e6354..2e00c3ab19 100644
--- a/packages/client/src/pages/messaging/messaging-room.vue
+++ b/packages/client/src/pages/messaging/messaging-room.vue
@@ -61,10 +61,10 @@ import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scrol
 import * as os from '@/os';
 import { stream } from '@/stream';
 import * as sound from '@/scripts/sound';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { $i } from '@/account';
 import { defaultStore } from '@/store';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	userAcct?: string;
@@ -280,15 +280,13 @@ onBeforeUnmount(() => {
 	if (scrollRemove) scrollRemove();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => !fetching ? user ? {
-		userName: user,
-		avatar: user,
-	} : {
-		title: group?.name,
-		icon: 'fas fa-users',
-	} : null),
-});
+definePageMetadata(computed(() => !fetching ? user ? {
+	userName: user,
+	avatar: user,
+} : {
+	title: group?.name,
+	icon: 'fas fa-users',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue
index 2c10494ede..2b92a69c4b 100644
--- a/packages/client/src/pages/mfm-cheat-sheet.vue
+++ b/packages/client/src/pages/mfm-cheat-sheet.vue
@@ -1,127 +1,129 @@
 <template>
-<div class="mwysmxbg">
-	<div class="_isolated">{{ $ts._mfm.intro }}</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.mention }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.mentionDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_mention"/>
-				<MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<div class="mwysmxbg">
+		<div class="_isolated">{{ $ts._mfm.intro }}</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.mention }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.mentionDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_mention"/>
+					<MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.hashtag }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.hashtagDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_hashtag"/>
-				<MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.hashtag }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.hashtagDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_hashtag"/>
+					<MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.url }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.urlDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_url"/>
-				<MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.url }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.urlDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_url"/>
+					<MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.link }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.linkDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_link"/>
-				<MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.link }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.linkDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_link"/>
+					<MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.emoji }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.emojiDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_emoji"/>
-				<MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.emoji }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.emojiDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_emoji"/>
+					<MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.bold }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.boldDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_bold"/>
-				<MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.bold }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.boldDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_bold"/>
+					<MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.small }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.smallDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_small"/>
-				<MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.small }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.smallDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_small"/>
+					<MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.quote }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.quoteDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_quote"/>
-				<MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.quote }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.quoteDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_quote"/>
+					<MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.center }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.centerDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_center"/>
-				<MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.center }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.centerDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_center"/>
+					<MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.inlineCode }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.inlineCodeDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_inlineCode"/>
-				<MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.inlineCode }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.inlineCodeDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_inlineCode"/>
+					<MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.blockCode }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.blockCodeDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_blockCode"/>
-				<MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.blockCode }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.blockCodeDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_blockCode"/>
+					<MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.inlineMath }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.inlineMathDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_inlineMath"/>
-				<MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.inlineMath }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.inlineMathDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_inlineMath"/>
+					<MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<!-- deprecated
+		<!-- deprecated
 	<div class="section _block">
 		<div class="title">{{ $ts._mfm.search }}</div>
 		<div class="content">
@@ -133,216 +135,210 @@
 		</div>
 	</div>
 	-->
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.flip }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.flipDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_flip"/>
-				<MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.flip }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.flipDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_flip"/>
+					<MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.font }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.fontDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_font"/>
+					<MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.x2 }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.x2Description }}</p>
+				<div class="preview">
+					<Mfm :text="preview_x2"/>
+					<MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.x3 }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.x3Description }}</p>
+				<div class="preview">
+					<Mfm :text="preview_x3"/>
+					<MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.x4 }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.x4Description }}</p>
+				<div class="preview">
+					<Mfm :text="preview_x4"/>
+					<MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.blur }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.blurDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_blur"/>
+					<MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.jelly }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.jellyDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_jelly"/>
+					<MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.tada }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.tadaDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_tada"/>
+					<MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.jump }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.jumpDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_jump"/>
+					<MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.bounce }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.bounceDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_bounce"/>
+					<MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.spin }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.spinDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_spin"/>
+					<MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.shake }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.shakeDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_shake"/>
+					<MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.twitch }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.twitchDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_twitch"/>
+					<MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.rainbow }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.rainbowDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_rainbow"/>
+					<MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.sparkle }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.sparkleDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_sparkle"/>
+					<MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
+				</div>
+			</div>
+		</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.rotate }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.rotateDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_rotate"/>
+					<MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
+				</div>
 			</div>
 		</div>
 	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.font }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.fontDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_font"/>
-				<MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.x2 }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.x2Description }}</p>
-			<div class="preview">
-				<Mfm :text="preview_x2"/>
-				<MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.x3 }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.x3Description }}</p>
-			<div class="preview">
-				<Mfm :text="preview_x3"/>
-				<MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.x4 }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.x4Description }}</p>
-			<div class="preview">
-				<Mfm :text="preview_x4"/>
-				<MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.blur }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.blurDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_blur"/>
-				<MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.jelly }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.jellyDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_jelly"/>
-				<MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.tada }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.tadaDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_tada"/>
-				<MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.jump }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.jumpDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_jump"/>
-				<MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.bounce }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.bounceDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_bounce"/>
-				<MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.spin }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.spinDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_spin"/>
-				<MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.shake }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.shakeDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_shake"/>
-				<MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.twitch }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.twitchDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_twitch"/>
-				<MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.rainbow }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.rainbowDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_rainbow"/>
-				<MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.sparkle }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.sparkleDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_sparkle"/>
-				<MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
-			</div>
-		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.rotate }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.rotateDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_rotate"/>
-				<MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
-			</div>
-		</div>
-	</div>
-</div>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
+<script lang="ts" setup>
 import { defineComponent } from 'vue';
 import MkTextarea from '@/components/form/textarea.vue';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
 
-export default defineComponent({
-	components: {
-		MkTextarea
-	},
+const preview_mention = '@example';
+const preview_hashtag = '#test';
+const preview_url = 'https://example.com';
+const preview_link = `[${i18n.ts._mfm.dummy}](https://example.com)`;
+const preview_emoji = instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:';
+const preview_bold = `**${i18n.ts._mfm.dummy}**`;
+const preview_small = `<small>${i18n.ts._mfm.dummy}</small>`;
+const preview_center = `<center>${i18n.ts._mfm.dummy}</center>`;
+const preview_inlineCode = '`<: "Hello, world!"`';
+const preview_blockCode = '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```';
+const preview_inlineMath = '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)';
+const preview_quote = `> ${i18n.ts._mfm.dummy}`;
+const preview_search = `${i18n.ts._mfm.dummy} 検索`;
+const preview_jelly = '$[jelly 🍮] $[jelly.speed=5s 🍮]';
+const preview_tada = '$[tada 🍮] $[tada.speed=5s 🍮]';
+const preview_jump = '$[jump 🍮] $[jump.speed=5s 🍮]';
+const preview_bounce = '$[bounce 🍮] $[bounce.speed=5s 🍮]';
+const preview_shake = '$[shake 🍮] $[shake.speed=5s 🍮]';
+const preview_twitch = '$[twitch 🍮] $[twitch.speed=5s 🍮]';
+const preview_spin = '$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]';
+const preview_flip = `$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`;
+const preview_font = `$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`;
+const preview_x2 = '$[x2 🍮]';
+const preview_x3 = '$[x3 🍮]';
+const preview_x4 = '$[x4 🍮]';
+const preview_blur = `$[blur ${i18n.ts._mfm.dummy}]`;
+const preview_rainbow = '$[rainbow 🍮] $[rainbow.speed=5s 🍮]';
+const preview_sparkle = '$[sparkle 🍮]';
+const preview_rotate = '$[rotate 🍮]';
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts._mfm.cheatSheet,
-				icon: 'fas fa-question-circle',
-			},
-			preview_mention: '@example',
-			preview_hashtag: '#test',
-			preview_url: `https://example.com`,
-			preview_link: `[${this.$ts._mfm.dummy}](https://example.com)`,
-			preview_emoji: this.$instance.emojis.length ? `:${this.$instance.emojis[0].name}:` : `:emojiname:`,
-			preview_bold: `**${this.$ts._mfm.dummy}**`,
-			preview_small: `<small>${this.$ts._mfm.dummy}</small>`,
-			preview_center: `<center>${this.$ts._mfm.dummy}</center>`,
-			preview_inlineCode: '`<: "Hello, world!"`',
-			preview_blockCode: '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```',
-			preview_inlineMath: '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)',
-			preview_quote: `> ${this.$ts._mfm.dummy}`,
-			preview_search: `${this.$ts._mfm.dummy} 検索`,
-			preview_jelly: `$[jelly 🍮] $[jelly.speed=5s 🍮]`,
-			preview_tada: `$[tada 🍮] $[tada.speed=5s 🍮]`,
-			preview_jump: `$[jump 🍮] $[jump.speed=5s 🍮]`,
-			preview_bounce: `$[bounce 🍮] $[bounce.speed=5s 🍮]`,
-			preview_shake: `$[shake 🍮] $[shake.speed=5s 🍮]`,
-			preview_twitch: `$[twitch 🍮] $[twitch.speed=5s 🍮]`,
-			preview_spin: `$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]`,
-			preview_flip: `$[flip ${this.$ts._mfm.dummy}]\n$[flip.v ${this.$ts._mfm.dummy}]\n$[flip.h,v ${this.$ts._mfm.dummy}]`,
-			preview_font: `$[font.serif ${this.$ts._mfm.dummy}]\n$[font.monospace ${this.$ts._mfm.dummy}]\n$[font.cursive ${this.$ts._mfm.dummy}]\n$[font.fantasy ${this.$ts._mfm.dummy}]`,
-			preview_x2: `$[x2 🍮]`,
-			preview_x3: `$[x3 🍮]`,
-			preview_x4: `$[x4 🍮]`,
-			preview_blur: `$[blur ${this.$ts._mfm.dummy}]`,
-			preview_rainbow: `$[rainbow 🍮] $[rainbow.speed=5s 🍮]`,
-			preview_sparkle: `$[sparkle 🍮]`,
-			preview_rotate: `$[rotate 🍮]`,
-		};
-	},
+definePageMetadata({
+	title: i18n.ts._mfm.cheatSheet,
+	icon: 'fas fa-question-circle',
 });
 </script>
 
diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue
index 4032d7723e..4b3ac7761e 100644
--- a/packages/client/src/pages/miauth.vue
+++ b/packages/client/src/pages/miauth.vue
@@ -49,28 +49,12 @@ export default defineComponent({
 		MkSignin,
 		MkButton,
 	},
+	props: ['session', 'callback', 'name', 'icon', 'permission'],
 	data() {
 		return {
-			state: null
+			state: null,
 		};
 	},
-	computed: {
-		session(): string {
-			return this.$route.params.session;
-		},
-		callback(): string {
-			return this.$route.query.callback;
-		},
-		name(): string {
-			return this.$route.query.name;
-		},
-		icon(): string {
-			return this.$route.query.icon;
-		},
-		permission(): string[] {
-			return this.$route.query.permission ? this.$route.query.permission.split(',') : [];
-		},
-	},
 	methods: {
 		async accept() {
 			this.state = 'waiting';
@@ -84,7 +68,7 @@ export default defineComponent({
 			this.state = 'accepted';
 			if (this.callback) {
 				location.href = appendQuery(this.callback, query({
-					session: this.session
+					session: this.session,
 				}));
 			}
 		},
@@ -93,8 +77,8 @@ export default defineComponent({
 		},
 		onLogin(res) {
 			login(res.i);
-		}
-	}
+		},
+	},
 });
 </script>
 
diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue
index a08bece731..e792834a89 100644
--- a/packages/client/src/pages/my-antennas/create.vue
+++ b/packages/client/src/pages/my-antennas/create.vue
@@ -5,11 +5,13 @@
 </template>
 
 <script lang="ts" setup>
-import { } from 'vue';
+import { inject } from 'vue';
 import XAntenna from './editor.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { useRouter } from '@/router';
+
+const router = useRouter();
 
 let draft = $ref({
 	name: '',
@@ -22,19 +24,21 @@ let draft = $ref({
 	withReplies: false,
 	caseSensitive: false,
 	withFile: false,
-	notify: false
+	notify: false,
 });
 
 function onAntennaCreated() {
 	router.push('/my/antennas');
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.manageAntennas,
-		icon: 'fas fa-satellite',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'fas fa-satellite',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue
index 38e56ce35d..53f9b07db0 100644
--- a/packages/client/src/pages/my-antennas/edit.vue
+++ b/packages/client/src/pages/my-antennas/edit.vue
@@ -5,14 +5,14 @@
 </template>
 
 <script lang="ts" setup>
-import { watch } from 'vue';
+import { inject, watch } from 'vue';
 import XAntenna from './editor.vue';
-import * as symbols from '@/symbols';
 import * as os from '@/os';
-import { MisskeyNavigator } from '@/scripts/navigate';
 import { i18n } from '@/i18n';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-const nav = new MisskeyNavigator();
+const router = useRouter();
 
 let antenna: any = $ref(null);
 
@@ -21,18 +21,20 @@ const props = defineProps<{
 }>();
 
 function onAntennaUpdated() {
-	nav.push('/my/antennas');
+	router.push('/my/antennas');
 }
 
 os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => {
 	antenna = antennaResponse;
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.manageAntennas,
-		icon: 'fas fa-satellite',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'fas fa-satellite',
 });
 </script>
 
diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue
index a568f64c52..2cdb26031f 100644
--- a/packages/client/src/pages/my-antennas/index.vue
+++ b/packages/client/src/pages/my-antennas/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="ieepwinx">
 		<MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="fas fa-plus"></i> {{ i18n.ts.add }}</MkButton>
 
@@ -11,27 +12,29 @@
 			</MkPagination>
 		</div>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'antennas/list' as const,
 	limit: 10,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.manageAntennas,
-		icon: 'fas fa-satellite',
-		bg: 'var(--bg)'
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'fas fa-satellite',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue
index e287357a42..6434b0c004 100644
--- a/packages/client/src/pages/my-clips/index.vue
+++ b/packages/client/src/pages/my-clips/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="qtcaoidl">
 		<MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
 
@@ -10,7 +11,7 @@
 			</MkA>
 		</MkPagination>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -18,8 +19,8 @@ import { } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'clips/list' as const,
@@ -61,15 +62,17 @@ function onClipDeleted() {
 	pagingComponent.reload();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.clip,
-		icon: 'fas fa-paperclip',
-		bg: 'var(--bg)',
-		action: {
-			icon: 'fas fa-plus',
-			handler: create
-		},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.clip,
+	icon: 'fas fa-paperclip',
+	bg: 'var(--bg)',
+	action: {
+		icon: 'fas fa-plus',
+		handler: create,
 	},
 });
 </script>
diff --git a/packages/client/src/pages/my-groups/group.vue b/packages/client/src/pages/my-groups/group.vue
deleted file mode 100644
index 92c0483af9..0000000000
--- a/packages/client/src/pages/my-groups/group.vue
+++ /dev/null
@@ -1,178 +0,0 @@
-<template>
-<div class="mk-group-page">
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<div v-if="group" class="_section">
-			<div class="_content" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-				<MkButton inline @click="invite()">{{ $ts.invite }}</MkButton>
-				<MkButton inline @click="renameGroup()">{{ $ts.rename }}</MkButton>
-				<MkButton inline @click="transfer()">{{ $ts.transfer }}</MkButton>
-				<MkButton inline @click="deleteGroup()">{{ $ts.delete }}</MkButton>
-			</div>
-		</div>
-	</transition>
-
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<div v-if="group" class="_section members _gap">
-			<div class="_title">{{ $ts.members }}</div>
-			<div class="_content">
-				<div class="users">
-					<div v-for="user in users" :key="user.id" class="user _panel">
-						<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
-						<div class="body">
-							<MkUserName :user="user" class="name"/>
-							<MkAcct :user="user" class="acct"/>
-						</div>
-						<div class="action">
-							<button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button>
-						</div>
-					</div>
-				</div>
-			</div>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		MkButton
-	},
-
-	props: {
-		groupId: {
-			type: String,
-			required: true,
-		},
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.group ? {
-				title: this.group.name,
-				icon: 'fas fa-users',
-			} : null),
-			group: null,
-			users: [],
-		};
-	},
-
-	watch: {
-		groupId: 'fetch',
-	},
-
-	created() {
-		this.fetch();
-	},
-
-	methods: {
-		fetch() {
-			os.api('users/groups/show', {
-				groupId: this.groupId
-			}).then(group => {
-				this.group = group;
-				os.api('users/show', {
-					userIds: this.group.userIds
-				}).then(users => {
-					this.users = users;
-				});
-			});
-		},
-
-		invite() {
-			os.selectUser().then(user => {
-				os.apiWithDialog('users/groups/invite', {
-					groupId: this.group.id,
-					userId: user.id
-				});
-			});
-		},
-
-		removeUser(user) {
-			os.api('users/groups/pull', {
-				groupId: this.group.id,
-				userId: user.id
-			}).then(() => {
-				this.users = this.users.filter(x => x.id !== user.id);
-			});
-		},
-
-		async renameGroup() {
-			const { canceled, result: name } = await os.inputText({
-				title: this.$ts.groupName,
-				default: this.group.name
-			});
-			if (canceled) return;
-
-			await os.api('users/groups/update', {
-				groupId: this.group.id,
-				name: name
-			});
-
-			this.group.name = name;
-		},
-
-		transfer() {
-			os.selectUser().then(user => {
-				os.apiWithDialog('users/groups/transfer', {
-					groupId: this.group.id,
-					userId: user.id
-				});
-			});
-		},
-
-		async deleteGroup() {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: this.$t('removeAreYouSure', { x: this.group.name }),
-			});
-			if (canceled) return;
-
-			await os.apiWithDialog('users/groups/delete', {
-				groupId: this.group.id
-			});
-			this.$router.push('/my/groups');
-		}
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-group-page {
-	> .members {
-		> ._content {
-			> .users {
-				> .user {
-					display: flex;
-					align-items: center;
-					padding: 16px;
-
-					> .avatar {
-						width: 50px;
-						height: 50px;
-					}
-
-					> .body {
-						flex: 1;
-						padding: 8px;
-
-						> .name {
-							display: block;
-							font-weight: bold;
-						}
-
-						> .acct {
-							opacity: 0.5;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/my-groups/index.vue b/packages/client/src/pages/my-groups/index.vue
deleted file mode 100644
index 4b2b2963a8..0000000000
--- a/packages/client/src/pages/my-groups/index.vue
+++ /dev/null
@@ -1,147 +0,0 @@
-<template>
-<MkSpacer :content-max="700">
-	<div v-if="tab === 'owned'" class="_content">
-		<MkButton primary style="margin: 0 auto var(--margin) auto;" @click="create"><i class="fas fa-plus"></i> {{ $ts.createGroup }}</MkButton>
-
-		<MkPagination v-slot="{items}" ref="owned" :pagination="ownedPagination">
-			<div v-for="group in items" :key="group.id" class="_card">
-				<div class="_title"><MkA :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</MkA></div>
-				<div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
-			</div>
-		</MkPagination>
-	</div>
-
-	<div v-else-if="tab === 'joined'" class="_content">
-		<MkPagination v-slot="{items}" ref="joined" :pagination="joinedPagination">
-			<div v-for="group in items" :key="group.id" class="_card">
-				<div class="_title">{{ group.name }}</div>
-				<div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
-				<div class="_footer">
-					<MkButton danger @click="leave(group)">{{ $ts.leaveGroup }}</MkButton>
-				</div>
-			</div>
-		</MkPagination>
-	</div>
-
-	<div v-else-if="tab === 'invites'" class="_content">
-		<MkPagination v-slot="{items}" ref="invitations" :pagination="invitationPagination">
-			<div v-for="invitation in items" :key="invitation.id" class="_card">
-				<div class="_title">{{ invitation.group.name }}</div>
-				<div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div>
-				<div class="_footer">
-					<MkButton primary inline @click="acceptInvite(invitation)"><i class="fas fa-check"></i> {{ $ts.accept }}</MkButton>
-					<MkButton primary inline @click="rejectInvite(invitation)"><i class="fas fa-ban"></i> {{ $ts.reject }}</MkButton>
-				</div>
-			</div>
-		</MkPagination>
-	</div>
-</MkSpacer>
-</template>
-
-<script lang="ts">
-import { defineComponent, computed } from 'vue';
-import MkPagination from '@/components/ui/pagination.vue';
-import MkButton from '@/components/ui/button.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkAvatars from '@/components/avatars.vue';
-import MkTab from '@/components/tab.vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		MkPagination,
-		MkButton,
-		MkContainer,
-		MkTab,
-		MkAvatars,
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.$ts.groups,
-				icon: 'fas fa-users',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-plus',
-					text: this.$ts.createGroup,
-					handler: this.create,
-				}],
-				tabs: [{
-					active: this.tab === 'owned',
-					title: this.$ts.ownedGroups,
-					icon: 'fas fa-user-tie',
-					onClick: () => { this.tab = 'owned'; },
-				}, {
-					active: this.tab === 'joined',
-					title: this.$ts.joinedGroups,
-					icon: 'fas fa-id-badge',
-					onClick: () => { this.tab = 'joined'; },
-				}, {
-					active: this.tab === 'invites',
-					title: this.$ts.invites,
-					icon: 'fas fa-envelope-open-text',
-					onClick: () => { this.tab = 'invites'; },
-				},]
-			})),
-			tab: 'owned',
-			ownedPagination: {
-				endpoint: 'users/groups/owned' as const,
-				limit: 10,
-			},
-			joinedPagination: {
-				endpoint: 'users/groups/joined' as const,
-				limit: 10,
-			},
-			invitationPagination: {
-				endpoint: 'i/user-group-invites' as const,
-				limit: 10,
-			},
-		};
-	},
-
-	methods: {
-		async create() {
-			const { canceled, result: name } = await os.inputText({
-				title: this.$ts.groupName,
-			});
-			if (canceled) return;
-			await os.api('users/groups/create', { name: name });
-			this.$refs.owned.reload();
-			os.success();
-		},
-		acceptInvite(invitation) {
-			os.api('users/groups/invitations/accept', {
-				invitationId: invitation.id
-			}).then(() => {
-				os.success();
-				this.$refs.invitations.reload();
-				this.$refs.joined.reload();
-			});
-		},
-		rejectInvite(invitation) {
-			os.api('users/groups/invitations/reject', {
-				invitationId: invitation.id
-			}).then(() => {
-				this.$refs.invitations.reload();
-			});
-		},
-		async leave(group) {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: this.$t('leaveGroupConfirm', { name: group.name }),
-			});
-			if (canceled) return;
-			os.apiWithDialog('users/groups/leave', {
-				groupId: group.id,
-			}).then(() => {
-				this.$refs.joined.reload();
-			});
-		}
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue
index 9ed9e2960e..411826a950 100644
--- a/packages/client/src/pages/my-lists/index.vue
+++ b/packages/client/src/pages/my-lists/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="qkcjvfiv">
 		<MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton>
 
@@ -10,7 +11,7 @@
 			</MkA>
 		</MkPagination>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -19,8 +20,8 @@ import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkAvatars from '@/components/avatars.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
 
@@ -38,15 +39,17 @@ async function create() {
 	pagingComponent.reload();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.manageLists,
-		icon: 'fas fa-list-ul',
-		bg: 'var(--bg)',
-		action: {
-			icon: 'fas fa-plus',
-			handler: create,
-		},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageLists,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
+	action: {
+		icon: 'fas fa-plus',
+		handler: create,
 	},
 });
 </script>
diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue
index bc24f58431..6e76c4a7d5 100644
--- a/packages/client/src/pages/my-lists/list.vue
+++ b/packages/client/src/pages/my-lists/list.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="mk-list-page">
 		<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
 			<div v-if="list" class="_section">
@@ -31,104 +32,96 @@
 			</div>
 		</transition>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		MkButton
-	},
+const props = defineProps<{
+	listId: string;
+}>();
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.list ? {
-				title: this.list.name,
-				icon: 'fas fa-list-ul',
-				bg: 'var(--bg)',
-			} : null),
-			list: null,
-			users: [],
-		};
-	},
+let list = $ref(null);
+let users = $ref([]);
 
-	watch: {
-		$route: 'fetch'
-	},
+function fetchList() {
+	os.api('users/lists/show', {
+		listId: props.listId,
+	}).then(_list => {
+		list = _list;
+		os.api('users/show', {
+			userIds: list.userIds,
+		}).then(_users => {
+			users = _users;
+		});
+	});
+}
 
-	created() {
-		this.fetch();
-	},
+function addUser() {
+	os.selectUser().then(user => {
+		os.apiWithDialog('users/lists/push', {
+			listId: list.id,
+			userId: user.id,
+		}).then(() => {
+			users.push(user);
+		});
+	});
+}
 
-	methods: {
-		fetch() {
-			os.api('users/lists/show', {
-				listId: this.$route.params.list
-			}).then(list => {
-				this.list = list;
-				os.api('users/show', {
-					userIds: this.list.userIds
-				}).then(users => {
-					this.users = users;
-				});
-			});
-		},
+function removeUser(user) {
+	os.api('users/lists/pull', {
+		listId: list.id,
+		userId: user.id,
+	}).then(() => {
+		users = users.filter(x => x.id !== user.id);
+	});
+}
 
-		addUser() {
-			os.selectUser().then(user => {
-				os.apiWithDialog('users/lists/push', {
-					listId: this.list.id,
-					userId: user.id
-				}).then(() => {
-					this.users.push(user);
-				});
-			});
-		},
+async function renameList() {
+	const { canceled, result: name } = await os.inputText({
+		title: i18n.ts.enterListName,
+		default: list.name,
+	});
+	if (canceled) return;
 
-		removeUser(user) {
-			os.api('users/lists/pull', {
-				listId: this.list.id,
-				userId: user.id
-			}).then(() => {
-				this.users = this.users.filter(x => x.id !== user.id);
-			});
-		},
+	await os.api('users/lists/update', {
+		listId: list.id,
+		name: name,
+	});
 
-		async renameList() {
-			const { canceled, result: name } = await os.inputText({
-				title: this.$ts.enterListName,
-				default: this.list.name
-			});
-			if (canceled) return;
+	list.name = name;
+}
 
-			await os.api('users/lists/update', {
-				listId: this.list.id,
-				name: name
-			});
+async function deleteList() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: list.name }),
+	});
+	if (canceled) return;
 
-			this.list.name = name;
-		},
+	await os.api('users/lists/delete', {
+		listId: list.id,
+	});
+	os.success();
+	mainRouter.push('/my/lists');
+}
 
-		async deleteList() {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: this.$t('removeAreYouSure', { x: this.list.name }),
-			});
-			if (canceled) return;
+watch(() => props.listId, fetchList, { immediate: true });
 
-			await os.api('users/lists/delete', {
-				listId: this.list.id
-			});
-			os.success();
-			this.$router.push('/my/lists');
-		}
-	}
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+	title: list.name,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue
index cdeb54b88b..955fbbccfd 100644
--- a/packages/client/src/pages/not-found.vue
+++ b/packages/client/src/pages/not-found.vue
@@ -8,14 +8,16 @@
 </template>
 
 <script lang="ts" setup>
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.notFound,
-		icon: 'fas fa-exclamation-triangle',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.notFound,
+	icon: 'fas fa-exclamation-triangle',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue
index f0a18ecc36..852b821573 100644
--- a/packages/client/src/pages/note.vue
+++ b/packages/client/src/pages/note.vue
@@ -1,10 +1,11 @@
-<template>
-<MkSpacer :content-max="800">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800">
 	<div class="fcuexfpr">
 		<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
 			<div v-if="note" class="note">
 				<div v-if="showNext" class="_gap">
-					<XNotes class="_content" :pagination="next" :no-gap="true"/>
+					<XNotes class="_content" :pagination="nextPagination" :no-gap="true"/>
 				</div>
 
 				<div class="main _gap">
@@ -27,121 +28,112 @@
 				</div>
 
 				<div v-if="showPrev" class="_gap">
-					<XNotes class="_content" :pagination="prev" :no-gap="true"/>
+					<XNotes class="_content" :pagination="prevPagination" :no-gap="true"/>
 				</div>
 			</div>
 			<MkError v-else-if="error" @retry="fetch()"/>
 			<MkLoading v-else/>
 		</transition>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
+import * as misskey from 'misskey-js';
 import XNote from '@/components/note.vue';
 import XNoteDetailed from '@/components/note-detailed.vue';
 import XNotes from '@/components/notes.vue';
 import MkRemoteCaution from '@/components/remote-caution.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		XNote,
-		XNoteDetailed,
-		XNotes,
-		MkRemoteCaution,
-		MkButton,
-	},
-	props: {
-		noteId: {
-			type: String,
-			required: true
-		}
-	},
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.note ? {
-				title: this.$ts.note,
-				subtitle: new Date(this.note.createdAt).toLocaleString(),
-				avatar: this.note.user,
-				path: `/notes/${this.note.id}`,
-				share: {
-					title: this.$t('noteOf', { user: this.note.user.name }),
-					text: this.note.text,
-				},
-				bg: 'var(--bg)',
-			} : null),
-			note: null,
-			clips: null,
-			hasPrev: false,
-			hasNext: false,
-			showPrev: false,
-			showNext: false,
-			error: null,
-			prev: {
-				endpoint: 'users/notes' as const,
-				limit: 10,
-				params: computed(() => ({
-					userId: this.note.userId,
-					untilId: this.note.id,
-				})),
-			},
-			next: {
-				reversed: true,
-				endpoint: 'users/notes' as const,
-				limit: 10,
-				params: computed(() => ({
-					userId: this.note.userId,
-					sinceId: this.note.id,
-				})),
-			},
-		};
-	},
-	watch: {
-		noteId: 'fetch'
-	},
-	created() {
-		this.fetch();
-	},
-	methods: {
-		fetch() {
-			this.hasPrev = false;
-			this.hasNext = false;
-			this.showPrev = false;
-			this.showNext = false;
-			this.note = null;
-			os.api('notes/show', {
-				noteId: this.noteId
-			}).then(note => {
-				this.note = note;
-				Promise.all([
-					os.api('notes/clips', {
-						noteId: note.id,
-					}),
-					os.api('users/notes', {
-						userId: note.userId,
-						untilId: note.id,
-						limit: 1,
-					}),
-					os.api('users/notes', {
-						userId: note.userId,
-						sinceId: note.id,
-						limit: 1,
-					}),
-				]).then(([clips, prev, next]) => {
-					this.clips = clips;
-					this.hasPrev = prev.length !== 0;
-					this.hasNext = next.length !== 0;
-				});
-			}).catch(err => {
-				this.error = err;
-			});
-		}
-	}
+const props = defineProps<{
+	noteId: string;
+}>();
+
+let note = $ref<null | misskey.entities.Note>();
+let clips = $ref();
+let hasPrev = $ref(false);
+let hasNext = $ref(false);
+let showPrev = $ref(false);
+let showNext = $ref(false);
+let error = $ref();
+
+const prevPagination = {
+	endpoint: 'users/notes' as const,
+	limit: 10,
+	params: computed(() => note ? ({
+		userId: note.userId,
+		untilId: note.id,
+	}) : null),
+};
+
+const nextPagination = {
+	reversed: true,
+	endpoint: 'users/notes' as const,
+	limit: 10,
+	params: computed(() => note ? ({
+		userId: note.userId,
+		sinceId: note.id,
+	}) : null),
+};
+
+function fetchNote() {
+	hasPrev = false;
+	hasNext = false;
+	showPrev = false;
+	showNext = false;
+	note = null;
+	os.api('notes/show', {
+		noteId: props.noteId,
+	}).then(res => {
+		note = res;
+		Promise.all([
+			os.api('notes/clips', {
+				noteId: note.id,
+			}),
+			os.api('users/notes', {
+				userId: note.userId,
+				untilId: note.id,
+				limit: 1,
+			}),
+			os.api('users/notes', {
+				userId: note.userId,
+				sinceId: note.id,
+				limit: 1,
+			}),
+		]).then(([_clips, prev, next]) => {
+			clips = _clips;
+			hasPrev = prev.length !== 0;
+			hasNext = next.length !== 0;
+		});
+	}).catch(err => {
+		error = err;
+	});
+}
+
+watch(() => props.noteId, fetchNote, {
+	immediate: true,
 });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => note ? {
+	title: i18n.ts.note,
+	subtitle: new Date(note.createdAt).toLocaleString(),
+	avatar: note.user,
+	path: `/notes/${note.id}`,
+	share: {
+		title: i18n.t('noteOf', { user: note.user.name }),
+		text: note.text,
+	},
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue
index 36e423e534..3d1014b3cd 100644
--- a/packages/client/src/pages/notifications.vue
+++ b/packages/client/src/pages/notifications.vue
@@ -1,18 +1,21 @@
 <template>
-<MkSpacer :content-max="800">
-	<div class="clupoqwt">
-		<XNotifications class="notifications" :include-types="includeTypes" :unread-only="tab === 'unread'"/>
-	</div>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div class="clupoqwt">
+			<XNotifications class="notifications" :include-types="includeTypes" :unread-only="tab === 'unread'"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { computed } from 'vue';
+import { notificationTypes } from 'misskey-js';
 import XNotifications from '@/components/notifications.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { notificationTypes } from 'misskey-js';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let tab = $ref('all');
 let includeTypes = $ref<string[] | null>(null);
@@ -23,46 +26,46 @@ function setFilter(ev) {
 		active: includeTypes && includeTypes.includes(t),
 		action: () => {
 			includeTypes = [t];
-		}
+		},
 	}));
 	const items = includeTypes != null ? [{
 		icon: 'fas fa-times',
 		text: i18n.ts.clear,
 		action: () => {
 			includeTypes = null;
-		}
+		},
 	}, null, ...typeItems] : typeItems;
 	os.popupMenu(items, ev.currentTarget ?? ev.target);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.notifications,
-		icon: 'fas fa-bell',
-		bg: 'var(--bg)',
-		actions: [{
-			text: i18n.ts.filter,
-			icon: 'fas fa-filter',
-			highlighted: includeTypes != null,
-			handler: setFilter,
-		}, {
-			text: i18n.ts.markAllAsRead,
-			icon: 'fas fa-check',
-			handler: () => {
-				os.apiWithDialog('notifications/mark-all-as-read');
-			},
-		}],
-		tabs: [{
-			active: tab === 'all',
-			title: i18n.ts.all,
-			onClick: () => { tab = 'all'; },
-		}, {
-			active: tab === 'unread',
-			title: i18n.ts.unread,
-			onClick: () => { tab = 'unread'; },
-		},]
-	})),
-});
+const headerActions = $computed(() => [{
+	text: i18n.ts.filter,
+	icon: 'fas fa-filter',
+	highlighted: includeTypes != null,
+	handler: setFilter,
+}, {
+	text: i18n.ts.markAllAsRead,
+	icon: 'fas fa-check',
+	handler: () => {
+		os.apiWithDialog('notifications/mark-all-as-read');
+	},
+}]);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'all',
+	title: i18n.ts.all,
+	onClick: () => { tab = 'all'; },
+}, {
+	active: tab === 'unread',
+	title: i18n.ts.unread,
+	onClick: () => { tab = 'unread'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.notifications,
+	icon: 'fas fa-bell',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue
index 9566592618..97c278e5e8 100644
--- a/packages/client/src/pages/page-editor/page-editor.vue
+++ b/packages/client/src/pages/page-editor/page-editor.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="jqqmcavi">
 		<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton>
 		<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
@@ -55,7 +56,7 @@
 			<XDraggable v-show="variables.length > 0" v-model="variables" tag="div" class="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5">
 				<template #item="{element}">
 					<XVariable
-						:modelValue="element"
+						:model-value="element"
 						:removable="true"
 						:hpml="hpml"
 						:name="element.name"
@@ -75,11 +76,11 @@
 			<MkTextarea v-model="script" class="_code"/>
 		</div>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { defineComponent, defineAsyncComponent, computed, provide, watch } from 'vue';
 import 'prismjs';
 import { highlight, languages } from 'prismjs/components/prism-core';
 import 'prismjs/components/prism-clike';
@@ -101,367 +102,349 @@ import { url } from '@/config';
 import { collectPageVars } from '@/scripts/collect-page-vars';
 import * as os from '@/os';
 import { selectFile } from '@/scripts/select-file';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+const XDraggable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
 
-export default defineComponent({
-	components: {
-		XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
-		XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput,
-	},
+const props = defineProps<{
+	initPageId?: string;
+	initPageName?: string;
+	initUser?: string;
+}>();
 
-	provide() {
-		return {
-			readonly: this.readonly,
-			getScriptBlockList: this.getScriptBlockList,
-			getPageBlockList: this.getPageBlockList
-		};
-	},
+let tab = $ref('settings');
+let author = $ref($i);
+let readonly = $ref(false);
+let page = $ref(null);
+let pageId = $ref(null);
+let currentName = $ref(null);
+let title = $ref('');
+let summary = $ref(null);
+let name = $ref(Date.now().toString());
+let eyeCatchingImage = $ref(null);
+let eyeCatchingImageId = $ref(null);
+let font = $ref('sans-serif');
+let content = $ref([]);
+let alignCenter = $ref(false);
+let hideTitleWhenPinned = $ref(false);
+let variables = $ref([]);
+let hpml = $ref(null);
+let script = $ref('');
 
-	props: {
-		initPageId: {
-			type: String,
-			required: false
-		},
-		initPageName: {
-			type: String,
-			required: false
-		},
-		initUser: {
-			type: String,
-			required: false
-		},
-	},
+provide('readonly', readonly);
+provide('getScriptBlockList', getScriptBlockList);
+provide('getPageBlockList', getPageBlockList);
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => {
-				let title = this.$ts._pages.newPage;
-				if (this.initPageId) {
-					title = this.$ts._pages.editPage;
-				}
-				else if (this.initPageName && this.initUser) {
-					title = this.$ts._pages.readPage;
-				}
-				return {
-					title: title,
-					icon: 'fas fa-pencil-alt',
-					bg: 'var(--bg)',
-					tabs: [{
-						active: this.tab === 'settings',
-						title: this.$ts._pages.pageSetting,
-						icon: 'fas fa-cog',
-						onClick: () => { this.tab = 'settings'; },
-					}, {
-						active: this.tab === 'contents',
-						title: this.$ts._pages.contents,
-						icon: 'fas fa-sticky-note',
-						onClick: () => { this.tab = 'contents'; },
-					}, {
-						active: this.tab === 'variables',
-						title: this.$ts._pages.variables,
-						icon: 'fas fa-magic',
-						onClick: () => { this.tab = 'variables'; },
-					}, {
-						active: this.tab === 'script',
-						title: this.$ts.script,
-						icon: 'fas fa-code',
-						onClick: () => { this.tab = 'script'; },
-					}],
-				};
-			}),
-			tab: 'settings',
-			author: this.$i,
-			readonly: false,
-			page: null,
-			pageId: null,
-			currentName: null,
-			title: '',
-			summary: null,
-			name: Date.now().toString(),
-			eyeCatchingImage: null,
-			eyeCatchingImageId: null,
-			font: 'sans-serif',
-			content: [],
-			alignCenter: false,
-			hideTitleWhenPinned: false,
-			variables: [],
-			hpml: null,
-			script: '',
-			url,
-		};
-	},
-
-	watch: {
-		async eyeCatchingImageId() {
-			if (this.eyeCatchingImageId == null) {
-				this.eyeCatchingImage = null;
-			} else {
-				this.eyeCatchingImage = await os.api('drive/files/show', {
-					fileId: this.eyeCatchingImageId,
-				});
-			}
-		},
-	},
-
-	async created() {
-		this.hpml = new HpmlTypeChecker();
-
-		this.$watch('variables', () => {
-			this.hpml.variables = this.variables;
-		}, { deep: true });
-
-		this.$watch('content', () => {
-			this.hpml.pageVars = collectPageVars(this.content);
-		}, { deep: true });
-
-		if (this.initPageId) {
-			this.page = await os.api('pages/show', {
-				pageId: this.initPageId,
-			});
-		} else if (this.initPageName && this.initUser) {
-			this.page = await os.api('pages/show', {
-				name: this.initPageName,
-				username: this.initUser,
-			});
-			this.readonly = true;
-		}
-
-		if (this.page) {
-			this.author = this.page.user;
-			this.pageId = this.page.id;
-			this.title = this.page.title;
-			this.name = this.page.name;
-			this.currentName = this.page.name;
-			this.summary = this.page.summary;
-			this.font = this.page.font;
-			this.script = this.page.script;
-			this.hideTitleWhenPinned = this.page.hideTitleWhenPinned;
-			this.alignCenter = this.page.alignCenter;
-			this.content = this.page.content;
-			this.variables = this.page.variables;
-			this.eyeCatchingImageId = this.page.eyeCatchingImageId;
-		} else {
-			const id = uuid();
-			this.content = [{
-				id,
-				type: 'text',
-				text: 'Hello World!'
-			}];
-		}
-	},
-
-	methods: {
-		getSaveOptions() {
-			return {
-				title: this.title.trim(),
-				name: this.name.trim(),
-				summary: this.summary,
-				font: this.font,
-				script: this.script,
-				hideTitleWhenPinned: this.hideTitleWhenPinned,
-				alignCenter: this.alignCenter,
-				content: this.content,
-				variables: this.variables,
-				eyeCatchingImageId: this.eyeCatchingImageId,
-			};
-		},
-
-		save() {
-			const options = this.getSaveOptions();
-
-			const onError = err => {
-				if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
-					if (err.info.param == 'name') {
-						os.alert({
-							type: 'error',
-							title: this.$ts._pages.invalidNameTitle,
-							text: this.$ts._pages.invalidNameText
-						});
-					}
-				} else if (err.code == 'NAME_ALREADY_EXISTS') {
-					os.alert({
-						type: 'error',
-						text: this.$ts._pages.nameAlreadyExists
-					});
-				}
-			};
-
-			if (this.pageId) {
-				options.pageId = this.pageId;
-				os.api('pages/update', options)
-				.then(page => {
-					this.currentName = this.name.trim();
-					os.alert({
-						type: 'success',
-						text: this.$ts._pages.updated
-					});
-				}).catch(onError);
-			} else {
-				os.api('pages/create', options)
-				.then(page => {
-					this.pageId = page.id;
-					this.currentName = this.name.trim();
-					os.alert({
-						type: 'success',
-						text: this.$ts._pages.created
-					});
-					this.$router.push(`/pages/edit/${this.pageId}`);
-				}).catch(onError);
-			}
-		},
-
-		del() {
-			os.confirm({
-				type: 'warning',
-				text: this.$t('removeAreYouSure', { x: this.title.trim() }),
-			}).then(({ canceled }) => {
-				if (canceled) return;
-				os.api('pages/delete', {
-					pageId: this.pageId,
-				}).then(() => {
-					os.alert({
-						type: 'success',
-						text: this.$ts._pages.deleted
-					});
-					this.$router.push(`/pages`);
-				});
-			});
-		},
-
-		duplicate() {
-			this.title = this.title + ' - copy';
-			this.name = this.name + '-copy';
-			os.api('pages/create', this.getSaveOptions()).then(page => {
-				this.pageId = page.id;
-				this.currentName = this.name.trim();
-				os.alert({
-					type: 'success',
-					text: this.$ts._pages.created
-				});
-				this.$router.push(`/pages/edit/${this.pageId}`);
-			});
-		},
-
-		async add() {
-			const { canceled, result: type } = await os.select({
-				type: null,
-				title: this.$ts._pages.chooseBlock,
-				groupedItems: this.getPageBlockList()
-			});
-			if (canceled) return;
-
-			const id = uuid();
-			this.content.push({ id, type });
-		},
-
-		async addVariable() {
-			let { canceled, result: name } = await os.inputText({
-				title: this.$ts._pages.enterVariableName,
-			});
-			if (canceled) return;
-
-			name = name.trim();
-
-			if (this.hpml.isUsedName(name)) {
-				os.alert({
-					type: 'error',
-					text: this.$ts._pages.variableNameIsAlreadyUsed
-				});
-				return;
-			}
-
-			const id = uuid();
-			this.variables.push({ id, name, type: null });
-		},
-
-		removeVariable(v) {
-			this.variables = this.variables.filter(x => x.name !== v.name);
-		},
-
-		getPageBlockList() {
-			return [{
-				label: this.$ts._pages.contentBlocks,
-				items: [
-					{ value: 'section', text: this.$ts._pages.blocks.section },
-					{ value: 'text', text: this.$ts._pages.blocks.text },
-					{ value: 'image', text: this.$ts._pages.blocks.image },
-					{ value: 'textarea', text: this.$ts._pages.blocks.textarea },
-					{ value: 'note', text: this.$ts._pages.blocks.note },
-					{ value: 'canvas', text: this.$ts._pages.blocks.canvas },
-				]
-			}, {
-				label: this.$ts._pages.inputBlocks,
-				items: [
-					{ value: 'button', text: this.$ts._pages.blocks.button },
-					{ value: 'radioButton', text: this.$ts._pages.blocks.radioButton },
-					{ value: 'textInput', text: this.$ts._pages.blocks.textInput },
-					{ value: 'textareaInput', text: this.$ts._pages.blocks.textareaInput },
-					{ value: 'numberInput', text: this.$ts._pages.blocks.numberInput },
-					{ value: 'switch', text: this.$ts._pages.blocks.switch },
-					{ value: 'counter', text: this.$ts._pages.blocks.counter }
-				]
-			}, {
-				label: this.$ts._pages.specialBlocks,
-				items: [
-					{ value: 'if', text: this.$ts._pages.blocks.if },
-					{ value: 'post', text: this.$ts._pages.blocks.post }
-				]
-			}];
-		},
-
-		getScriptBlockList(type: string = null) {
-			const list = [];
-
-			const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
-
-			for (const block of blocks) {
-				const category = list.find(x => x.category === block.category);
-				if (category) {
-					category.items.push({
-						value: block.type,
-						text: this.$t(`_pages.script.blocks.${block.type}`)
-					});
-				} else {
-					list.push({
-						category: block.category,
-						label: this.$t(`_pages.script.categories.${block.category}`),
-						items: [{
-							value: block.type,
-							text: this.$t(`_pages.script.blocks.${block.type}`)
-						}]
-					});
-				}
-			}
-
-			const userFns = this.variables.filter(x => x.type === 'fn');
-			if (userFns.length > 0) {
-				list.unshift({
-					label: this.$t(`_pages.script.categories.fn`),
-					items: userFns.map(v => ({
-						value: 'fn:' + v.name,
-						text: v.name
-					}))
-				});
-			}
-
-			return list;
-		},
-
-		setEyeCatchingImage(e) {
-			selectFile(e.currentTarget ?? e.target, null).then(file => {
-				this.eyeCatchingImageId = file.id;
-			});
-		},
-
-		removeEyeCatchingImage() {
-			this.eyeCatchingImageId = null;
-		},
-
-		highlighter(code) {
-			return highlight(code, languages.js, 'javascript');
-		},
+watch($$(eyeCatchingImageId), async () => {
+	if (eyeCatchingImageId == null) {
+		eyeCatchingImage = null;
+	} else {
+		eyeCatchingImage = await os.api('drive/files/show', {
+			fileId: eyeCatchingImageId,
+		});
 	}
 });
+
+function getSaveOptions() {
+	return {
+		title: tatitle.trim(),
+		name: taname.trim(),
+		summary: tasummary,
+		font: tafont,
+		script: tascript,
+		hideTitleWhenPinned: tahideTitleWhenPinned,
+		alignCenter: taalignCenter,
+		content: tacontent,
+		variables: tavariables,
+		eyeCatchingImageId: taeyeCatchingImageId,
+	};
+}
+
+function save() {
+	const options = tagetSaveOptions();
+
+	const onError = err => {
+		if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
+			if (err.info.param == 'name') {
+				os.alert({
+					type: 'error',
+					title: i18n.ts._pages.invalidNameTitle,
+					text: i18n.ts._pages.invalidNameText,
+				});
+			}
+		} else if (err.code == 'NAME_ALREADY_EXISTS') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts._pages.nameAlreadyExists,
+			});
+		}
+	};
+
+	if (tapageId) {
+		options.pageId = tapageId;
+		os.api('pages/update', options)
+		.then(page => {
+			tacurrentName = taname.trim();
+			os.alert({
+				type: 'success',
+				text: i18n.ts._pages.updated,
+			});
+		}).catch(onError);
+	} else {
+		os.api('pages/create', options)
+		.then(created => {
+			tapageId = created.id;
+			tacurrentName = name.trim();
+			os.alert({
+				type: 'success',
+				text: i18n.ts._pages.created,
+			});
+			mainRouter.push(`/pages/edit/${pageId}`);
+		}).catch(onError);
+	}
+}
+
+function del() {
+	os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: title.trim() }),
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		os.api('pages/delete', {
+			pageId: pageId,
+		}).then(() => {
+			os.alert({
+				type: 'success',
+				text: i18n.ts._pages.deleted,
+			});
+			mainRouter.push('/pages');
+		});
+	});
+}
+
+function duplicate() {
+	tatitle = tatitle + ' - copy';
+	taname = taname + '-copy';
+	os.api('pages/create', tagetSaveOptions()).then(created => {
+		tapageId = created.id;
+		tacurrentName = taname.trim();
+		os.alert({
+			type: 'success',
+			text: i18n.ts._pages.created,
+		});
+		mainRouter.push(`/pages/edit/${pageId}`);
+	});
+}
+
+async function add() {
+	const { canceled, result: type } = await os.select({
+		type: null,
+		title: i18n.ts._pages.chooseBlock,
+		groupedItems: tagetPageBlockList(),
+	});
+	if (canceled) return;
+
+	const id = uuid();
+	tacontent.push({ id, type });
+}
+
+async function addVariable() {
+	let { canceled, result: name } = await os.inputText({
+		title: i18n.ts._pages.enterVariableName,
+	});
+	if (canceled) return;
+
+	name = name.trim();
+
+	if (tahpml.isUsedName(name)) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts._pages.variableNameIsAlreadyUsed,
+		});
+		return;
+	}
+
+	const id = uuid();
+	tavariables.push({ id, name, type: null });
+}
+
+function removeVariable(v) {
+	tavariables = tavariables.filter(x => x.name !== v.name);
+}
+
+function getPageBlockList() {
+	return [{
+		label: i18n.ts._pages.contentBlocks,
+		items: [
+			{ value: 'section', text: i18n.ts._pages.blocks.section },
+			{ value: 'text', text: i18n.ts._pages.blocks.text },
+			{ value: 'image', text: i18n.ts._pages.blocks.image },
+			{ value: 'textarea', text: i18n.ts._pages.blocks.textarea },
+			{ value: 'note', text: i18n.ts._pages.blocks.note },
+			{ value: 'canvas', text: i18n.ts._pages.blocks.canvas },
+		],
+	}, {
+		label: i18n.ts._pages.inputBlocks,
+		items: [
+			{ value: 'button', text: i18n.ts._pages.blocks.button },
+			{ value: 'radioButton', text: i18n.ts._pages.blocks.radioButton },
+			{ value: 'textInput', text: i18n.ts._pages.blocks.textInput },
+			{ value: 'textareaInput', text: i18n.ts._pages.blocks.textareaInput },
+			{ value: 'numberInput', text: i18n.ts._pages.blocks.numberInput },
+			{ value: 'switch', text: i18n.ts._pages.blocks.switch },
+			{ value: 'counter', text: i18n.ts._pages.blocks.counter },
+		],
+	}, {
+		label: i18n.ts._pages.specialBlocks,
+		items: [
+			{ value: 'if', text: i18n.ts._pages.blocks.if },
+			{ value: 'post', text: i18n.ts._pages.blocks.post },
+		],
+	}];
+}
+
+function getScriptBlockList(type: string = null) {
+	const list = [];
+
+	const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
+
+	for (const block of blocks) {
+		const category = list.find(x => x.category === block.category);
+		if (category) {
+			category.items.push({
+				value: block.type,
+				text: i18n.t(`_pages.script.blocks.${block.type}`),
+			});
+		} else {
+			list.push({
+				category: block.category,
+				label: i18n.t(`_pages.script.categories.${block.category}`),
+				items: [{
+					value: block.type,
+					text: i18n.t(`_pages.script.blocks.${block.type}`),
+				}],
+			});
+		}
+	}
+
+	const userFns = variables.filter(x => x.type === 'fn');
+	if (userFns.length > 0) {
+		list.unshift({
+			label: i18n.t('_pages.script.categories.fn'),
+			items: userFns.map(v => ({
+				value: 'fn:' + v.name,
+				text: v.name,
+			})),
+		});
+	}
+
+	return list;
+}
+
+function setEyeCatchingImage(e) {
+	selectFile(e.currentTarget ?? e.target, null).then(file => {
+		eyeCatchingImageId = file.id;
+	});
+}
+
+function removeEyeCatchingImage() {
+	taeyeCatchingImageId = null;
+}
+
+function highlighter(code) {
+	return highlight(code, languages.js, 'javascript');
+}
+
+async function init() {
+	hpml = new HpmlTypeChecker();
+
+	watch($$(variables), () => {
+		hpml.variables = variables;
+	}, { deep: true });
+
+	watch($$(content), () => {
+		hpml.pageVars = collectPageVars(content);
+	}, { deep: true });
+
+	if (props.initPageId) {
+		page = await os.api('pages/show', {
+			pageId: props.initPageId,
+		});
+	} else if (props.initPageName && props.initUser) {
+		page = await os.api('pages/show', {
+			name: props.initPageName,
+			username: props.initUser,
+		});
+		readonly = true;
+	}
+
+	if (page) {
+		author = page.user;
+		pageId = page.id;
+		title = page.title;
+		name = page.name;
+		currentName = page.name;
+		summary = page.summary;
+		font = page.font;
+		script = page.script;
+		hideTitleWhenPinned = page.hideTitleWhenPinned;
+		alignCenter = page.alignCenter;
+		content = page.content;
+		variables = page.variables;
+		eyeCatchingImageId = page.eyeCatchingImageId;
+	} else {
+		const id = uuid();
+		content = [{
+			id,
+			type: 'text',
+			text: 'Hello World!',
+		}];
+	}
+}
+
+init();
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => {
+	let title = i18n.ts._pages.newPage;
+	if (props.initPageId) {
+		title = i18n.ts._pages.editPage;
+	}
+	else if (props.initPageName && props.initUser) {
+		title = i18n.ts._pages.readPage;
+	}
+	return {
+		title: title,
+		icon: 'fas fa-pencil-alt',
+		bg: 'var(--bg)',
+		tabs: [{
+			active: tab === 'settings',
+			title: i18n.ts._pages.pageSetting,
+			icon: 'fas fa-cog',
+			onClick: () => { tab = 'settings'; },
+		}, {
+			active: tab === 'contents',
+			title: i18n.ts._pages.contents,
+			icon: 'fas fa-sticky-note',
+			onClick: () => { tab = 'contents'; },
+		}, {
+			active: tab === 'variables',
+			title: i18n.ts._pages.variables,
+			icon: 'fas fa-magic',
+			onClick: () => { tab = 'variables'; },
+		}, {
+			active: tab === 'script',
+			title: i18n.ts.script,
+			icon: 'fas fa-code',
+			onClick: () => { tab = 'script'; },
+		}],
+	};
+}));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue
index 5bca971438..71b404bbf4 100644
--- a/packages/client/src/pages/page.vue
+++ b/packages/client/src/pages/page.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
 		<div v-if="page" :key="page.id" v-size="{ max: [450] }" class="xcukqgmh">
 			<div class="_block main">
@@ -56,138 +57,108 @@
 		<MkError v-else-if="error" @retry="fetch()"/>
 		<MkLoading v-else/>
 	</transition>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
 import XPage from '@/components/page/page.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { url } from '@/config';
 import MkFollowButton from '@/components/follow-button.vue';
 import MkContainer from '@/components/ui/container.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkPagePreview from '@/components/page-preview.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		XPage,
-		MkButton,
-		MkFollowButton,
-		MkContainer,
-		MkPagination,
-		MkPagePreview,
+const props = defineProps<{
+	pageName: string;
+	username: string;
+}>();
+
+let page = $ref(null);
+let error = $ref(null);
+const otherPostsPagination = {
+	endpoint: 'users/pages' as const,
+	limit: 6,
+	params: computed(() => ({
+		userId: page.user.id,
+	})),
+};
+const path = $computed(() => props.username + '/' + props.pageName);
+
+function fetchPage() {
+	page = null;
+	os.api('pages/show', {
+		name: props.pageName,
+		username: props.username,
+	}).then(_page => {
+		page = _page;
+	}).catch(err => {
+		error = err;
+	});
+}
+
+function share() {
+	navigator.share({
+		title: page.title ?? page.name,
+		text: page.summary,
+		url: `${url}/@${page.user.username}/pages/${page.name}`,
+	});
+}
+
+function shareWithNote() {
+	os.post({
+		initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,
+	});
+}
+
+function like() {
+	os.apiWithDialog('pages/like', {
+		pageId: page.id,
+	}).then(() => {
+		page.isLiked = true;
+		page.likedCount++;
+	});
+}
+
+async function unlike() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.unlikeConfirm,
+	});
+	if (confirm.canceled) return;
+	os.apiWithDialog('pages/unlike', {
+		pageId: page.id,
+	}).then(() => {
+		page.isLiked = false;
+		page.likedCount--;
+	});
+}
+
+function pin(pin) {
+	os.apiWithDialog('i/update', {
+		pinnedPageId: pin ? page.id : null,
+	});
+}
+
+watch(() => path, fetchPage, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => page ? {
+	title: computed(() => page.title || page.name),
+	avatar: page.user,
+	path: `/@${page.user.username}/pages/${page.name}`,
+	share: {
+		title: page.title || page.name,
+		text: page.summary,
 	},
-
-	props: {
-		pageName: {
-			type: String,
-			required: true
-		},
-		username: {
-			type: String,
-			required: true
-		},
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.page ? {
-				title: computed(() => this.page.title || this.page.name),
-				avatar: this.page.user,
-				path: `/@${this.page.user.username}/pages/${this.page.name}`,
-				share: {
-					title: this.page.title || this.page.name,
-					text: this.page.summary,
-				},
-			} : null),
-			page: null,
-			error: null,
-			otherPostsPagination: {
-				endpoint: 'users/pages' as const,
-				limit: 6,
-				params: computed(() => ({
-					userId: this.page.user.id
-				})),
-			},
-		};
-	},
-
-	computed: {
-		path(): string {
-			return this.username + '/' + this.pageName;
-		}
-	},
-
-	watch: {
-		path() {
-			this.fetch();
-		}
-	},
-
-	created() {
-		this.fetch();
-	},
-
-	methods: {
-		fetch() {
-			this.page = null;
-			os.api('pages/show', {
-				name: this.pageName,
-				username: this.username,
-			}).then(page => {
-				this.page = page;
-			}).catch(err => {
-				this.error = err;
-			});
-		},
-
-		share() {
-			navigator.share({
-				title: this.page.title || this.page.name,
-				text: this.page.summary,
-				url: `${url}/@${this.page.user.username}/pages/${this.page.name}`
-			});
-		},
-
-		shareWithNote() {
-			os.post({
-				initialText: `${this.page.title || this.page.name} ${url}/@${this.page.user.username}/pages/${this.page.name}`
-			});
-		},
-
-		like() {
-			os.apiWithDialog('pages/like', {
-				pageId: this.page.id,
-			}).then(() => {
-				this.page.isLiked = true;
-				this.page.likedCount++;
-			});
-		},
-
-		async unlike() {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: this.$ts.unlikeConfirm,
-			});
-			if (confirm.canceled) return;
-			os.apiWithDialog('pages/unlike', {
-				pageId: this.page.id,
-			}).then(() => {
-				this.page.isLiked = false;
-				this.page.likedCount--;
-			});
-		},
-
-		pin(pin) {
-			os.apiWithDialog('i/update', {
-				pinnedPageId: pin ? this.page.id : null,
-			});
-		}
-	}
-});
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue
index dcccf7f7c4..541c968ff4 100644
--- a/packages/client/src/pages/pages.vue
+++ b/packages/client/src/pages/pages.vue
@@ -1,86 +1,87 @@
 <template>
-<MkSpacer :content-max="700">
-	<div v-if="tab === 'featured'" class="rknalgpo">
-		<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
-			<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
-		</MkPagination>
-	</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="tab === 'featured'" class="rknalgpo">
+			<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
+				<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+			</MkPagination>
+		</div>
 
-	<div v-else-if="tab === 'my'" class="rknalgpo my">
-		<MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
-		<MkPagination v-slot="{items}" :pagination="myPagesPagination">
-			<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
-		</MkPagination>
-	</div>
+		<div v-else-if="tab === 'my'" class="rknalgpo my">
+			<MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+			<MkPagination v-slot="{items}" :pagination="myPagesPagination">
+				<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+			</MkPagination>
+		</div>
 
-	<div v-else-if="tab === 'liked'" class="rknalgpo">
-		<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
-			<MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
-		</MkPagination>
-	</div>
-</MkSpacer>
+		<div v-else-if="tab === 'liked'" class="rknalgpo">
+			<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
+				<MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject } from 'vue';
 import MkPagePreview from '@/components/page-preview.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		MkPagePreview, MkPagination, MkButton
-	},
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.$ts.pages,
-				icon: 'fas fa-sticky-note',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-plus',
-					text: this.$ts.create,
-					handler: this.create,
-				}],
-				tabs: [{
-					active: this.tab === 'featured',
-					title: this.$ts._pages.featured,
-					icon: 'fas fa-fire-alt',
-					onClick: () => { this.tab = 'featured'; },
-				}, {
-					active: this.tab === 'my',
-					title: this.$ts._pages.my,
-					icon: 'fas fa-edit',
-					onClick: () => { this.tab = 'my'; },
-				}, {
-					active: this.tab === 'liked',
-					title: this.$ts._pages.liked,
-					icon: 'fas fa-heart',
-					onClick: () => { this.tab = 'liked'; },
-				},]
-			})),
-			tab: 'featured',
-			featuredPagesPagination: {
-				endpoint: 'pages/featured' as const,
-				noPaging: true,
-			},
-			myPagesPagination: {
-				endpoint: 'i/pages' as const,
-				limit: 5,
-			},
-			likedPagesPagination: {
-				endpoint: 'i/page-likes' as const,
-				limit: 5,
-			},
-		};
-	},
-	methods: {
-		create() {
-			this.$router.push(`/pages/new`);
-		}
-	}
-});
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredPagesPagination = {
+	endpoint: 'pages/featured' as const,
+	noPaging: true,
+};
+const myPagesPagination = {
+	endpoint: 'i/pages' as const,
+	limit: 5,
+};
+const likedPagesPagination = {
+	endpoint: 'i/page-likes' as const,
+	limit: 5,
+};
+
+function create() {
+	router.push('/pages/new');
+}
+
+const headerActions = $computed(() => [{
+	icon: 'fas fa-plus',
+	text: i18n.ts.create,
+	handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'featured',
+	title: i18n.ts._pages.featured,
+	icon: 'fas fa-fire-alt',
+	onClick: () => { tab = 'featured'; },
+}, {
+	active: tab === 'my',
+	title: i18n.ts._pages.my,
+	icon: 'fas fa-edit',
+	onClick: () => { tab = 'my'; },
+}, {
+	active: tab === 'liked',
+	title: i18n.ts._pages.liked,
+	icon: 'fas fa-heart',
+	onClick: () => { tab = 'liked'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.pages,
+	icon: 'fas fa-sticky-note',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue
index 4accac4192..cba7589a38 100644
--- a/packages/client/src/pages/preview.vue
+++ b/packages/client/src/pages/preview.vue
@@ -7,16 +7,18 @@
 <script lang="ts" setup>
 import { computed } from 'vue';
 import MkSample from '@/components/sample.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.preview,
-		icon: 'fas fa-eye',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.preview,
+	icon: 'fas fa-eye',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue
index b3e2ca8d6f..39a1191caf 100644
--- a/packages/client/src/pages/reset-password.vue
+++ b/packages/client/src/pages/reset-password.vue
@@ -1,14 +1,17 @@
 <template>
-<MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
-	<div class="_formRoot">
-		<FormInput v-model="password" type="password" class="_formBlock">
-			<template #prefix><i class="fas fa-lock"></i></template>
-			<template #label>{{ i18n.ts.newPassword }}</template>
-		</FormInput>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
+		<div class="_formRoot">
+			<FormInput v-model="password" type="password" class="_formBlock">
+				<template #prefix><i class="fas fa-lock"></i></template>
+				<template #label>{{ i18n.ts.newPassword }}</template>
+			</FormInput>
 		
-		<FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton>
-	</div>
-</MkSpacer>
+			<FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -16,9 +19,9 @@ import { defineAsyncComponent, onMounted } from 'vue';
 import FormInput from '@/components/form/input.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	token?: string;
@@ -31,22 +34,24 @@ async function save() {
 		token: props.token,
 		password: password,
 	});
-	router.push('/');
+	mainRouter.push('/');
 }
 
 onMounted(() => {
 	if (props.token == null) {
 		os.popup(defineAsyncComponent(() => import('@/components/forgot-password.vue')), {}, {}, 'closed');
-		router.push('/');
+		mainRouter.push('/');
 	}
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.resetPassword,
-		icon: 'fas fa-lock',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.resetPassword,
+	icon: 'fas fa-lock',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue
index 34a41b81a5..d437601475 100644
--- a/packages/client/src/pages/scratchpad.vue
+++ b/packages/client/src/pages/scratchpad.vue
@@ -19,7 +19,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import 'prismjs';
 import { highlight, languages } from 'prismjs/components/prism-core';
 import 'prismjs/components/prism-clike';
@@ -32,9 +32,9 @@ import MkContainer from '@/components/ui/container.vue';
 import MkButton from '@/components/ui/button.vue';
 import { createAiScriptEnv } from '@/scripts/aiscript/api';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const code = ref('');
 const logs = ref<any[]>([]);
@@ -67,7 +67,7 @@ async function run() {
 			logs.value.push({
 				id: Math.random(),
 				text: value.type === 'str' ? value.value : utils.valToString(value),
-				print: true
+				print: true,
 			});
 		},
 		log: (type, params) => {
@@ -75,11 +75,11 @@ async function run() {
 				case 'end': logs.value.push({
 					id: Math.random(),
 					text: utils.valToString(params.val, true),
-					print: false
+					print: false,
 				}); break;
 				default: break;
 			}
-		}
+		},
 	});
 
 	let ast;
@@ -88,7 +88,7 @@ async function run() {
 	} catch (error) {
 		os.alert({
 			type: 'error',
-			text: 'Syntax error :('
+			text: 'Syntax error :(',
 		});
 		return;
 	}
@@ -97,7 +97,7 @@ async function run() {
 	} catch (error: any) {
 		os.alert({
 			type: 'error',
-			text: error.message
+			text: error.message,
 		});
 	}
 }
@@ -106,11 +106,13 @@ function highlighter(code) {
 	return highlight(code, languages.js, 'javascript');
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.scratchpad,
-		icon: 'fas fa-terminal',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.scratchpad,
+	icon: 'fas fa-terminal',
 });
 </script>
 
diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue
index ce2b7035da..25fef7af50 100644
--- a/packages/client/src/pages/search.vue
+++ b/packages/client/src/pages/search.vue
@@ -1,16 +1,17 @@
 <template>
-<div class="_section">
-	<div class="_content">
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
 		<XNotes ref="notes" :pagination="pagination"/>
-	</div>
-</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { computed } from 'vue';
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	query: string;
@@ -23,14 +24,16 @@ const pagination = {
 	params: computed(() => ({
 		query: props.query,
 		channelId: props.channel,
-	}))
+	})),
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.t('searchWith', { q: props.query }),
-		icon: 'fas fa-search',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.t('searchWith', { q: props.query }),
+	icon: 'fas fa-search',
+	bg: 'var(--bg)',
+})));
 </script>
diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue
index 12142b4dc1..65b6233693 100644
--- a/packages/client/src/pages/settings/account-info.vue
+++ b/packages/client/src/pages/settings/account-info.vue
@@ -127,30 +127,32 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, onMounted, ref } from 'vue';
+import { onMounted, ref } from 'vue';
 import FormSection from '@/components/form/section.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
 import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const stats = ref<any>({});
 
 onMounted(() => {
 	os.api('users/stats', {
-		userId: $i!.id
+		userId: $i!.id,
 	}).then(response => {
 		stats.value = response;
 	});
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.accountInfo,
-		icon: 'fas fa-info-circle'
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.accountInfo,
+	icon: 'fas fa-info-circle',
 });
 </script>
diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue
index 5e75639c55..47b816243f 100644
--- a/packages/client/src/pages/settings/accounts.vue
+++ b/packages/client/src/pages/settings/accounts.vue
@@ -21,13 +21,13 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, defineExpose, ref } from 'vue';
+import { defineAsyncComponent, ref } from 'vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { getAccounts, addAccount as addAccounts, login, $i } from '@/account';
+import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const storedAccounts = ref<any>(null);
 const accounts = ref<any>(null);
@@ -39,7 +39,7 @@ const init = async () => {
 		console.log(storedAccounts.value);
 
 		return os.api('users/show', {
-			userIds: storedAccounts.value.map(x => x.id)
+			userIds: storedAccounts.value.map(x => x.id),
 		});
 	}).then(response => {
 		accounts.value = response;
@@ -70,6 +70,10 @@ function addAccount(ev) {
 	}], ev.currentTarget ?? ev.target);
 }
 
+function removeAccount(account) {
+	_removeAccount(account.id);
+}
+
 function addExistingAccount() {
 	os.popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), {}, {
 		done: res => {
@@ -98,12 +102,14 @@ function switchAccountWithToken(token: string) {
 	login(token);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.accounts,
-		icon: 'fas fa-users',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.accounts,
+	icon: 'fas fa-users',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue
index e6375763f1..d94862712e 100644
--- a/packages/client/src/pages/settings/api.vue
+++ b/packages/client/src/pages/settings/api.vue
@@ -7,12 +7,12 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, defineExpose, ref } from 'vue';
+import { defineAsyncComponent, ref } from 'vue';
 import FormLink from '@/components/form/link.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const isDesktop = ref(window.innerWidth >= 1100);
 
@@ -29,17 +29,19 @@ function generateToken() {
 			os.alert({
 				type: 'success',
 				title: i18n.ts.token,
-				text: token
+				text: token,
 			});
 		},
 	}, 'closed');
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'API',
-		icon: 'fas fa-key',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'API',
+	icon: 'fas fa-key',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue
index 7b0b5548d5..673e91fe6b 100644
--- a/packages/client/src/pages/settings/apps.vue
+++ b/packages/client/src/pages/settings/apps.vue
@@ -7,7 +7,7 @@
 				<div>{{ i18n.ts.nothing }}</div>
 			</div>
 		</template>
-		<template v-slot="{items}">
+		<template #default="{items}">
 			<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
 				<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
 				<div class="body">
@@ -38,11 +38,11 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref } from 'vue';
+import { ref } from 'vue';
 import FormPagination from '@/components/ui/pagination.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const list = ref<any>(null);
 
@@ -50,8 +50,8 @@ const pagination = {
 	endpoint: 'i/apps' as const,
 	limit: 100,
 	params: {
-		sort: '+lastUsedAt'
-	}
+		sort: '+lastUsedAt',
+	},
 };
 
 function revoke(token) {
@@ -60,12 +60,14 @@ function revoke(token) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.installedApps,
-		icon: 'fas fa-plug',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.installedApps,
+	icon: 'fas fa-plug',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue
index 20db077ceb..3e032be257 100644
--- a/packages/client/src/pages/settings/custom-css.vue
+++ b/packages/client/src/pages/settings/custom-css.vue
@@ -9,13 +9,13 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormInfo from '@/components/ui/info.vue';
 import * as os from '@/os';
 import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const localCustomCss = ref(localStorage.getItem('customCss') ?? '');
 
@@ -35,11 +35,13 @@ watch(localCustomCss, async () => {
 	await apply();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.customCss,
-		icon: 'fas fa-code',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.customCss,
+	icon: 'fas fa-code',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue
index 2d868aa0a7..edada683ae 100644
--- a/packages/client/src/pages/settings/deck.vue
+++ b/packages/client/src/pages/settings/deck.vue
@@ -30,7 +30,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, watch } from 'vue';
+import { computed, watch } from 'vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormLink from '@/components/form/link.vue';
 import FormRadios from '@/components/form/radios.vue';
@@ -39,8 +39,8 @@ import FormGroup from '@/components/form/group.vue';
 import { deckStore } from '@/ui/deck/deck-store';
 import * as os from '@/os';
 import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const navWindow = computed(deckStore.makeGetterSetter('navWindow'));
 const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn'));
@@ -62,7 +62,7 @@ watch(navWindow, async () => {
 async function setProfile() {
 	const { canceled, result: name } = await os.inputText({
 		title: i18n.ts._deck.profile,
-		allowEmpty: false
+		allowEmpty: false,
 	});
 	if (canceled) return;
 	
@@ -70,11 +70,13 @@ async function setProfile() {
 	unisonReload();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.deck,
-		icon: 'fas fa-columns',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.deck,
+	icon: 'fas fa-columns',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue
index e9f19aaf0b..a587c32998 100644
--- a/packages/client/src/pages/settings/delete-account.vue
+++ b/packages/client/src/pages/settings/delete-account.vue
@@ -8,13 +8,12 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose } from 'vue';
 import FormInfo from '@/components/ui/info.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
 import { signout } from '@/account';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 async function deleteAccount() {
 	{
@@ -27,12 +26,12 @@ async function deleteAccount() {
 
 	const { canceled, result: password } = await os.inputText({
 		title: i18n.ts.password,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled) return;
 
 	await os.apiWithDialog('i/delete-account', {
-		password: password
+		password: password,
 	});
 
 	await os.alert({
@@ -42,11 +41,13 @@ async function deleteAccount() {
 	await signout();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts._accountDelete.accountDelete,
-		icon: 'fas fa-exclamation-triangle',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._accountDelete.accountDelete,
+	icon: 'fas fa-exclamation-triangle',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue
index 09a2537ed5..73c0384f1f 100644
--- a/packages/client/src/pages/settings/drive.vue
+++ b/packages/client/src/pages/settings/drive.vue
@@ -34,7 +34,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
 import tinycolor from 'tinycolor2';
 import FormLink from '@/components/form/link.vue';
 import FormSwitch from '@/components/form/switch.vue';
@@ -43,10 +43,10 @@ import MkKeyValue from '@/components/key-value.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os';
 import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
 import { defaultStore } from '@/store';
 import MkChart from '@/components/chart.vue';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const fetching = ref(true);
 const usage = ref<any>(null);
@@ -59,8 +59,8 @@ const meterStyle = computed(() => {
 		background: tinycolor({
 			h: 180 - (usage.value / capacity.value * 180),
 			s: 0.7,
-			l: 0.5
-		})
+			l: 0.5,
+		}),
 	};
 });
 
@@ -74,7 +74,7 @@ os.api('drive').then(info => {
 
 if (defaultStore.state.uploadFolder) {
 	os.api('drive/folders/show', {
-		folderId: defaultStore.state.uploadFolder
+		folderId: defaultStore.state.uploadFolder,
 	}).then(response => {
 		uploadFolder.value = response;
 	});
@@ -86,7 +86,7 @@ function chooseUploadFolder() {
 		os.success();
 		if (defaultStore.state.uploadFolder) {
 			uploadFolder.value = await os.api('drive/folders/show', {
-				folderId: defaultStore.state.uploadFolder
+				folderId: defaultStore.state.uploadFolder,
 			});
 		} else {
 			uploadFolder.value = null;
@@ -94,12 +94,14 @@ function chooseUploadFolder() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.drive,
-		icon: 'fas fa-cloud',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.drive,
+	icon: 'fas fa-cloud',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue
index 37f14068e2..8b67ff34dd 100644
--- a/packages/client/src/pages/settings/email.vue
+++ b/packages/client/src/pages/settings/email.vue
@@ -40,27 +40,27 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, onMounted, ref, watch } from 'vue';
+import { onMounted, ref, watch } from 'vue';
 import FormSection from '@/components/form/section.vue';
 import FormInput from '@/components/form/input.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const emailAddress = ref($i!.email);
 
 const onChangeReceiveAnnouncementEmail = (v) => {
 	os.api('i/update', {
-		receiveAnnouncementEmail: v
+		receiveAnnouncementEmail: v,
 	});
 };
 
 const saveEmailAddress = () => {
 	os.inputText({
 		title: i18n.ts.password,
-		type: 'password'
+		type: 'password',
 	}).then(({ canceled, result: password }) => {
 		if (canceled) return;
 		os.apiWithDialog('i/update-email', {
@@ -86,7 +86,7 @@ const saveNotificationSettings = () => {
 			...[emailNotification_follow.value ? 'follow' : null],
 			...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null],
 			...[emailNotification_groupInvited.value ? 'groupInvited' : null],
-		].filter(x => x != null)
+		].filter(x => x != null),
 	});
 };
 
@@ -100,11 +100,13 @@ onMounted(() => {
 	});
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.email,
-		icon: 'fas fa-envelope',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.email,
+	icon: 'fas fa-envelope',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue
index 64b8cc3106..ac2e3a4968 100644
--- a/packages/client/src/pages/settings/general.vue
+++ b/packages/client/src/pages/settings/general.vue
@@ -48,7 +48,8 @@
 		<FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch>
 		<FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch>
 		<FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch>
-		<FormSwitch v-model="useOsNativeEmojis" class="_formBlock">{{ i18n.ts.useOsNativeEmojis }}
+		<FormSwitch v-model="useOsNativeEmojis" class="_formBlock">
+			{{ i18n.ts.useOsNativeEmojis }}
 			<div><Mfm :key="useOsNativeEmojis" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
 		</FormSwitch>
 		<FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch>
@@ -92,7 +93,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormSelect from '@/components/form/select.vue';
 import FormRadios from '@/components/form/radios.vue';
@@ -104,8 +105,8 @@ import { langs } from '@/config';
 import { defaultStore } from '@/store';
 import * as os from '@/os';
 import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const lang = ref(localStorage.getItem('lang'));
 const fontSize = ref(localStorage.getItem('fontSize'));
@@ -173,16 +174,18 @@ watch([
 	aiChanMode,
 	showGapBetweenNotesInTimeline,
 	instanceTicker,
-	overridedDeviceKind
+	overridedDeviceKind,
 ], async () => {
 	await reloadAsk();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.general,
-		icon: 'fas fa-cogs',
-		bg: 'var(--bg)'
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.general,
+	icon: 'fas fa-cogs',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue
index 127cbcd4c1..438ecbd330 100644
--- a/packages/client/src/pages/settings/import-export.vue
+++ b/packages/client/src/pages/settings/import-export.vue
@@ -38,15 +38,15 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref } from 'vue';
+import { ref } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import FormSection from '@/components/form/section.vue';
 import FormGroup from '@/components/form/group.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
 import { selectFile } from '@/scripts/select-file';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const excludeMutingUsers = ref(false);
 const excludeInactiveUsers = ref(false);
@@ -116,12 +116,14 @@ const importBlocking = async (ev) => {
 	os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.importAndExport,
-		icon: 'fas fa-boxes',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.importAndExport,
+	icon: 'fas fa-boxes',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue
index e6670ea930..011962c2e3 100644
--- a/packages/client/src/pages/settings/index.vue
+++ b/packages/client/src/pages/settings/index.vue
@@ -1,46 +1,42 @@
 <template>
-<MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
-	<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
-		<div class="header">
-			<div class="title">
-				<MkA v-if="narrow" to="/settings">{{ $ts.settings }}</MkA>
-				<template v-else>{{ $ts.settings }}</template>
-			</div>
-			<div v-if="childInfo" class="subtitle">{{ childInfo.title }}</div>
-		</div>
-		<div class="body">
-			<div v-if="!narrow || initialPage == null" class="nav">
-				<div class="baaadecd">
-					<MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
-					<MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
+		<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
+			<div class="body">
+				<div v-if="!narrow || initialPage == null" class="nav">
+					<div class="baaadecd">
+						<MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+						<MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
+					</div>
 				</div>
-			</div>
-			<div v-if="!(narrow && initialPage == null)" class="main">
-				<div class="bkzroven">
-					<component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/>
+				<div v-if="!(narrow && initialPage == null)" class="main">
+					<div class="bkzroven">
+						<component :is="component" :key="initialPage" v-bind="pageProps"/>
+					</div>
 				</div>
 			</div>
 		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</mkstickycontainer>
 </template>
 
 <script setup lang="ts">
-import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
+import { computed, defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue';
 import { i18n } from '@/i18n';
 import MkInfo from '@/components/ui/info.vue';
 import MkSuperMenu from '@/components/ui/super-menu.vue';
 import { scroll } from '@/scripts/scroll';
-import { signout } from '@/account';
+import { signout , $i } from '@/account';
 import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
 import { instance } from '@/instance';
-import { $i } from '@/account';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
-const props = defineProps<{
-  initialPage?: string
-}>();
+const props = withDefaults(defineProps<{
+  initialPage?: string;
+}>(), {
+});
 
 const indexInfo = {
 	title: i18n.ts.settings,
@@ -52,7 +48,7 @@ const INFO = ref(indexInfo);
 const el = ref<HTMLElement | null>(null);
 const childInfo = ref(null);
 
-const nav = new MisskeyNavigator();
+const router = useRouter();
 
 const narrow = ref(false);
 const NARROW_THRESHOLD = 600;
@@ -189,7 +185,7 @@ const menuDef = computed(() => [{
 			signout();
 		},
 		danger: true,
-	},],
+	}],
 }]);
 
 const pageProps = ref({});
@@ -242,7 +238,7 @@ watch(component, () => {
 
 watch(() => props.initialPage, () => {
 	if (props.initialPage == null && !narrow.value) {
-		nav.push('/settings/profile');
+		router.push('/settings/profile');
 	} else {
 		if (props.initialPage == null) {
 			INFO.value = indexInfo;
@@ -252,7 +248,7 @@ watch(() => props.initialPage, () => {
 
 watch(narrow, () => {
 	if (props.initialPage == null && !narrow.value) {
-		nav.push('/settings/profile');
+		router.push('/settings/profile');
 	}
 });
 
@@ -261,7 +257,7 @@ onMounted(() => {
 
 	narrow.value = el.value.offsetWidth < NARROW_THRESHOLD;
 	if (props.initialPage == null && !narrow.value) {
-		nav.push('/settings/profile');
+		router.push('/settings/profile');
 	}
 });
 
@@ -271,38 +267,23 @@ onUnmounted(() => {
 
 const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
 
-const pageChanged = (page) => {
-	if (page == null) {
+provideMetadataReceiver((info) => {
+	if (info == null) {
 		childInfo.value = null;
 	} else {
-		childInfo.value = page[symbols.PAGE_INFO];
+		childInfo.value = info;
 	}
-};
-
-defineExpose({
-	[symbols.PAGE_INFO]: INFO,
 });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
 </script>
 
 <style lang="scss" scoped>
 .vvcocwet {
-	> .header {
-		display: flex;
-		margin-bottom: 24px;
-		font-size: 1.3em;
-		font-weight: bold;
-
-		> .title {
-			display: block;
-			width: 34%;
-		}
-
-		> .subtitle {
-			flex: 1;
-			min-width: 0;
-		}
-	}
-
 	> .body {
 		> .nav {
 			.baaadecd {
diff --git a/packages/client/src/pages/settings/instance-mute.vue b/packages/client/src/pages/settings/instance-mute.vue
index bcc2ee85ad..d0ca85adca 100644
--- a/packages/client/src/pages/settings/instance-mute.vue
+++ b/packages/client/src/pages/settings/instance-mute.vue
@@ -10,14 +10,14 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import MkInfo from '@/components/ui/info.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const instanceMutes = ref($i!.mutedInstances.join('\n'));
 const changed = ref(false);
@@ -42,10 +42,12 @@ watch(instanceMutes, () => {
 	changed.value = true;
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.instanceMute,
-		icon: 'fas fa-volume-mute'
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.instanceMute,
+	icon: 'fas fa-volume-mute',
 });
 </script>
diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue
index 75c6200944..7de151040e 100644
--- a/packages/client/src/pages/settings/integration.vue
+++ b/packages/client/src/pages/settings/integration.vue
@@ -24,14 +24,14 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, onMounted, ref, watch } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
 import { apiUrl } from '@/config';
 import FormSection from '@/components/form/section.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { instance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const twitterForm = ref<Window | null>(null);
 const discordForm = ref<Window | null>(null);
@@ -42,7 +42,7 @@ const integrations = computed(() => $i!.integrations);
 function openWindow(service: string, type: string) {
 	return window.open(`${apiUrl}/${type}/${service}`,
 		`${service}_${type}_window`,
-		'height=570, width=520'
+		'height=570, width=520',
 	);
 }
 
@@ -72,7 +72,7 @@ function disconnectGithub() {
 
 onMounted(() => {
 	document.cookie = `igi=${$i!.token}; path=/;` +
-		` max-age=31536000;` +
+		' max-age=31536000;' +
 		(document.location.protocol.startsWith('https') ? ' secure' : '');
 
 	watch(integrations, () => {
@@ -88,11 +88,13 @@ onMounted(() => {
 	});
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.integration,
-		icon: 'fas fa-share-alt',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.integration,
+	icon: 'fas fa-share-alt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue
index 2288c3f718..1b4d8799c8 100644
--- a/packages/client/src/pages/settings/menu.vue
+++ b/packages/client/src/pages/settings/menu.vue
@@ -18,16 +18,16 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormRadios from '@/components/form/radios.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
 import { menuDef } from '@/menu';
 import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
 import { unisonReload } from '@/scripts/unison-reload';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const items = ref(defaultStore.state.menu.join('\n'));
 
@@ -37,7 +37,7 @@ const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
 async function reloadAsk() {
 	const { canceled } = await os.confirm({
 		type: 'info',
-		text: i18n.ts.reloadToApplySetting
+		text: i18n.ts.reloadToApplySetting,
 	});
 	if (canceled) return;
 
@@ -49,10 +49,10 @@ async function addItem() {
 	const { canceled, result: item } = await os.select({
 		title: i18n.ts.addItem,
 		items: [...menu.map(k => ({
-			value: k, text: i18n.ts[menuDef[k].title]
+			value: k, text: i18n.ts[menuDef[k].title],
 		})), {
-			value: '-', text: i18n.ts.divider
-		}]
+			value: '-', text: i18n.ts.divider,
+		}],
 	});
 	if (canceled) return;
 	items.value = [...split.value, item].join('\n');
@@ -76,11 +76,13 @@ watch(menuDisplay, async () => {
 	await reloadAsk();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.menu,
-		icon: 'fas fa-list-ul',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.menu,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue
index 28d11809e3..d8cb286626 100644
--- a/packages/client/src/pages/settings/mute-block.vue
+++ b/packages/client/src/pages/settings/mute-block.vue
@@ -7,7 +7,7 @@
 	<div v-if="tab === 'mute'">
 		<MkPagination :pagination="mutingPagination" class="muting">
 			<template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
-			<template v-slot="{items}">
+			<template #default="{items}">
 				<FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)">
 					<MkAcct :user="mute.mutee"/>
 				</FormLink>
@@ -17,7 +17,7 @@
 	<div v-if="tab === 'block'">
 		<MkPagination :pagination="blockingPagination" class="blocking">
 			<template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
-			<template v-slot="{items}">
+			<template #default="{items}">
 				<FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)">
 					<MkAcct :user="block.blockee"/>
 				</FormLink>
@@ -35,8 +35,8 @@ import FormInfo from '@/components/ui/info.vue';
 import FormLink from '@/components/form/link.vue';
 import { userPage } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let tab = $ref('mute');
 
@@ -50,11 +50,13 @@ const blockingPagination = {
 	limit: 10,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.muteAndBlock,
-		icon: 'fas fa-ban',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.muteAndBlock,
+	icon: 'fas fa-ban',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue
index b8fff95a8d..494a3eebe0 100644
--- a/packages/client/src/pages/settings/notifications.vue
+++ b/packages/client/src/pages/settings/notifications.vue
@@ -10,15 +10,15 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, defineExpose } from 'vue';
+import { defineAsyncComponent } from 'vue';
+import { notificationTypes } from 'misskey-js';
 import FormButton from '@/components/ui/button.vue';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
-import { notificationTypes } from 'misskey-js';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 async function readAllUnreadNotes() {
 	await os.api('i/read-all-unread-notes');
@@ -45,15 +45,17 @@ function configure() {
 			}).then(i => {
 				$i!.mutingNotificationTypes = i.mutingNotificationTypes;
 			});
-		}
+		},
 	}, 'closed');
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.notifications,
-		icon: 'fas fa-bell',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.notifications,
+	icon: 'fas fa-bell',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue
index 82e174a5b4..283d87a066 100644
--- a/packages/client/src/pages/settings/other.vue
+++ b/packages/client/src/pages/settings/other.vue
@@ -15,30 +15,32 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose } from 'vue';
+import { computed } from 'vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormLink from '@/components/form/link.vue';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const reportError = computed(defaultStore.makeGetterSetter('reportError'));
 
 function onChangeInjectFeaturedNote(v) {
 	os.api('i/update', {
-		injectFeaturedNote: v
+		injectFeaturedNote: v,
 	}).then((i) => {
 		$i!.injectFeaturedNote = i.injectFeaturedNote;
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.other,
-		icon: 'fas fa-ellipsis-h',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.other,
+	icon: 'fas fa-ellipsis-h',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue
index 96c0abfd99..7ff55e9d83 100644
--- a/packages/client/src/pages/settings/plugin.install.vue
+++ b/packages/client/src/pages/settings/plugin.install.vue
@@ -13,7 +13,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, defineAsyncComponent, nextTick, ref } from 'vue';
+import { defineAsyncComponent, nextTick, ref } from 'vue';
 import { AiScript, parse } from '@syuilo/aiscript';
 import { serialize } from '@syuilo/aiscript/built/serializer';
 import { v4 as uuid } from 'uuid';
@@ -24,7 +24,7 @@ import * as os from '@/os';
 import { ColdDeviceStorage } from '@/store';
 import { unisonReload } from '@/scripts/unison-reload';
 import { i18n } from '@/i18n';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const code = ref(null);
 
@@ -35,7 +35,7 @@ function installPlugin({ id, meta, ast, token }) {
 		active: true,
 		configData: {},
 		token: token,
-		ast: ast
+		ast: ast,
 	}));
 }
 
@@ -46,7 +46,7 @@ async function install() {
 	} catch (err) {
 		os.alert({
 			type: 'error',
-			text: 'Syntax error :('
+			text: 'Syntax error :(',
 		});
 		return;
 	}
@@ -55,7 +55,7 @@ async function install() {
 	if (meta == null) {
 		os.alert({
 			type: 'error',
-			text: 'No metadata found :('
+			text: 'No metadata found :(',
 		});
 		return;
 	}
@@ -64,7 +64,7 @@ async function install() {
 	if (metadata == null) {
 		os.alert({
 			type: 'error',
-			text: 'No metadata found :('
+			text: 'No metadata found :(',
 		});
 		return;
 	}
@@ -73,7 +73,7 @@ async function install() {
 	if (name == null || version == null || author == null) {
 		os.alert({
 			type: 'error',
-			text: 'Required property not found :('
+			text: 'Required property not found :(',
 		});
 		return;
 	}
@@ -83,7 +83,7 @@ async function install() {
 			title: i18n.ts.tokenRequested,
 			information: i18n.ts.pluginTokenRequestedDescription,
 			initialName: name,
-			initialPermissions: permissions
+			initialPermissions: permissions,
 		}, {
 			done: async result => {
 				const { name, permissions } = result;
@@ -93,17 +93,17 @@ async function install() {
 					permission: permissions,
 				});
 				res(token);
-			}
+			},
 		}, 'closed');
 	});
 
 	installPlugin({
 		id: uuid(),
 		meta: {
-			name, version, author, description, permissions, config
+			name, version, author, description, permissions, config,
 		},
 		token,
-		ast: serialize(ast)
+		ast: serialize(ast),
 	});
 
 	os.success();
@@ -113,11 +113,13 @@ async function install() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts._plugin.install,
-		icon: 'fas fa-download',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._plugin.install,
+	icon: 'fas fa-download',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue
index 873a022cbc..75cf42bb89 100644
--- a/packages/client/src/pages/settings/plugin.vue
+++ b/packages/client/src/pages/settings/plugin.vue
@@ -7,7 +7,7 @@
 		<div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;">
 			<span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
 
-			<FormSwitch class="_formBlock" :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
+			<FormSwitch class="_formBlock" :model-value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
 
 			<MkKeyValue class="_formBlock">
 				<template #key>{{ i18n.ts.author }}</template>
@@ -32,7 +32,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, nextTick, ref } from 'vue';
+import { nextTick, ref } from 'vue';
 import FormLink from '@/components/form/link.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormSection from '@/components/form/section.vue';
@@ -40,9 +40,9 @@ import MkButton from '@/components/ui/button.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import * as os from '@/os';
 import { ColdDeviceStorage } from '@/store';
-import * as symbols from '@/symbols';
 import { unisonReload } from '@/scripts/unison-reload';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const plugins = ref(ColdDeviceStorage.get('plugins'));
 
@@ -83,12 +83,14 @@ function changeActive(plugin, active) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.plugins,
-		icon: 'fas fa-plug',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.plugins,
+	icon: 'fas fa-plug',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue
index a84d2f8786..a209c3f469 100644
--- a/packages/client/src/pages/settings/privacy.vue
+++ b/packages/client/src/pages/settings/privacy.vue
@@ -55,9 +55,9 @@ import FormSection from '@/components/form/section.vue';
 import FormGroup from '@/components/form/group.vue';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let isLocked = $ref($i.isLocked);
 let autoAcceptFollowed = $ref($i.autoAcceptFollowed);
@@ -84,11 +84,13 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.privacy,
-		icon: 'fas fa-lock-open',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.privacy,
+	icon: 'fas fa-lock-open',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
index b64dc93cc7..b662de9e3d 100644
--- a/packages/client/src/pages/settings/profile.vue
+++ b/packages/client/src/pages/settings/profile.vue
@@ -74,10 +74,10 @@ import FormSlot from '@/components/form/slot.vue';
 import { host } from '@/config';
 import { selectFile } from '@/scripts/select-file';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { $i } from '@/account';
 import { langmap } from '@/scripts/langmap';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const profile = reactive({
 	name: $i.name,
@@ -176,12 +176,14 @@ function changeBanner(ev) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.profile,
-		icon: 'fas fa-user',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.profile,
+	icon: 'fas fa-user',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue
index 963ac81dfa..d0fdf835cf 100644
--- a/packages/client/src/pages/settings/reaction.vue
+++ b/packages/client/src/pages/settings/reaction.vue
@@ -64,8 +64,8 @@ import FormSection from '@/components/form/section.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let reactions = $ref(JSON.parse(JSON.stringify(defaultStore.state.reactions)));
 
@@ -83,7 +83,7 @@ function remove(reaction, ev: MouseEvent) {
 		text: i18n.ts.remove,
 		action: () => {
 			reactions = reactions.filter(x => x !== reaction);
-		}
+		},
 	}], ev.currentTarget ?? ev.target);
 }
 
@@ -106,7 +106,7 @@ async function setDefault() {
 
 function chooseEmoji(ev: MouseEvent) {
 	os.pickEmoji(ev.currentTarget ?? ev.target, {
-		showPinned: false
+		showPinned: false,
 	}).then(emoji => {
 		if (!reactions.includes(emoji)) {
 			reactions.push(emoji);
@@ -120,16 +120,18 @@ watch($$(reactions), () => {
 	deep: true,
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.reaction,
-		icon: 'fas fa-laugh',
-		action: {
-			icon: 'fas fa-eye',
-			handler: preview,
-		},
-		bg: 'var(--bg)',
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.reaction,
+	icon: 'fas fa-laugh',
+	action: {
+		icon: 'fas fa-eye',
+		handler: preview,
 	},
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue
index 401648790a..57880ef3dd 100644
--- a/packages/client/src/pages/settings/security.vue
+++ b/packages/client/src/pages/settings/security.vue
@@ -13,7 +13,7 @@
 	<FormSection>
 		<template #label>{{ i18n.ts.signinHistory }}</template>
 		<MkPagination :pagination="pagination">
-			<template v-slot="{items}">
+			<template #default="{items}">
 				<div>
 					<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
 						<header>
@@ -38,15 +38,14 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose } from 'vue';
+import X2fa from './2fa.vue';
 import FormSection from '@/components/form/section.vue';
 import FormSlot from '@/components/form/slot.vue';
 import FormButton from '@/components/ui/button.vue';
 import MkPagination from '@/components/ui/pagination.vue';
-import X2fa from './2fa.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'i/signin-history' as const,
@@ -56,54 +55,56 @@ const pagination = {
 async function change() {
 	const { canceled: canceled1, result: currentPassword } = await os.inputText({
 		title: i18n.ts.currentPassword,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled1) return;
 
 	const { canceled: canceled2, result: newPassword } = await os.inputText({
 		title: i18n.ts.newPassword,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled2) return;
 
 	const { canceled: canceled3, result: newPassword2 } = await os.inputText({
 		title: i18n.ts.newPasswordRetype,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled3) return;
 
 	if (newPassword !== newPassword2) {
 		os.alert({
 			type: 'error',
-			text: i18n.ts.retypedNotMatch
+			text: i18n.ts.retypedNotMatch,
 		});
 		return;
 	}
 	
 	os.apiWithDialog('i/change-password', {
 		currentPassword,
-		newPassword
+		newPassword,
 	});
 }
 
 function regenerateToken() {
 	os.inputText({
 		title: i18n.ts.password,
-		type: 'password'
+		type: 'password',
 	}).then(({ canceled, result: password }) => {
 		if (canceled) return;
 		os.api('i/regenerate_token', {
-			password: password
+			password: password,
 		});
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.security,
-		icon: 'fas fa-lock',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.security,
+	icon: 'fas fa-lock',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue
index d01e87c1f8..bb23257d7a 100644
--- a/packages/client/src/pages/settings/sounds.vue
+++ b/packages/client/src/pages/settings/sounds.vue
@@ -18,7 +18,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
 import FormRange from '@/components/form/range.vue';
 import FormButton from '@/components/ui/button.vue';
 import FormLink from '@/components/form/link.vue';
@@ -26,8 +26,8 @@ import FormSection from '@/components/form/section.vue';
 import * as os from '@/os';
 import { ColdDeviceStorage } from '@/store';
 import { playFile } from '@/scripts/sound';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const masterVolume = computed({
 	get: () => {
@@ -35,19 +35,19 @@ const masterVolume = computed({
 	},
 	set: (value) => {
 		ColdDeviceStorage.set('sound_masterVolume', value);
-	}
+	},
 });
 
 const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up');
 
 const sounds = ref({
-    note: ColdDeviceStorage.get('sound_note'),
-    noteMy: ColdDeviceStorage.get('sound_noteMy'),
-    notification: ColdDeviceStorage.get('sound_notification'),
-    chat: ColdDeviceStorage.get('sound_chat'),
-    chatBg: ColdDeviceStorage.get('sound_chatBg'),
-    antenna: ColdDeviceStorage.get('sound_antenna'),
-    channel: ColdDeviceStorage.get('sound_channel'),
+	note: ColdDeviceStorage.get('sound_note'),
+	noteMy: ColdDeviceStorage.get('sound_noteMy'),
+	notification: ColdDeviceStorage.get('sound_notification'),
+	chat: ColdDeviceStorage.get('sound_chat'),
+	chatBg: ColdDeviceStorage.get('sound_chatBg'),
+	antenna: ColdDeviceStorage.get('sound_antenna'),
+	channel: ColdDeviceStorage.get('sound_channel'),
 });
 
 const soundsTypes = [
@@ -95,15 +95,15 @@ async function edit(type) {
 			step: 0.05,
 			textConverter: (v) => `${Math.floor(v * 100)}%`,
 			label: i18n.ts.volume,
-			default: sounds.value[type].volume
+			default: sounds.value[type].volume,
 		},
 		listen: {
 			type: 'button',
 			content: i18n.ts.listen,
 			action: (_, values) => {
 				playFile(values.type, values.volume);
-			}
-		}
+			},
+		},
 	});
 	if (canceled) return;
 
@@ -124,11 +124,13 @@ function reset() {
 	}
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.sounds,
-		icon: 'fas fa-music',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.sounds,
+	icon: 'fas fa-music',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue
index 25fa6c012b..6a863ed9e6 100644
--- a/packages/client/src/pages/settings/theme.install.vue
+++ b/packages/client/src/pages/settings/theme.install.vue
@@ -19,8 +19,8 @@ import FormButton from '@/components/ui/button.vue';
 import { applyTheme, validateTheme } from '@/scripts/theme';
 import * as os from '@/os';
 import { addTheme, getThemes } from '@/theme-store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let installThemeCode = $ref(null);
 
@@ -32,21 +32,21 @@ function parseThemeCode(code: string) {
 	} catch (err) {
 		os.alert({
 			type: 'error',
-			text: i18n.ts._theme.invalid
+			text: i18n.ts._theme.invalid,
 		});
 		return false;
 	}
 	if (!validateTheme(theme)) {
 		os.alert({
 			type: 'error',
-			text: i18n.ts._theme.invalid
+			text: i18n.ts._theme.invalid,
 		});
 		return false;
 	}
 	if (getThemes().some(t => t.id === theme.id)) {
 		os.alert({
 			type: 'info',
-			text: i18n.ts._theme.alreadyInstalled
+			text: i18n.ts._theme.alreadyInstalled,
 		});
 		return false;
 	}
@@ -65,15 +65,17 @@ async function install(code: string): Promise<void> {
 	await addTheme(theme);
 	os.alert({
 		type: 'success',
-		text: i18n.t('_theme.installed', { name: theme.name })
+		text: i18n.t('_theme.installed', { name: theme.name }),
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts._theme.install,
-		icon: 'fas fa-download',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._theme.install,
+	icon: 'fas fa-download',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue
index 94b2d24455..68cbf3c353 100644
--- a/packages/client/src/pages/settings/theme.manage.vue
+++ b/packages/client/src/pages/settings/theme.manage.vue
@@ -26,7 +26,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
 import JSON5 from 'json5';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormSelect from '@/components/form/select.vue';
@@ -36,8 +36,8 @@ import { Theme, getBuiltinThemesRef } from '@/scripts/theme';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
 import * as os from '@/os';
 import { getThemes, removeTheme } from '@/theme-store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const installedThemes = ref(getThemes());
 const builtinThemes = getBuiltinThemesRef();
@@ -67,11 +67,13 @@ function uninstall() {
 	os.success();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts._theme.manage,
-		icon: 'fas fa-folder-open',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._theme.manage,
+	icon: 'fas fa-folder-open',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue
index 5e7ffcff4b..db4262ba7e 100644
--- a/packages/client/src/pages/settings/theme.vue
+++ b/packages/client/src/pages/settings/theme.vue
@@ -96,13 +96,12 @@ import FormButton from '@/components/ui/button.vue';
 import { getBuiltinThemesRef } from '@/scripts/theme';
 import { selectFile } from '@/scripts/select-file';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
-import { ColdDeviceStorage } from '@/store';
+import { ColdDeviceStorage , defaultStore } from '@/store';
 import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
 import { instance } from '@/instance';
 import { uniqueBy } from '@/scripts/array';
 import { fetchThemes, getThemes } from '@/theme-store';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const installedThemes = ref(getThemes());
 const builtinThemes = getBuiltinThemesRef();
@@ -121,7 +120,7 @@ const darkThemeId = computed({
 	},
 	set(id) {
 		ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id));
-	}
+	},
 });
 const lightTheme = ColdDeviceStorage.ref('lightTheme');
 const lightThemeId = computed({
@@ -130,7 +129,7 @@ const lightThemeId = computed({
 	},
 	set(id) {
 		ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id));
-	}
+	},
 });
 const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
 const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
@@ -168,12 +167,14 @@ function setWallpaper(event) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.theme,
-		icon: 'fas fa-palette',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.theme,
+	icon: 'fas fa-palette',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/webhook.edit.vue b/packages/client/src/pages/settings/webhook.edit.vue
index 3690526b41..d3cf5d7b79 100644
--- a/packages/client/src/pages/settings/webhook.edit.vue
+++ b/packages/client/src/pages/settings/webhook.edit.vue
@@ -40,19 +40,11 @@ import FormSection from '@/components/form/section.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
-
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'Edit webhook',
-		icon: 'fas fa-bolt',
-		bg: 'var(--bg)',
-	},
-});
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const webhook = await os.api('i/webhooks/show', {
-	webhookId: new URLSearchParams(window.location.search).get('id')
+	webhookId: new URLSearchParams(window.location.search).get('id'),
 });
 
 let name = $ref(webhook.name);
@@ -86,4 +78,14 @@ async function save(): Promise<void> {
 		active,
 	});
 }
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Edit webhook',
+	icon: 'fas fa-bolt',
+	bg: 'var(--bg)',
+});
 </script>
diff --git a/packages/client/src/pages/settings/webhook.new.vue b/packages/client/src/pages/settings/webhook.new.vue
index 9bb492c49e..508c0d78be 100644
--- a/packages/client/src/pages/settings/webhook.new.vue
+++ b/packages/client/src/pages/settings/webhook.new.vue
@@ -38,8 +38,8 @@ import FormSection from '@/components/form/section.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let name = $ref('');
 let url = $ref('');
@@ -71,11 +71,13 @@ async function create(): Promise<void> {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'Create new webhook',
-		icon: 'fas fa-bolt',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Create new webhook',
+	icon: 'fas fa-bolt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/webhook.vue b/packages/client/src/pages/settings/webhook.vue
index c9af8b6766..50739e2fd1 100644
--- a/packages/client/src/pages/settings/webhook.vue
+++ b/packages/client/src/pages/settings/webhook.vue
@@ -8,7 +8,7 @@
 	
 	<FormSection>
 		<MkPagination :pagination="pagination">
-			<template v-slot="{items}">
+			<template #default="{items}">
 				<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit?id=${webhook.id}`" class="_formBlock">
 					<template #icon>
 						<i v-if="webhook.active === false" class="fas fa-circle-pause"></i>
@@ -34,19 +34,21 @@ import FormSection from '@/components/form/section.vue';
 import FormLink from '@/components/form/link.vue';
 import { userPage } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'i/webhooks/list' as const,
 	limit: 10,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'Webhook',
-		icon: 'fas fa-bolt',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Webhook',
+	icon: 'fas fa-bolt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue
index 6e1a4b2ccb..c6af0e7661 100644
--- a/packages/client/src/pages/settings/word-mute.vue
+++ b/packages/client/src/pages/settings/word-mute.vue
@@ -29,7 +29,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import MkButton from '@/components/ui/button.vue';
@@ -37,10 +37,10 @@ import MkInfo from '@/components/ui/info.vue';
 import MkTab from '@/components/tab.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
-import * as symbols from '@/symbols';
 import { defaultStore } from '@/store';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const render = (mutedWords) => mutedWords.map(x => {
 	if (Array.isArray(x)) {
@@ -87,7 +87,7 @@ async function save() {
 					os.alert({
 						type: 'error',
 						title: i18n.ts.regexpError,
-						text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + "\n" + err.toString()
+						text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(),
 					});
 					// re-throw error so these invalid settings are not saved
 					throw err;
@@ -117,11 +117,13 @@ async function save() {
 	changed.value = false;
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.wordMute,
-		icon: 'fas fa-comment-slash',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.wordMute,
+	icon: 'fas fa-comment-slash',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue
index 1700944f82..8984823b60 100644
--- a/packages/client/src/pages/share.vue
+++ b/packages/client/src/pages/share.vue
@@ -22,158 +22,144 @@
 </div>
 </template>
 
-<script lang="ts">
+<script lang="ts" setup>
 // SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
 
 import { defineComponent } from 'vue';
+import { noteVisibilities } from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
+import * as Misskey from 'misskey-js';
 import MkButton from '@/components/ui/button.vue';
 import XPostForm from '@/components/post-form.vue';
 import * as os from '@/os';
-import { noteVisibilities } from 'misskey-js';
-import * as Acct from 'misskey-js/built/acct';
-import * as symbols from '@/symbols';
-import * as Misskey from 'misskey-js';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		XPostForm,
-		MkButton,
-	},
+const urlParams = new URLSearchParams(window.location.search);
+const localOnlyQuery = urlParams.get('localOnly');
+const visibilityQuery = urlParams.get('visibility');
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.share,
-				icon: 'fas fa-share-alt'
-			},
-			state: 'fetching' as 'fetching' | 'writing' | 'posted',
+let state = $ref('fetching' as 'fetching' | 'writing' | 'posted');
+let title = $ref(urlParams.get('title'));
+const text = urlParams.get('text');
+const url = urlParams.get('url');
+let initialText = $ref(null as string | null);
+let reply = $ref(null as Misskey.entities.Note | null);
+let renote = $ref(null as Misskey.entities.Note | null);
+let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null);
+let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null);
+let files = $ref([] as Misskey.entities.DriveFile[]);
+let visibleUsers = $ref([] as Misskey.entities.User[]);
 
-			title: null as string | null,
-			initialText: null as string | null,
-			reply: null as Misskey.entities.Note | null,
-			renote: null as Misskey.entities.Note | null,
-			visibility: null as string | null,
-			localOnly: null as boolean | null,
-			files: [] as Misskey.entities.DriveFile[],
-			visibleUsers: [] as Misskey.entities.User[],
-		};
-	},
+async function init() {
+	let noteText = '';
+	if (title) noteText += `[ ${title} ]\n`;
+	// Googleニュース対策
+	if (text?.startsWith(`${title}.\n`)) noteText += text.replace(`${title}.\n`, '');
+	else if (text && title !== text) noteText += `${text}\n`;
+	if (url) noteText += `${url}`;
+	initialText = noteText.trim();
 
-	async created() {
-		const urlParams = new URLSearchParams(window.location.search);
+	if (visibility === 'specified') {
+		const visibleUserIds = urlParams.get('visibleUserIds');
+		const visibleAccts = urlParams.get('visibleAccts');
+		await Promise.all(
+			[
+				...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
+				...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []),
+			]
+			// TypeScriptの指示通りに変換する
+			.map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
+			.map(q => os.api('users/show', q)
+				.then(user => {
+					visibleUsers.push(user);
+				}, () => {
+					console.error(`Invalid user query: ${JSON.stringify(q)}`);
+				}),
+			),
+		);
+	}
 
-		this.title = urlParams.get('title');
-		const text = urlParams.get('text');
-		const url = urlParams.get('url');
-
-		let noteText = '';
-		if (this.title) noteText += `[ ${this.title} ]\n`;
-		// Googleニュース対策
-		if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, '');
-		else if (text && this.title !== text) noteText += `${text}\n`;
-		if (url) noteText += `${url}`;
-		this.initialText = noteText.trim();
-
-		const visibility = urlParams.get('visibility');
-		if (noteVisibilities.includes(visibility)) {
-			this.visibility = visibility;
+	try {
+		//#region Reply
+		const replyId = urlParams.get('replyId');
+		const replyUri = urlParams.get('replyUri');
+		if (replyId) {
+			reply = await os.api('notes/show', {
+				noteId: replyId,
+			});
+		} else if (replyUri) {
+			const obj = await os.api('ap/show', {
+				uri: replyUri,
+			});
+			if (obj.type === 'Note') {
+				reply = obj.object;
+			}
 		}
+		//#endregion
 
-		if (this.visibility === 'specified') {
-			const visibleUserIds = urlParams.get('visibleUserIds');
-			const visibleAccts = urlParams.get('visibleAccts');
+		//#region Renote
+		const renoteId = urlParams.get('renoteId');
+		const renoteUri = urlParams.get('renoteUri');
+		if (renoteId) {
+			renote = await os.api('notes/show', {
+				noteId: renoteId,
+			});
+		} else if (renoteUri) {
+			const obj = await os.api('ap/show', {
+				uri: renoteUri,
+			});
+			if (obj.type === 'Note') {
+				renote = obj.object;
+			}
+		}
+		//#endregion
+
+		//#region Drive files
+		const fileIds = urlParams.get('fileIds');
+		if (fileIds) {
 			await Promise.all(
-				[
-					...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
-					...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : [])
-				]
-				// TypeScriptの指示通りに変換する
-				.map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
-				.map(q => os.api('users/show', q)
-					.then(user => {
-						this.visibleUsers.push(user);
+				fileIds.split(',')
+				.map(fileId => os.api('drive/files/show', { fileId })
+					.then(file => {
+						files.push(file);
 					}, () => {
-						console.error(`Invalid user query: ${JSON.stringify(q)}`);
-					})
-				)
+						console.error(`Failed to fetch a file ${fileId}`);
+					}),
+				),
 			);
 		}
-
-		const localOnly = urlParams.get('localOnly');
-		if (localOnly === '0') this.localOnly = false;
-		else if (localOnly === '1') this.localOnly = true;
-
-		try {
-			//#region Reply
-			const replyId = urlParams.get('replyId');
-			const replyUri = urlParams.get('replyUri');
-			if (replyId) {
-				this.reply = await os.api('notes/show', {
-					noteId: replyId
-				});
-			} else if (replyUri) {
-				const obj = await os.api('ap/show', {
-					uri: replyUri
-				});
-				if (obj.type === 'Note') {
-					this.reply = obj.object;
-				}
-			}
-			//#endregion
-
-			//#region Renote
-			const renoteId = urlParams.get('renoteId');
-			const renoteUri = urlParams.get('renoteUri');
-			if (renoteId) {
-				this.renote = await os.api('notes/show', {
-					noteId: renoteId
-				});
-			} else if (renoteUri) {
-				const obj = await os.api('ap/show', {
-					uri: renoteUri
-				});
-				if (obj.type === 'Note') {
-					this.renote = obj.object;
-				}
-			}
-			//#endregion
-
-			//#region Drive files
-			const fileIds = urlParams.get('fileIds');
-			if (fileIds) {
-				await Promise.all(
-					fileIds.split(',')
-					.map(fileId => os.api('drive/files/show', { fileId })
-						.then(file => {
-							this.files.push(file);
-						}, () => {
-							console.error(`Failed to fetch a file ${fileId}`);
-						})
-					)
-				);
-			}
-			//#endregion
-		} catch (err) {
-			os.alert({
-				type: 'error',
-				title: err.message,
-				text: err.name
-			});
-		}
-
-		this.state = 'writing';
-	},
-
-	methods: {
-		close() {
-			window.close();
-
-			// 閉じなければ100ms後タイムラインに
-			window.setTimeout(() => {
-				this.$router.push('/');
-			}, 100);
-		}
+		//#endregion
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			title: err.message,
+			text: err.name,
+		});
 	}
+
+	state = 'writing';
+}
+
+init();
+
+function close(): void {
+	window.close();
+
+	// 閉じなければ100ms後タイムラインに
+	window.setTimeout(() => {
+		mainRouter.push('/');
+	}, 100);
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.share,
+	icon: 'fas fa-share-alt',
 });
 </script>
 
diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue
index 344c9195f7..a97990c129 100644
--- a/packages/client/src/pages/signup-complete.vue
+++ b/packages/client/src/pages/signup-complete.vue
@@ -7,9 +7,9 @@
 <script lang="ts" setup>
 import { onMounted } from 'vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { login } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	code: string;
@@ -26,11 +26,13 @@ onMounted(async () => {
 	login(res.i, '/');
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.signup,
-		icon: 'fas fa-user',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.signup,
+	icon: 'fas fa-user',
 });
 </script>
 
diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue
index 045f1ef259..d63864ed5c 100644
--- a/packages/client/src/pages/tag.vue
+++ b/packages/client/src/pages/tag.vue
@@ -7,7 +7,7 @@
 <script lang="ts" setup>
 import { computed } from 'vue';
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	tag: string;
@@ -21,11 +21,13 @@ const pagination = {
 	})),
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: props.tag,
-		icon: 'fas fa-hashtag',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: props.tag,
+	icon: 'fas fa-hashtag',
+	bg: 'var(--bg)',
+})));
 </script>
diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue
index 2a11c07fd2..38f3b90a6e 100644
--- a/packages/client/src/pages/theme-editor.vue
+++ b/packages/client/src/pages/theme-editor.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
 	<div class="cwepdizn _formRoot">
 		<FormFolder :default-open="true" class="_formBlock">
 			<template #label>{{ i18n.ts.backgroundColor }}</template>
@@ -61,7 +62,7 @@
 			</div>
 		</FormFolder>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -82,7 +83,6 @@ import { host } from '@/config';
 import * as os from '@/os';
 import { ColdDeviceStorage, defaultStore } from '@/store';
 import { addTheme } from '@/theme-store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { useLeaveGuard } from '@/scripts/use-leave-guard';
 
@@ -204,23 +204,25 @@ async function saveAs() {
 
 watch($$(theme), apply, { deep: true });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.themeEditor,
-		icon: 'fas fa-palette',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-eye',
-			text: i18n.ts.preview,
-			handler: showPreview,
-		}, {
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.saveAs,
-			handler: saveAs,
-		}],
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.themeEditor,
+	icon: 'fas fa-palette',
+	bg: 'var(--bg)',
+	actions: [{
+		asFullButton: true,
+		icon: 'fas fa-eye',
+		text: i18n.ts.preview,
+		handler: showPreview,
+	}, {
+		asFullButton: true,
+		icon: 'fas fa-check',
+		text: i18n.ts.saveAs,
+		handler: saveAs,
+	}],
 });
 </script>
 
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
index fe3dbc3cff..e0f0a1f151 100644
--- a/packages/client/src/pages/timeline.vue
+++ b/packages/client/src/pages/timeline.vue
@@ -1,39 +1,37 @@
 <template>
-<MkSpacer :content-max="800">
-	<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
-		<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
-		<XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
+			<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
+			<XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
 
-		<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
-		<div class="tl _block">
-			<XTimeline ref="tl" :key="src"
-				class="tl"
-				:src="src"
-				:sound="true"
-				@queue="queueUpdated"
-			/>
+			<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+			<div class="tl _block">
+				<XTimeline
+					ref="tl" :key="src"
+					class="tl"
+					:src="src"
+					:sound="true"
+					@queue="queueUpdated"
+				/>
+			</div>
 		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-export default {
-	name: 'MkTimelinePage',
-};
-</script>
-
 <script lang="ts" setup>
 import { defineAsyncComponent, computed, watch } from 'vue';
 import XTimeline from '@/components/timeline.vue';
 import XPostForm from '@/components/post-form.vue';
 import { scroll } from '@/scripts/scroll';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { defaultStore } from '@/store';
 import { i18n } from '@/i18n';
 import { instance } from '@/instance';
 import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue'));
 
@@ -111,55 +109,55 @@ function focus(): void {
 	tlComponent.focus();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.timeline,
-		icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home',
-		bg: 'var(--bg)',
-		actions: [{
-			icon: 'fas fa-list-ul',
-			text: i18n.ts.lists,
-			handler: chooseList,
-		}, {
-			icon: 'fas fa-satellite',
-			text: i18n.ts.antennas,
-			handler: chooseAntenna,
-		}, {
-			icon: 'fas fa-satellite-dish',
-			text: i18n.ts.channel,
-			handler: chooseChannel,
-		}, {
-			icon: 'fas fa-calendar-alt',
-			text: i18n.ts.jumpToSpecifiedDate,
-			handler: timetravel,
-		}],
-		tabs: [{
-			active: src === 'home',
-			title: i18n.ts._timelines.home,
-			icon: 'fas fa-home',
-			iconOnly: true,
-			onClick: () => { saveSrc('home'); },
-		}, ...(isLocalTimelineAvailable ? [{
-			active: src === 'local',
-			title: i18n.ts._timelines.local,
-			icon: 'fas fa-comments',
-			iconOnly: true,
-			onClick: () => { saveSrc('local'); },
-		}, {
-			active: src === 'social',
-			title: i18n.ts._timelines.social,
-			icon: 'fas fa-share-alt',
-			iconOnly: true,
-			onClick: () => { saveSrc('social'); },
-		}] : []), ...(isGlobalTimelineAvailable ? [{
-			active: src === 'global',
-			title: i18n.ts._timelines.global,
-			icon: 'fas fa-globe',
-			iconOnly: true,
-			onClick: () => { saveSrc('global'); },
-		}] : [])],
-	})),
-});
+const headerActions = $computed(() => [{
+	icon: 'fas fa-list-ul',
+	text: i18n.ts.lists,
+	handler: chooseList,
+}, {
+	icon: 'fas fa-satellite',
+	text: i18n.ts.antennas,
+	handler: chooseAntenna,
+}, {
+	icon: 'fas fa-satellite-dish',
+	text: i18n.ts.channel,
+	handler: chooseChannel,
+}, {
+	icon: 'fas fa-calendar-alt',
+	text: i18n.ts.jumpToSpecifiedDate,
+	handler: timetravel,
+}]);
+
+const headerTabs = $computed(() => [{
+	active: src === 'home',
+	title: i18n.ts._timelines.home,
+	icon: 'fas fa-home',
+	iconOnly: true,
+	onClick: () => { saveSrc('home'); },
+}, ...(isLocalTimelineAvailable ? [{
+	active: src === 'local',
+	title: i18n.ts._timelines.local,
+	icon: 'fas fa-comments',
+	iconOnly: true,
+	onClick: () => { saveSrc('local'); },
+}, {
+	active: src === 'social',
+	title: i18n.ts._timelines.social,
+	icon: 'fas fa-share-alt',
+	iconOnly: true,
+	onClick: () => { saveSrc('social'); },
+}] : []), ...(isGlobalTimelineAvailable ? [{
+	active: src === 'global',
+	title: i18n.ts._timelines.global,
+	icon: 'fas fa-globe',
+	iconOnly: true,
+	onClick: () => { saveSrc('global'); },
+}] : [])]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.timeline,
+	icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
index 54e1f13021..6dc25e99d6 100644
--- a/packages/client/src/pages/user-info.vue
+++ b/packages/client/src/pages/user-info.vue
@@ -1,71 +1,75 @@
 <template>
-<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<div class="_formBlock aeakzknw">
-				<MkAvatar class="avatar" :user="user" :show-indicator="true"/>
-			</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<div class="_formBlock aeakzknw">
+					<MkAvatar class="avatar" :user="user" :show-indicator="true"/>
+				</div>
 
-			<FormLink :to="userPage(user)">Profile</FormLink>
-
-			<div class="_formBlock">
-				<MkKeyValue :copy="acct(user)" oneline style="margin: 1em 0;">
-					<template #key>Acct</template>
-					<template #value><span class="_monospace">{{ acct(user) }}</span></template>
-				</MkKeyValue>
-
-				<MkKeyValue :copy="user.id" oneline style="margin: 1em 0;">
-					<template #key>ID</template>
-					<template #value><span class="_monospace">{{ user.id }}</span></template>
-				</MkKeyValue>
-			</div>
-
-			<FormSection v-if="iAmModerator">
-				<template #label>Moderation</template>
-				<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch>
-				<FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch>
-				<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch>
-				{{ $ts.reflectMayTakeTime }}
-				<FormButton v-if="user.host == null && iAmModerator" class="_formBlock" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
-			</FormSection>
-
-			<FormSection>
-				<template #label>ActivityPub</template>
+				<FormLink :to="userPage(user)">Profile</FormLink>
 
 				<div class="_formBlock">
-					<MkKeyValue v-if="user.host" oneline style="margin: 1em 0;">
-						<template #key>{{ $ts.instanceInfo }}</template>
-						<template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template>
+					<MkKeyValue :copy="acct(user)" oneline style="margin: 1em 0;">
+						<template #key>Acct</template>
+						<template #value><span class="_monospace">{{ acct(user) }}</span></template>
 					</MkKeyValue>
-					<MkKeyValue v-else oneline style="margin: 1em 0;">
-						<template #key>{{ $ts.instanceInfo }}</template>
-						<template #value>(Local user)</template>
-					</MkKeyValue>
-					<MkKeyValue oneline style="margin: 1em 0;">
-						<template #key>{{ $ts.updatedAt }}</template>
-						<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
-					</MkKeyValue>
-					<MkKeyValue v-if="ap" oneline style="margin: 1em 0;">
-						<template #key>Type</template>
-						<template #value><span class="_monospace">{{ ap.type }}</span></template>
+
+					<MkKeyValue :copy="user.id" oneline style="margin: 1em 0;">
+						<template #key>ID</template>
+						<template #value><span class="_monospace">{{ user.id }}</span></template>
 					</MkKeyValue>
 				</div>
 
-				<FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
-			</FormSection>
+				<FormSection v-if="iAmModerator">
+					<template #label>Moderation</template>
+					<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch>
+					<FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch>
+					<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch>
+					{{ $ts.reflectMayTakeTime }}
+					<FormButton v-if="user.host == null && iAmModerator" class="_formBlock" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
+				</FormSection>
 
-			<MkObjectView v-if="info && $i.isAdmin" tall :value="info">
-			</MkObjectView>
+				<FormSection>
+					<template #label>ActivityPub</template>
 
-			<MkObjectView tall :value="user">
-			</MkObjectView>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+					<div class="_formBlock">
+						<MkKeyValue v-if="user.host" oneline style="margin: 1em 0;">
+							<template #key>{{ $ts.instanceInfo }}</template>
+							<template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template>
+						</MkKeyValue>
+						<MkKeyValue v-else oneline style="margin: 1em 0;">
+							<template #key>{{ $ts.instanceInfo }}</template>
+							<template #value>(Local user)</template>
+						</MkKeyValue>
+						<MkKeyValue oneline style="margin: 1em 0;">
+							<template #key>{{ $ts.updatedAt }}</template>
+							<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+						</MkKeyValue>
+						<MkKeyValue v-if="ap" oneline style="margin: 1em 0;">
+							<template #key>Type</template>
+							<template #value><span class="_monospace">{{ ap.type }}</span></template>
+						</MkKeyValue>
+					</div>
+
+					<FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
+				</FormSection>
+
+				<MkObjectView v-if="info && $i.isAdmin" tall :value="info">
+				</MkObjectView>
+
+				<MkObjectView tall :value="user">
+				</MkObjectView>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineAsyncComponent, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, defineComponent, watch } from 'vue';
+import * as misskey from 'misskey-js';
 import MkObjectView from '@/components/object-view.vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormSwitch from '@/components/form/switch.vue';
@@ -77,174 +81,145 @@ import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
 import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
 import { url } from '@/config';
 import { userPage, acct } from '@/filters/user';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { iAmModerator } from '@/account';
 
-export default defineComponent({
-	components: {
-		FormSection,
-		FormTextarea,
-		FormSwitch,
-		MkObjectView,
-		FormButton,
-		FormLink,
-		MkKeyValue,
-		FormSuspense,
-	},
+const props = defineProps<{
+	userId: string;
+}>();
 
-	props: {
-		userId: {
-			type: String,
-			required: true
-		}
-	},
+let user = $ref<null | misskey.entities.UserDetailed>();
+let init = $ref();
+let info = $ref();
+let ap = $ref(null);
+let moderator = $ref(false);
+let silenced = $ref(false);
+let suspended = $ref(false);
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.user ? acct(this.user) : this.$ts.userInfo,
-				icon: 'fas fa-info-circle',
-				bg: 'var(--bg)',
-				actions: this.user ? [this.user.url ? {
-					text: this.user.url,
-					icon: 'fas fa-external-link-alt',
-					handler: () => {
-						window.open(this.user.url, '_blank');
-					}
-				} : undefined].filter(x => x !== undefined) : [],
-			})),
-			init: null,
-			user: null,
-			info: null,
-			ap: null,
-			moderator: false,
-			silenced: false,
-			suspended: false,
-		};
-	},
-
-	computed: {
-		iAmModerator(): boolean {
-			return this.$i && (this.$i.isAdmin || this.$i.isModerator);
-		}
-	},
-
-	watch: {
-		userId: {
-			handler() {
-				this.init = this.createFetcher();
-			},
-			immediate: true
-		},
-		user() {
-			os.api('ap/get', {
-				uri: this.user.uri || `${url}/users/${this.user.id}`
-			}).then(res => {
-				this.ap = res;
-			});
-		}
-	},
-
-	methods: {
-		number,
-		bytes,
-		userPage,
-		acct,
-
-		createFetcher() {
-			if (this.iAmModerator) {
-				return () => Promise.all([os.api('users/show', {
-					userId: this.userId
-				}), os.api('admin/show-user', {
-					userId: this.userId
-				})]).then(([user, info]) => {
-					this.user = user;
-					this.info = info;
-					this.moderator = this.info.isModerator;
-					this.silenced = this.info.isSilenced;
-					this.suspended = this.info.isSuspended;
-				});
-			} else {
-				return () => os.api('users/show', {
-					userId: this.userId
-				}).then((user) => {
-					this.user = user;
-				});
-			}
-		},
-
-		refreshUser() {
-			this.init = this.createFetcher();
-		},
-
-		async updateRemoteUser() {
-			await os.apiWithDialog('federation/update-remote-user', { userId: this.user.id });
-			this.refreshUser();
-		},
-
-		async resetPassword() {
-			const { password } = await os.api('admin/reset-password', {
-				userId: this.user.id,
-			});
-
-			os.alert({
-				type: 'success',
-				text: this.$t('newPasswordIs', { password })
-			});
-		},
-
-		async toggleSilence(v) {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm,
-			});
-			if (confirm.canceled) {
-				this.silenced = !v;
-			} else {
-				await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
-				await this.refreshUser();
-			}
-		},
-
-		async toggleSuspend(v) {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm,
-			});
-			if (confirm.canceled) {
-				this.suspended = !v;
-			} else {
-				await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
-				await this.refreshUser();
-			}
-		},
-
-		async toggleModerator(v) {
-			await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
-			await this.refreshUser();
-		},
-
-		async deleteAllFiles() {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: this.$ts.deleteAllFilesConfirm,
-			});
-			if (confirm.canceled) return;
-			const process = async () => {
-				await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
-				os.success();
-			};
-			await process().catch(err => {
-				os.alert({
-					type: 'error',
-					text: err.toString(),
-				});
-			});
-			await this.refreshUser();
-		},
+function createFetcher() {
+	if (iAmModerator) {
+		return () => Promise.all([os.api('users/show', {
+			userId: props.userId,
+		}), os.api('admin/show-user', {
+			userId: props.userId,
+		})]).then(([_user, _info]) => {
+			user = _user;
+			info = _info;
+			moderator = info.isModerator;
+			silenced = info.isSilenced;
+			suspended = info.isSuspended;
+		});
+	} else {
+		return () => os.api('users/show', {
+			userId: props.userId,
+		}).then((res) => {
+			user = res;
+		});
 	}
+}
+
+function refreshUser() {
+	init = createFetcher();
+}
+
+async function updateRemoteUser() {
+	await os.apiWithDialog('federation/update-remote-user', { userId: user.id });
+	refreshUser();
+}
+
+async function resetPassword() {
+	const { password } = await os.api('admin/reset-password', {
+		userId: user.id,
+	});
+
+	os.alert({
+		type: 'success',
+		text: i18n.t('newPasswordIs', { password }),
+	});
+}
+
+async function toggleSilence(v) {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm,
+	});
+	if (confirm.canceled) {
+		silenced = !v;
+	} else {
+		await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id });
+		await refreshUser();
+	}
+}
+
+async function toggleSuspend(v) {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm,
+	});
+	if (confirm.canceled) {
+		suspended = !v;
+	} else {
+		await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id });
+		await refreshUser();
+	}
+}
+
+async function toggleModerator(v) {
+	await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id });
+	await refreshUser();
+}
+
+async function deleteAllFiles() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteAllFilesConfirm,
+	});
+	if (confirm.canceled) return;
+	const process = async () => {
+		await os.api('admin/delete-all-files-of-a-user', { userId: user.id });
+		os.success();
+	};
+	await process().catch(err => {
+		os.alert({
+			type: 'error',
+			text: err.toString(),
+		});
+	});
+	await refreshUser();
+}
+
+watch(() => props.userId, () => {
+	init = createFetcher();
+}, {
+	immediate: true,
 });
+
+watch(() => user, () => {
+	os.api('ap/get', {
+		uri: user.uri || `${url}/users/${user.id}`,
+	}).then(res => {
+		ap = res;
+	});
+});
+
+const headerActions = $computed(() => user && user.url ? [{
+	text: user.url,
+	icon: 'fas fa-external-link-alt',
+	handler: () => {
+		window.open(user.url, '_blank');
+	},
+}] : []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: user ? acct(user) : i18n.ts.userInfo,
+	icon: 'fas fa-info-circle',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue
index 4476567cfb..9783088a19 100644
--- a/packages/client/src/pages/user-list-timeline.vue
+++ b/packages/client/src/pages/user-list-timeline.vue
@@ -1,8 +1,9 @@
 <template>
-<div v-hotkey.global="keymap" v-size="{ min: [800] }" class="eqqrhokj">
+<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="eqqrhokj">
 	<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
 	<div class="tl _block">
-		<XTimeline ref="tl" :key="listId"
+		<XTimeline
+			ref="tlEl" :key="listId"
 			class="tl"
 			src="list"
 			:list="listId"
@@ -13,92 +14,71 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { computed, watch, inject } from 'vue';
 import XTimeline from '@/components/timeline.vue';
 import { scroll } from '@/scripts/scroll';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		XTimeline,
-	},
+const router = useRouter();
 
-	props: {
-		listId: {
-			type: String,
-			required: true
-		}
-	},
+const props = defineProps<{
+	listId: string;
+}>();
 
-	data() {
-		return {
-			list: null,
-			queue: 0,
-			[symbols.PAGE_INFO]: computed(() => this.list ? {
-				title: this.list.name,
-				icon: 'fas fa-list-ul',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-calendar-alt',
-					text: this.$ts.jumpToSpecifiedDate,
-					handler: this.timetravel
-				}, {
-					icon: 'fas fa-cog',
-					text: this.$ts.settings,
-					handler: this.settings
-				}],
-			} : null),
-		};
-	},
+let list = $ref(null);
+let queue = $ref(0);
+let tlEl = $ref<InstanceType<typeof XTimeline>>();
+let rootEl = $ref<HTMLElement>();
 
-	computed: {
-		keymap(): any {
-			return {
-				't': this.focus
-			};
-		},
-	},
+watch(() => props.listId, async () => {
+	list = await os.api('users/lists/show', {
+		listId: props.listId,
+	});
+}, { immediate: true });
 
-	watch: {
-		listId: {
-			async handler() {
-				this.list = await os.api('users/lists/show', {
-					listId: this.listId
-				});
-			},
-			immediate: true
-		}
-	},
+function queueUpdated(q) {
+	queue = q;
+}
 
-	methods: {
-		queueUpdated(q) {
-			this.queue = q;
-		},
+function top() {
+	scroll(rootEl, { top: 0 });
+}
 
-		top() {
-			scroll(this.$el, { top: 0 });
-		},
+function settings() {
+	router.push(`/my/lists/${props.listId}`);
+}
 
-		settings() {
-			this.$router.push(`/my/lists/${this.listId}`);
-		},
+async function timetravel() {
+	const { canceled, result: date } = await os.inputDate({
+		title: i18n.ts.date,
+	});
+	if (canceled) return;
 
-		async timetravel() {
-			const { canceled, result: date } = await os.inputDate({
-				title: this.$ts.date,
-			});
-			if (canceled) return;
+	tlEl.timetravel(date);
+}
 
-			this.$refs.tl.timetravel(date);
-		},
+const headerActions = $computed(() => []);
 
-		focus() {
-			(this.$refs.tl as any).focus();
-		}
-	}
-});
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+	title: list.name,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
+	actions: [{
+		icon: 'fas fa-calendar-alt',
+		text: i18n.ts.jumpToSpecifiedDate,
+		handler: timetravel,
+	}, {
+		icon: 'fas fa-cog',
+		text: i18n.ts.settings,
+		handler: settings,
+	}],
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue
index a024dd28bc..7b2e2cde1a 100644
--- a/packages/client/src/pages/user/index.vue
+++ b/packages/client/src/pages/user/index.vue
@@ -1,131 +1,136 @@
 <template>
-<div>
-	<transition name="fade" mode="out-in">
-		<MkSpacer v-if="user" :content-max="narrow ? 800 : 1100">
-			<div v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
-				<div class="main">
-					<!-- TODO -->
-					<!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
-					<!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> -->
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div ref="rootEl">
+		<transition name="fade" mode="out-in">
+			<MkSpacer v-if="user" :content-max="narrow ? 800 : 1100">
+				<div v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
+					<div class="main">
+						<!-- TODO -->
+						<!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
+						<!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> -->
 
-					<div class="profile">
-						<MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
+						<div class="profile">
+							<MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
 
-						<div :key="user.id" class="_block main">
-							<div class="banner-container" :style="style">
-								<div ref="banner" class="banner" :style="style"></div>
-								<div class="fade"></div>
+							<div :key="user.id" class="_block main">
+								<div class="banner-container" :style="style">
+									<div ref="bannerEl" class="banner" :style="style"></div>
+									<div class="fade"></div>
+									<div class="title">
+										<MkUserName class="name" :user="user" :nowrap="true"/>
+										<div class="bottom">
+											<span class="username"><MkAcct :user="user" :detail="true"/></span>
+											<span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+											<span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+											<span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+											<span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+										</div>
+									</div>
+									<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
+									<div v-if="$i" class="actions">
+										<button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button>
+										<MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+									</div>
+								</div>
+								<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
 								<div class="title">
-									<MkUserName class="name" :user="user" :nowrap="true"/>
+									<MkUserName :user="user" :nowrap="false" class="name"/>
 									<div class="bottom">
-										<span class="username"><MkAcct :user="user" :detail="true" /></span>
+										<span class="username"><MkAcct :user="user" :detail="true"/></span>
 										<span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
 										<span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
 										<span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
 										<span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
 									</div>
 								</div>
-								<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
-								<div v-if="$i" class="actions">
-									<button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button>
-									<MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+								<div class="description">
+									<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+									<p v-else class="empty">{{ $ts.noAccountDescription }}</p>
 								</div>
-							</div>
-							<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
-							<div class="title">
-								<MkUserName :user="user" :nowrap="false" class="name"/>
-								<div class="bottom">
-									<span class="username"><MkAcct :user="user" :detail="true" /></span>
-									<span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
-									<span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
-									<span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
-									<span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+								<div class="fields system">
+									<dl v-if="user.location" class="field">
+										<dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
+										<dd class="value">{{ user.location }}</dd>
+									</dl>
+									<dl v-if="user.birthday" class="field">
+										<dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
+										<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+									</dl>
+									<dl class="field">
+										<dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
+										<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+									</dl>
+								</div>
+								<div v-if="user.fields.length > 0" class="fields">
+									<dl v-for="(field, i) in user.fields" :key="i" class="field">
+										<dt class="name">
+											<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
+										</dt>
+										<dd class="value">
+											<Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
+										</dd>
+									</dl>
+								</div>
+								<div class="status">
+									<MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
+										<b>{{ number(user.notesCount) }}</b>
+										<span>{{ $ts.notes }}</span>
+									</MkA>
+									<MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
+										<b>{{ number(user.followingCount) }}</b>
+										<span>{{ $ts.following }}</span>
+									</MkA>
+									<MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
+										<b>{{ number(user.followersCount) }}</b>
+										<span>{{ $ts.followers }}</span>
+									</MkA>
 								</div>
-							</div>
-							<div class="description">
-								<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
-								<p v-else class="empty">{{ $ts.noAccountDescription }}</p>
-							</div>
-							<div class="fields system">
-								<dl v-if="user.location" class="field">
-									<dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
-									<dd class="value">{{ user.location }}</dd>
-								</dl>
-								<dl v-if="user.birthday" class="field">
-									<dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
-									<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
-								</dl>
-								<dl class="field">
-									<dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
-									<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
-								</dl>
-							</div>
-							<div v-if="user.fields.length > 0" class="fields">
-								<dl v-for="(field, i) in user.fields" :key="i" class="field">
-									<dt class="name">
-										<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
-									</dt>
-									<dd class="value">
-										<Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
-									</dd>
-								</dl>
-							</div>
-							<div class="status">
-								<MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
-									<b>{{ number(user.notesCount) }}</b>
-									<span>{{ $ts.notes }}</span>
-								</MkA>
-								<MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
-									<b>{{ number(user.followingCount) }}</b>
-									<span>{{ $ts.following }}</span>
-								</MkA>
-								<MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
-									<b>{{ number(user.followersCount) }}</b>
-									<span>{{ $ts.followers }}</span>
-								</MkA>
 							</div>
 						</div>
-					</div>
 
-					<div class="contents">
-						<template v-if="page === 'index'">
-							<div>
-								<div v-if="user.pinnedNotes.length > 0" class="_gap">
-									<XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/>
+						<div class="contents">
+							<template v-if="page === 'index'">
+								<div>
+									<div v-if="user.pinnedNotes.length > 0" class="_gap">
+										<XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/>
+									</div>
+									<MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
+									<template v-if="narrow">
+										<XPhotos :key="user.id" :user="user"/>
+										<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
+									</template>
 								</div>
-								<MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
-								<template v-if="narrow">
-									<XPhotos :key="user.id" :user="user"/>
-									<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
-								</template>
-							</div>
-							<div>
-								<XUserTimeline :user="user"/>
-							</div>
-						</template>
-						<XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/>
-						<XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
-						<XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/>
-						<XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
-						<XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
-						<XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
+								<div>
+									<XUserTimeline :user="user"/>
+								</div>
+							</template>
+							<XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/>
+							<XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
+							<XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/>
+							<XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
+							<XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
+							<XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
+						</div>
+					</div>
+					<div v-if="!narrow" class="sub">
+						<XPhotos :key="user.id" :user="user"/>
+						<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
 					</div>
 				</div>
-				<div v-if="!narrow" class="sub">
-					<XPhotos :key="user.id" :user="user"/>
-					<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
-				</div>
-			</div>
-		</MkSpacer>
-		<MkError v-else-if="error" @retry="fetch()"/>
-		<MkLoading v-else/>
-	</transition>
-</div>
+			</MkSpacer>
+			<MkError v-else-if="error" @retry="fetch()"/>
+			<MkLoading v-else/>
+		</transition>
+	</div>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
-import age from 's-age';
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import calcAge from 's-age';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
 import XUserTimeline from './index.timeline.vue';
 import XNote from '@/components/note.vue';
 import MkFollowButton from '@/components/follow-button.vue';
@@ -134,164 +139,140 @@ import MkFolder from '@/components/ui/folder.vue';
 import MkRemoteCaution from '@/components/remote-caution.vue';
 import MkTab from '@/components/tab.vue';
 import MkInfo from '@/components/ui/info.vue';
-import * as Acct from 'misskey-js/built/acct';
 import { getScrollPosition } from '@/scripts/scroll';
 import { getUserMenu } from '@/scripts/get-user-menu';
 import number from '@/filters/number';
 import { userPage, acct as getAcct } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
 
-export default defineComponent({
-	components: {
-		XUserTimeline,
-		XNote,
-		MkFollowButton,
-		MkContainer,
-		MkRemoteCaution,
-		MkFolder,
-		MkTab,
-		MkInfo,
-		XFollowList: defineAsyncComponent(() => import('./follow-list.vue')),
-		XReactions: defineAsyncComponent(() => import('./reactions.vue')),
-		XClips: defineAsyncComponent(() => import('./clips.vue')),
-		XPages: defineAsyncComponent(() => import('./pages.vue')),
-		XGallery: defineAsyncComponent(() => import('./gallery.vue')),
-		XPhotos: defineAsyncComponent(() => import('./index.photos.vue')),
-		XActivity: defineAsyncComponent(() => import('./index.activity.vue')),
-	},
+const XFollowList = defineAsyncComponent(() => import('./follow-list.vue'));
+const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
+const XClips = defineAsyncComponent(() => import('./clips.vue'));
+const XPages = defineAsyncComponent(() => import('./pages.vue'));
+const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
+const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
+const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
 
-	props: {
-		acct: {
-			type: String,
-			required: true
-		},
-		page: {
-			type: String,
-			required: false,
-			default: 'index'
-		}
-	},
+const props = withDefaults(defineProps<{
+	acct: string;
+	page?: string;
+}>(), {
+	page: 'index',
+});
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.user ? {
-				icon: 'fas fa-user',
-				title: this.user.name ? `${this.user.name} (@${this.user.username})` : `@${this.user.username}`,
-				subtitle: `@${getAcct(this.user)}`,
-				userName: this.user,
-				avatar: this.user,
-				path: `/@${this.user.username}`,
-				share: {
-					title: this.user.name,
-				},
-				bg: 'var(--bg)',
-				tabs: [{
-					active: this.page === 'index',
-					title: this.$ts.overview,
-					icon: 'fas fa-home',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user)); },
-				}, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{
-					active: this.page === 'reactions',
-					title: this.$ts.reaction,
-					icon: 'fas fa-laugh',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/reactions'); },
-				}] : [], {
-					active: this.page === 'clips',
-					title: this.$ts.clips,
-					icon: 'fas fa-paperclip',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/clips'); },
-				}, {
-					active: this.page === 'pages',
-					title: this.$ts.pages,
-					icon: 'fas fa-file-alt',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/pages'); },
-				}, {
-					active: this.page === 'gallery',
-					title: this.$ts.gallery,
-					icon: 'fas fa-icons',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/gallery'); },
-				}],
-			} : null),
-			user: null,
-			error: null,
-			parallaxAnimationId: null,
-			narrow: null,
-			mkNav: new MisskeyNavigator(),
-		};
-	},
+const router = useRouter();
 
-	computed: {
-		style(): any {
-			if (this.user.bannerUrl == null) return {};
-			return {
-				backgroundImage: `url(${ this.user.bannerUrl })`
-			};
-		},
+let user = $ref<null | misskey.entities.UserDetailed>(null);
+let error = $ref(null);
+let parallaxAnimationId = $ref<null | number>(null);
+let narrow = $ref<null | boolean>(null);
+let rootEl = $ref<null | HTMLElement>(null);
+let bannerEl = $ref<null | HTMLElement>(null);
 
-		age(): number {
-			return age(this.user.birthday);
-		}
-	},
+const style = $computed(() => {
+	if (user?.bannerUrl == null) return {};
+	return {
+		backgroundImage: `url(${ user.bannerUrl })`,
+	};
+});
 
-	watch: {
-		acct: 'fetch'
-	},
+const age = $computed(() => {
+	if (user == null) return null;
+	return calcAge(user.birthday);
+});
 
-	created() {
-		this.fetch();
-	},
+function fetchUser(): void {
+	if (props.acct == null) return;
+	user = null;
+	os.api('users/show', Acct.parse(props.acct)).then(u => {
+		user = u;
+	}).catch(err => {
+		error = err;
+	});
+}
 
-	mounted() {
-		window.requestAnimationFrame(this.parallaxLoop);
-		this.narrow = this.$el.clientWidth < 1000;
-	},
+watch(() => props.acct, fetchUser, {
+	immediate: true,
+});
 
-	beforeUnmount() {
-		window.cancelAnimationFrame(this.parallaxAnimationId);
-	},
+function menu(ev) {
+	os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target);
+}
 
-	methods: {
-		getAcct,
+function parallaxLoop() {
+	parallaxAnimationId = window.requestAnimationFrame(parallaxLoop);
+	parallax();
+}
 
-		fetch() {
-			if (this.acct == null) return;
-			this.user = null;
-			os.api('users/show', Acct.parse(this.acct)).then(user => {
-				this.user = user;
-			}).catch(err => {
-				this.error = err;
-			});
-		},
+function parallax() {
+	const banner = bannerEl as any;
+	if (banner == null) return;
 
-		menu(ev) {
-			os.popupMenu(getUserMenu(this.user), ev.currentTarget ?? ev.target);
-		},
+	const top = getScrollPosition(rootEl);
 
-		parallaxLoop() {
-			this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop);
-			this.parallax();
-		},
+	if (top < 0) return;
 
-		parallax() {
-			const banner = this.$refs.banner as any;
-			if (banner == null) return;
+	const z = 1.75; // 奥行き(小さいほど奥)
+	const pos = -(top / z);
+	banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
+}
 
-			const top = getScrollPosition(this.$el);
+onMounted(() => {
+	window.requestAnimationFrame(parallaxLoop);
+	narrow = rootEl!.clientWidth < 1000;
+});
 
-			if (top < 0) return;
-
-			const z = 1.75; // 奥行き(小さいほど奥)
-			const pos = -(top / z);
-			banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
-		},
-
-		number,
-
-		userPage
+onUnmounted(() => {
+	if (parallaxAnimationId) {
+		window.cancelAnimationFrame(parallaxAnimationId);
 	}
 });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => user ? [{
+	active: props.page === 'index',
+	title: i18n.ts.overview,
+	icon: 'fas fa-home',
+	onClick: () => { router.push('/@' + getAcct(user)); },
+}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{
+	active: props.page === 'reactions',
+	title: i18n.ts.reaction,
+	icon: 'fas fa-laugh',
+	onClick: () => { router.push('/@' + getAcct(user) + '/reactions'); },
+}] : [], {
+	active: props.page === 'clips',
+	title: i18n.ts.clips,
+	icon: 'fas fa-paperclip',
+	onClick: () => { router.push('/@' + getAcct(user) + '/clips'); },
+}, {
+	active: props.page === 'pages',
+	title: i18n.ts.pages,
+	icon: 'fas fa-file-alt',
+	onClick: () => { router.push('/@' + getAcct(user) + '/pages'); },
+}, {
+	active: props.page === 'gallery',
+	title: i18n.ts.gallery,
+	icon: 'fas fa-icons',
+	onClick: () => { router.push('/@' + getAcct(user) + '/gallery'); },
+}] : null);
+
+definePageMetadata(computed(() => user ? {
+	icon: 'fas fa-user',
+	title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+	subtitle: `@${getAcct(user)}`,
+	userName: user,
+	avatar: user,
+	path: `/@${user.username}`,
+	share: {
+		title: user.name,
+	},
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue
index 98808229da..a1c3fc2abb 100644
--- a/packages/client/src/pages/welcome.vue
+++ b/packages/client/src/pages/welcome.vue
@@ -11,7 +11,7 @@ import XSetup from './welcome.setup.vue';
 import XEntrance from './welcome.entrance.a.vue';
 import { instanceName } from '@/config';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let meta = $ref(null);
 
@@ -19,10 +19,12 @@ os.api('meta', { detail: true }).then(res => {
 	meta = res;
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: instanceName,
-		icon: null,
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: instanceName,
+	icon: null,
+})));
 </script>
diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts
index 7e16c35d2b..769d9cb2ac 100644
--- a/packages/client/src/router.ts
+++ b/packages/client/src/router.ts
@@ -1,147 +1,253 @@
-import { AsyncComponentLoader, defineAsyncComponent, markRaw } from 'vue';
-import { createRouter, createWebHistory } from 'vue-router';
-import { $i, iAmModerator } from './account';
+import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue';
+import { Router } from '@/nirax';
+import { $i, iAmModerator } from '@/account';
 import MkLoading from '@/pages/_loading_.vue';
 import MkError from '@/pages/_error_.vue';
-import MkTimeline from '@/pages/timeline.vue';
 import { ui } from '@/config';
 
-// pathに/が入るとrollupが解決してくれないので、() => import('*.vue')を指定すること
-const page = (path: string | AsyncComponentLoader<any>, uiName?: string) => defineAsyncComponent({
-	loader: typeof path === 'string' ? uiName ? () => import(`./ui/${ui}/pages/${path}.vue`) : () => import(`./pages/${path}.vue`) : path,
+const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
+	loader: loader,
 	loadingComponent: MkLoading,
 	errorComponent: MkError,
 });
 
-let indexScrollPos = 0;
-
-const defaultRoutes = [
-	// NOTE: MkTimelineをdynamic importするとAsyncComponentWrapperが間に入るせいでkeep-aliveのコンポーネント指定が効かなくなる
-	{ path: '/', name: 'index', component: $i ? MkTimeline : page('welcome') },
-	{ path: '/@:acct/:page?', name: 'user', component: page(() => import('./pages/user/index.vue')), props: route => ({ acct: route.params.acct, page: route.params.page || 'index' }) },
-	{ path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) },
-	{ path: '/@:user/pages/:pageName/view-source', component: page(() => import('./pages/page-editor/page-editor.vue')), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) },
-	{ path: '/settings/:page(.*)?', name: 'settings', component: page(() => import('./pages/settings/index.vue')), props: route => ({ initialPage: route.params.page || null }) },
-	{ path: '/reset-password/:token?', component: page('reset-password'), props: route => ({ token: route.params.token }) },
-	{ path: '/signup-complete/:code', component: page('signup-complete'), props: route => ({ code: route.params.code }) },
-	{ path: '/announcements', component: page('announcements') },
-	{ path: '/about', component: page('about') },
-	{ path: '/about-misskey', component: page('about-misskey') },
-	{ path: '/featured', component: page('featured') },
-	{ path: '/theme-editor', component: page('theme-editor') },
-	{ path: '/advanced-theme-editor', component: page('advanced-theme-editor') },
-	{ path: '/explore', component: page('explore') },
-	{ path: '/explore/tags/:tag', props: true, component: page('explore') },
-	{ path: '/federation', component: page('federation') },
-	{ path: '/emojis', component: page('emojis') },
-	{ path: '/search', component: page('search'), props: route => ({ query: route.query.q, channel: route.query.channel }) },
-	{ path: '/pages', name: 'pages', component: page('pages') },
-	{ path: '/pages/new', component: page(() => import('./pages/page-editor/page-editor.vue')) },
-	{ path: '/pages/edit/:pageId', component: page(() => import('./pages/page-editor/page-editor.vue')), props: route => ({ initPageId: route.params.pageId }) },
-	{ path: '/gallery', component: page(() => import('./pages/gallery/index.vue')) },
-	{ path: '/gallery/new', component: page(() => import('./pages/gallery/edit.vue')) },
-	{ path: '/gallery/:postId/edit', component: page(() => import('./pages/gallery/edit.vue')), props: route => ({ postId: route.params.postId }) },
-	{ path: '/gallery/:postId', component: page(() => import('./pages/gallery/post.vue')), props: route => ({ postId: route.params.postId }) },
-	{ path: '/channels', component: page('channels') },
-	{ path: '/channels/new', component: page('channel-editor') },
-	{ path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
-	{ path: '/channels/:channelId', component: page('channel'), props: route => ({ channelId: route.params.channelId }) },
-	{ path: '/clips/:clipId', component: page('clip'), props: route => ({ clipId: route.params.clipId }) },
-	{ path: '/timeline/list/:listId', component: page('user-list-timeline'), props: route => ({ listId: route.params.listId }) },
-	{ path: '/timeline/antenna/:antennaId', component: page('antenna-timeline'), props: route => ({ antennaId: route.params.antennaId }) },
-	{ path: '/my/notifications', component: page('notifications') },
-	{ path: '/my/favorites', component: page('favorites') },
-	{ path: '/my/messages', component: page('messages') },
-	{ path: '/my/mentions', component: page('mentions') },
-	{ path: '/my/messaging', name: 'messaging', component: page(() => import('./pages/messaging/index.vue')) },
-	{ path: '/my/messaging/:user', component: page(() => import('./pages/messaging/messaging-room.vue')), props: route => ({ userAcct: route.params.user }) },
-	{ path: '/my/messaging/group/:group', component: page(() => import('./pages/messaging/messaging-room.vue')), props: route => ({ groupId: route.params.group }) },
-	{ path: '/my/drive', name: 'drive', component: page('drive') },
-	{ path: '/my/drive/folder/:folder', component: page('drive') },
-	{ path: '/my/follow-requests', component: page('follow-requests') },
-	{ path: '/my/lists', component: page(() => import('./pages/my-lists/index.vue')) },
-	{ path: '/my/lists/:list', component: page(() => import('./pages/my-lists/list.vue')) },
-	{ path: '/my/groups', component: page(() => import('./pages/my-groups/index.vue')) },
-	{ path: '/my/groups/:group', component: page(() => import('./pages/my-groups/group.vue')), props: route => ({ groupId: route.params.group }) },
-	{ path: '/my/antennas', component: page(() => import('./pages/my-antennas/index.vue')) },
-	{ path: '/my/antennas/create', component: page(() => import('./pages/my-antennas/create.vue')) },
-	{ path: '/my/antennas/:antennaId', component: page(() => import('./pages/my-antennas/edit.vue')), props: true },
-	{ path: '/my/clips', component: page(() => import('./pages/my-clips/index.vue')) },
-	{ path: '/scratchpad', component: page('scratchpad') },
-	{ path: '/admin/:page(.*)?', component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page('not-found'), props: route => ({ initialPage: route.params.page || null }) },
-	{ path: '/admin', component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page('not-found') },
-	{ path: '/admin-file/:fileId', component: page('admin-file'), props: route => ({ fileId: route.params.fileId }) },
-	{ path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) },
-	{ path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) },
-	{ path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) },
-	{ path: '/instance-info/:host', component: page('instance-info'), props: route => ({ host: route.params.host }) },
-	{ path: '/mfm-cheat-sheet', component: page('mfm-cheat-sheet') },
-	{ path: '/api-console', component: page('api-console') },
-	{ path: '/preview', component: page('preview') },
-	{ path: '/test', component: page('test') },
-	{ path: '/auth/:token', component: page('auth') },
-	{ path: '/miauth/:session', component: page('miauth') },
-	{ path: '/authorize-follow', component: page('follow') },
-	{ path: '/share', component: page('share') },
-	{ path: '/:catchAll(.*)', component: page('not-found') },
-];
-
-const chatRoutes = [
-	{ path: '/timeline', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) },
-	{ path: '/timeline/home', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) },
-	{ path: '/timeline/local', component: page('timeline', 'chat'), props: route => ({ src: 'local' }) },
-	{ path: '/timeline/social', component: page('timeline', 'chat'), props: route => ({ src: 'social' }) },
-	{ path: '/timeline/global', component: page('timeline', 'chat'), props: route => ({ src: 'global' }) },
-	{ path: '/channels/:channelId', component: page('channel', 'chat'), props: route => ({ channelId: route.params.channelId }) },
-];
-
-function margeRoutes(routes: any[]) {
-	const result = defaultRoutes;
-	for (const route of routes) {
-		const found = result.findIndex(x => x.path === route.path);
-		if (found > -1) {
-			result[found] = route;
-		} else {
-			result.unshift(route);
-		}
-	}
-	return result;
-}
-
-export const router = createRouter({
-	history: createWebHistory(),
-	routes: margeRoutes(ui === 'chat' ? chatRoutes : []),
-	// なんかHacky
-	// 通常の使い方をすると scroll メソッドの behavior を設定できないため、自前で window.scroll するようにする
-	scrollBehavior(to) {
-		window._scroll = () => { // さらにHacky
-			if (to.name === 'index') {
-				window.scroll({ top: indexScrollPos, behavior: 'instant' });
-				const i = window.setInterval(() => {
-					window.scroll({ top: indexScrollPos, behavior: 'instant' });
-				}, 10);
-				window.setTimeout(() => {
-					window.clearInterval(i);
-				}, 500);
-			} else {
-				window.scroll({ top: 0, behavior: 'instant' });
-			}
-		};
+export const routes = [{
+	name: 'user',
+	path: '/@:acct/:page?',
+	component: page(() => import('./pages/user/index.vue')),
+}, {
+	path: '/@:initUser/pages/:initPageName/view-source',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+}, {
+	path: '/@:username/pages/:pageName',
+	component: page(() => import('./pages/page.vue')),
+}, {
+	name: 'note',
+	path: '/notes/:noteId',
+	component: page(() => import('./pages/note.vue')),
+}, {
+	path: '/clips/:clipId',
+	component: page(() => import('./pages/clip.vue')),
+}, {
+	path: '/user-info/:userId',
+	component: page(() => import('./pages/user-info.vue')),
+}, {
+	path: '/instance-info/:host',
+	component: page(() => import('./pages/instance-info.vue')),
+}, {
+	name: 'settings',
+	path: '/settings/:initialPage(*)?',
+	component: page(() => import('./pages/settings/index.vue')),
+}, {
+	path: '/reset-password/:token?',
+	component: page(() => import('./pages/reset-password.vue')),
+}, {
+	path: '/signup-complete/:code',
+	component: page(() => import('./pages/signup-complete.vue')),
+}, {
+	path: '/announcements',
+	component: page(() => import('./pages/announcements.vue')),
+}, {
+	path: '/about',
+	component: page(() => import('./pages/about.vue')),
+}, {
+	path: '/about-misskey',
+	component: page(() => import('./pages/about-misskey.vue')),
+}, {
+	path: '/featured',
+	component: page(() => import('./pages/featured.vue')),
+}, {
+	path: '/theme-editor',
+	component: page(() => import('./pages/theme-editor.vue')),
+}, {
+	path: '/explore/tags/:tag',
+	component: page(() => import('./pages/explore.vue')),
+}, {
+	path: '/explore',
+	component: page(() => import('./pages/explore.vue')),
+}, {
+	path: '/federation',
+	component: page(() => import('./pages/federation.vue')),
+}, {
+	path: '/emojis',
+	component: page(() => import('./pages/emojis.vue')),
+}, {
+	path: '/search',
+	component: page(() => import('./pages/search.vue')),
+	query: {
+		q: 'query',
+		channel: 'channel',
 	},
-});
+}, {
+	path: '/authorize-follow',
+	component: page(() => import('./pages/follow.vue')),
+}, {
+	path: '/share',
+	component: page(() => import('./pages/share.vue')),
+}, {
+	path: '/api-console',
+	component: page(() => import('./pages/api-console.vue')),
+}, {
+	path: '/mfm-cheat-sheet',
+	component: page(() => import('./pages/mfm-cheat-sheet.vue')),
+}, {
+	path: '/scratchpad',
+	component: page(() => import('./pages/scratchpad.vue')),
+}, {
+	path: '/preview',
+	component: page(() => import('./pages/preview.vue')),
+}, {
+	path: '/auth/:token',
+	component: page(() => import('./pages/auth.vue')),
+}, {
+	path: '/miauth/:session',
+	component: page(() => import('./pages/miauth.vue')),
+	query: {
+		callback: 'callback',
+		name: 'name',
+		icon: 'icon',
+		permission: 'permission',
+	},
+}, {
+	path: '/tags/:tag',
+	component: page(() => import('./pages/tag.vue')),
+}, {
+	path: '/pages/new',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+}, {
+	path: '/pages/edit/:initPageId',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+}, {
+	path: '/pages',
+	component: page(() => import('./pages/pages.vue')),
+}, {
+	path: '/gallery/:postId/edit',
+	component: page(() => import('./pages/gallery/edit.vue')),
+}, {
+	path: '/gallery/new',
+	component: page(() => import('./pages/gallery/edit.vue')),
+}, {
+	path: '/gallery/:postId',
+	component: page(() => import('./pages/gallery/post.vue')),
+}, {
+	path: '/gallery',
+	component: page(() => import('./pages/gallery/index.vue')),
+}, {
+	path: '/channels/:channelId/edit',
+	component: page(() => import('./pages/channel-editor.vue')),
+}, {
+	path: '/channels/new',
+	component: page(() => import('./pages/channel-editor.vue')),
+}, {
+	path: '/channels/:channelId',
+	component: page(() => import('./pages/channel.vue')),
+}, {
+	path: '/channels',
+	component: page(() => import('./pages/channels.vue')),
+}, {
+	path: '/admin/file/:fileId',
+	component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')),
+}, {
+	path: '/admin/:initialPage(*)?',
+	component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')),
+}, {
+	path: '/my/notifications',
+	component: page(() => import('./pages/notifications.vue')),
+}, {
+	path: '/my/favorites',
+	component: page(() => import('./pages/favorites.vue')),
+}, {
+	path: '/my/messages',
+	component: page(() => import('./pages/messages.vue')),
+}, {
+	path: '/my/mentions',
+	component: page(() => import('./pages/mentions.vue')),
+}, {
+	name: 'messaging',
+	path: '/my/messaging',
+	component: page(() => import('./pages/messaging/index.vue')),
+}, {
+	path: '/my/messaging/:userAcct',
+	component: page(() => import('./pages/messaging/messaging-room.vue')),
+}, {
+	path: '/my/messaging/group/:groupId',
+	component: page(() => import('./pages/messaging/messaging-room.vue')),
+}, {
+	path: '/my/drive/folder/:folder',
+	component: page(() => import('./pages/drive.vue')),
+}, {
+	path: '/my/drive',
+	component: page(() => import('./pages/drive.vue')),
+}, {
+	path: '/my/follow-requests',
+	component: page(() => import('./pages/follow-requests.vue')),
+}, {
+	path: '/my/lists/:listId',
+	component: page(() => import('./pages/my-lists/list.vue')),
+}, {
+	path: '/my/lists',
+	component: page(() => import('./pages/my-lists/index.vue')),
+}, {
+	path: '/my/clips',
+	component: page(() => import('./pages/my-clips/index.vue')),
+}, {
+	path: '/my/antennas/create',
+	component: page(() => import('./pages/my-antennas/create.vue')),
+}, {
+	path: '/my/antennas/:antennaId',
+	component: page(() => import('./pages/my-antennas/edit.vue')),
+}, {
+	path: '/my/antennas',
+	component: page(() => import('./pages/my-antennas/index.vue')),
+}, {
+	path: '/timeline/list/:listId',
+	component: page(() => import('./pages/user-list-timeline.vue')),
+}, {
+	path: '/timeline/antenna/:antennaId',
+	component: page(() => import('./pages/antenna-timeline.vue')),
+}, {
+	name: 'index',
+	path: '/',
+	component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')),
+	globalCacheKey: 'index',
+}, {
+	path: '/(*)',
+	component: page(() => import('./pages/not-found.vue')),
+}];
 
-router.afterEach((to, from) => {
-	if (from.name === 'index') {
-		indexScrollPos = window.scrollY;
+export const mainRouter = new Router(routes, location.pathname + location.search);
+
+window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href);
+
+// TODO: このファイルでスクロール位置も管理する設計だとdeckに対応できないのでなんとかする
+// スクロール位置取得+スクロール位置設定関数をprovideする感じでも良いかも
+
+const scrollPosStore = new Map<string, number>();
+
+window.setInterval(() => {
+	scrollPosStore.set(window.history.state?.key, window.scrollY);
+}, 1000);
+
+mainRouter.addListener('push', ctx => {
+	window.history.pushState({ key: ctx.key }, '', ctx.path);
+	const scrollPos = scrollPosStore.get(ctx.key) ?? 0;
+	window.scroll({ top: scrollPos, behavior: 'instant' });
+	if (scrollPos !== 0) {
+		window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
+			window.scroll({ top: scrollPos, behavior: 'instant' });
+		}, 1000);
 	}
 });
 
-export function resolve(path: string) {
-	const resolved = router.resolve(path);
-	const route = resolved.matched[0];
-	return {
-		component: markRaw(route.components.default),
-		// TODO: route.propsには関数以外も入る可能性があるのでよしなにハンドリングする
-		props: route.props.default ? route.props.default(resolved) : resolved.params,
-	};
+window.addEventListener('popstate', (event) => {
+	mainRouter.change(location.pathname + location.search, event.state?.key);
+	const scrollPos = scrollPosStore.get(event.state?.key) ?? 0;
+	window.scroll({ top: scrollPos, behavior: 'instant' });
+	window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
+		window.scroll({ top: scrollPos, behavior: 'instant' });
+	}, 1000);
+});
+
+export function useRouter(): Router {
+	return inject<Router | null>('router', null) ?? mainRouter;
 }
diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts
index 091338efd6..25bcd90e9f 100644
--- a/packages/client/src/scripts/get-user-menu.ts
+++ b/packages/client/src/scripts/get-user-menu.ts
@@ -1,12 +1,12 @@
+import * as Acct from 'misskey-js/built/acct';
+import { defineAsyncComponent } from 'vue';
 import { i18n } from '@/i18n';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
 import { host } from '@/config';
-import * as Acct from 'misskey-js/built/acct';
 import * as os from '@/os';
 import { userActions } from '@/store';
-import { router } from '@/router';
 import { $i, iAmModerator } from '@/account';
-import { defineAsyncComponent } from 'vue';
+import { mainRouter } from '@/router';
 
 export function getUserMenu(user) {
 	const meId = $i ? $i.id : null;
@@ -17,20 +17,20 @@ export function getUserMenu(user) {
 		if (lists.length === 0) {
 			os.alert({
 				type: 'error',
-				text: i18n.ts.youHaveNoLists
+				text: i18n.ts.youHaveNoLists,
 			});
 			return;
 		}
 		const { canceled, result: listId } = await os.select({
 			title: t,
 			items: lists.map(list => ({
-				value: list.id, text: list.name
-			}))
+				value: list.id, text: list.name,
+			})),
 		});
 		if (canceled) return;
 		os.apiWithDialog('users/lists/push', {
 			listId: listId,
-			userId: user.id
+			userId: user.id,
 		});
 	}
 
@@ -39,20 +39,20 @@ export function getUserMenu(user) {
 		if (groups.length === 0) {
 			os.alert({
 				type: 'error',
-				text: i18n.ts.youHaveNoGroups
+				text: i18n.ts.youHaveNoGroups,
 			});
 			return;
 		}
 		const { canceled, result: groupId } = await os.select({
 			title: i18n.ts.group,
 			items: groups.map(group => ({
-				value: group.id, text: group.name
-			}))
+				value: group.id, text: group.name,
+			})),
 		});
 		if (canceled) return;
 		os.apiWithDialog('users/groups/invite', {
 			groupId: groupId,
-			userId: user.id
+			userId: user.id,
 		});
 	}
 
@@ -101,7 +101,7 @@ export function getUserMenu(user) {
 		if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
 
 		os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
-			userId: user.id
+			userId: user.id,
 		}).then(() => {
 			user.isBlocking = !user.isBlocking;
 		});
@@ -111,7 +111,7 @@ export function getUserMenu(user) {
 		if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return;
 
 		os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', {
-			userId: user.id
+			userId: user.id,
 		}).then(() => {
 			user.isSilenced = !user.isSilenced;
 		});
@@ -121,7 +121,7 @@ export function getUserMenu(user) {
 		if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
 
 		os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', {
-			userId: user.id
+			userId: user.id,
 		}).then(() => {
 			user.isSuspended = !user.isSuspended;
 		});
@@ -145,7 +145,7 @@ export function getUserMenu(user) {
 
 	async function invalidateFollow() {
 		os.apiWithDialog('following/invalidate', {
-			userId: user.id
+			userId: user.id,
 		}).then(() => {
 			user.isFollowed = !user.isFollowed;
 		});
@@ -156,19 +156,19 @@ export function getUserMenu(user) {
 		text: i18n.ts.copyUsername,
 		action: () => {
 			copyToClipboard(`@${user.username}@${user.host || host}`);
-		}
+		},
 	}, {
 		icon: 'fas fa-info-circle',
 		text: i18n.ts.info,
 		action: () => {
 			os.pageWindow(`/user-info/${user.id}`);
-		}
+		},
 	}, {
 		icon: 'fas fa-envelope',
 		text: i18n.ts.sendMessage,
 		action: () => {
 			os.post({ specified: user });
-		}
+		},
 	}, meId !== user.id ? {
 		type: 'link',
 		icon: 'fas fa-comments',
@@ -177,47 +177,47 @@ export function getUserMenu(user) {
 	} : undefined, null, {
 		icon: 'fas fa-list-ul',
 		text: i18n.ts.addToList,
-		action: pushList
+		action: pushList,
 	}, meId !== user.id ? {
 		icon: 'fas fa-users',
 		text: i18n.ts.inviteToGroup,
-		action: inviteGroup
+		action: inviteGroup,
 	} : undefined] as any;
 
 	if ($i && meId !== user.id) {
 		menu = menu.concat([null, {
 			icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash',
 			text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
-			action: toggleMute
+			action: toggleMute,
 		}, {
 			icon: 'fas fa-ban',
 			text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
-			action: toggleBlock
+			action: toggleBlock,
 		}]);
 
 		if (user.isFollowed) {
 			menu = menu.concat([{
 				icon: 'fas fa-unlink',
 				text: i18n.ts.breakFollow,
-				action: invalidateFollow
+				action: invalidateFollow,
 			}]);
 		}
 
 		menu = menu.concat([null, {
 			icon: 'fas fa-exclamation-circle',
 			text: i18n.ts.reportAbuse,
-			action: reportAbuse
+			action: reportAbuse,
 		}]);
 
 		if (iAmModerator) {
 			menu = menu.concat([null, {
 				icon: 'fas fa-microphone-slash',
 				text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence,
-				action: toggleSilence
+				action: toggleSilence,
 			}, {
 				icon: 'fas fa-snowflake',
 				text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend,
-				action: toggleSuspend
+				action: toggleSuspend,
 			}]);
 		}
 	}
@@ -227,8 +227,8 @@ export function getUserMenu(user) {
 			icon: 'fas fa-pencil-alt',
 			text: i18n.ts.editProfile,
 			action: () => {
-				router.push('/settings/profile');
-			}
+				mainRouter.push('/settings/profile');
+			},
 		}]);
 	}
 
@@ -238,7 +238,7 @@ export function getUserMenu(user) {
 			text: action.title,
 			action: () => {
 				action.handler(user);
-			}
+			},
 		}))]);
 	}
 
diff --git a/packages/client/src/scripts/navigate.ts b/packages/client/src/scripts/navigate.ts
deleted file mode 100644
index 08b891ec5b..0000000000
--- a/packages/client/src/scripts/navigate.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { inject } from 'vue';
-import { router } from '@/router';
-import { defaultStore } from '@/store';
-
-export type Navigate = (path: string, record?: boolean) => void;
-
-export class MisskeyNavigator {
-	public readonly navHook: Navigate | null = null;
-	public readonly sideViewHook: Navigate | null = null;
-
-	// It should be constructed during vue creating in order for inject function to work
-	constructor() {
-		this.navHook = inject<Navigate | null>('navHook', null);
-		this.sideViewHook = inject<Navigate | null>('sideViewHook', null);
-	}
-
-	// Use this method instead of router.push()
-	public push(path: string, record = true) {
-		if (this.navHook) {
-			this.navHook(path, record);
-		} else {
-			if (defaultStore.state.defaultSideView && this.sideViewHook && path !== '/') {
-				return this.sideViewHook(path, record);
-			}
-	
-			if (router.currentRoute.value.path === path) {
-				window.scroll({ top: 0, behavior: 'smooth' });
-			} else {
-				if (record) router.push(path);
-				else router.replace(path);
-			}
-		}
-	}
-}
diff --git a/packages/client/src/scripts/page-metadata.ts b/packages/client/src/scripts/page-metadata.ts
new file mode 100644
index 0000000000..0db8369f9d
--- /dev/null
+++ b/packages/client/src/scripts/page-metadata.ts
@@ -0,0 +1,41 @@
+import * as misskey from 'misskey-js';
+import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue';
+
+export const setPageMetadata = Symbol('setPageMetadata');
+export const pageMetadataProvider = Symbol('pageMetadataProvider');
+
+export type PageMetadata = {
+	title: string;
+	subtitle?: string;
+	icon?: string | null;
+	avatar?: misskey.entities.User | null;
+	userName?: misskey.entities.User | null;
+	bg?: string;
+};
+
+export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void {
+	const _metadata = isRef(metadata) ? metadata : ref(metadata);
+
+	provide(pageMetadataProvider, _metadata);
+
+	const set = inject(setPageMetadata) as any;
+	if (set) {
+		set(_metadata);
+
+		onMounted(() => {
+			set(_metadata);
+		});
+
+		onActivated(() => {
+			set(_metadata);
+		});
+	}
+}
+
+export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void {
+	provide(setPageMetadata, callback);
+}
+
+export function injectPageMetadata(): PageMetadata | undefined {
+	return inject(pageMetadataProvider);
+}
diff --git a/packages/client/src/scripts/search.ts b/packages/client/src/scripts/search.ts
index 0aedee9c98..64914d3d65 100644
--- a/packages/client/src/scripts/search.ts
+++ b/packages/client/src/scripts/search.ts
@@ -1,6 +1,6 @@
 import * as os from '@/os';
 import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
 
 export async function search() {
 	const { canceled, result: query } = await os.inputText({
@@ -11,12 +11,12 @@ export async function search() {
 	const q = query.trim();
 
 	if (q.startsWith('@') && !q.includes(' ')) {
-		router.push(`/${q}`);
+		mainRouter.push(`/${q}`);
 		return;
 	}
 
 	if (q.startsWith('#')) {
-		router.push(`/tags/${encodeURIComponent(q.substr(1))}`);
+		mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`);
 		return;
 	}
 
@@ -36,14 +36,14 @@ export async function search() {
 		//v.$root.$emit('warp', date);
 		os.alert({
 			icon: 'fas fa-history',
-			iconOnly: true, autoClose: true
+			iconOnly: true, autoClose: true,
 		});
 		return;
 	}
 
 	if (q.startsWith('https://')) {
 		const promise = os.api('ap/show', {
-			uri: q
+			uri: q,
 		});
 
 		os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
@@ -51,13 +51,13 @@ export async function search() {
 		const res = await promise;
 
 		if (res.type === 'User') {
-			router.push(`/@${res.object.username}@${res.object.host}`);
+			mainRouter.push(`/@${res.object.username}@${res.object.host}`);
 		} else if (res.type === 'Note') {
-			router.push(`/notes/${res.object.id}`);
+			mainRouter.push(`/notes/${res.object.id}`);
 		}
 
 		return;
 	}
 
-	router.push(`/search?q=${encodeURIComponent(q)}`);
+	mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
 }
diff --git a/packages/client/src/scripts/use-leave-guard.ts b/packages/client/src/scripts/use-leave-guard.ts
index 33eea6b522..379a7e577c 100644
--- a/packages/client/src/scripts/use-leave-guard.ts
+++ b/packages/client/src/scripts/use-leave-guard.ts
@@ -1,5 +1,4 @@
 import { inject, onUnmounted, Ref } from 'vue';
-import { onBeforeRouteLeave } from 'vue-router';
 import { i18n } from '@/i18n';
 import * as os from '@/os';
 
diff --git a/packages/client/src/symbols.ts b/packages/client/src/symbols.ts
deleted file mode 100644
index 6913f29c28..0000000000
--- a/packages/client/src/symbols.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const PAGE_INFO = Symbol('Page info');
diff --git a/packages/client/src/ui/_common_/sw-inject.ts b/packages/client/src/ui/_common_/sw-inject.ts
index 371f80ca15..8676d2d48d 100644
--- a/packages/client/src/ui/_common_/sw-inject.ts
+++ b/packages/client/src/ui/_common_/sw-inject.ts
@@ -3,12 +3,9 @@ import { post } from '@/os';
 import { $i, login } from '@/account';
 import { defaultStore } from '@/store';
 import { getAccountFromId } from '@/scripts/get-account-from-id';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
 
 export function swInject() {
-	const navHook = inject('navHook', null);
-	const sideViewHook = inject('sideViewHook', null);
-
 	navigator.serviceWorker.addEventListener('message', ev => {
 		if (_DEV_) {
 			console.log('sw msg', ev.data);
@@ -27,16 +24,10 @@ export function swInject() {
 			case 'post':
 				return post(ev.data.options);
 			case 'push':
-				if (router.currentRoute.value.path === ev.data.url) {
+				if (mainRouter.currentRoute.value.path === ev.data.url) {
 					return window.scroll({ top: 0, behavior: 'smooth' });
 				}
-				if (navHook) {
-					return navHook(ev.data.url);
-				}
-				if (sideViewHook && defaultStore.state.defaultSideView && ev.data.url !== '/') {
-					return sideViewHook(ev.data.url);
-				}
-				return router.push(ev.data.url);
+				return mainRouter.push(ev.data.url);
 			default:
 				return;
 		}
diff --git a/packages/client/src/ui/classic.side.vue b/packages/client/src/ui/classic.side.vue
deleted file mode 100644
index 6c2329194e..0000000000
--- a/packages/client/src/ui/classic.side.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<template>
-<div v-if="component" class="qvzfzxam _narrow_">
-	<div class="container">
-		<header class="header" @contextmenu.prevent.stop="onContextmenu">
-			<button v-if="history.length > 0" class="_button" @click="back()"><i class="fas fa-chevron-left"></i></button>
-			<button v-else class="_button" style="pointer-events: none;"><!-- マージンのバランスを取るためのダミー --></button>
-			<span class="title" v-text="pageInfo?.title" />
-			<button class="_button" @click="close()"><i class="fas fa-times"></i></button>
-		</header>
-		<MkHeader class="pageHeader" :info="pageInfo"/>
-		<component :is="component" v-bind="props" :ref="changePage"/>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { provide } from 'vue';
-import * as os from '@/os';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { resolve, router } from '@/router';
-import { url as root } from '@/config';
-import * as symbols from '@/symbols';
-import { i18n } from '@/i18n';
-
-provide('navHook', navigate);
-
-let path: string | null = $ref(null);
-let component: ReturnType<typeof resolve>['component'] | null = $ref(null);
-let props: any | null = $ref(null);
-let pageInfo: any | null = $ref(null);
-let history: string[] = $ref([]);
-
-let url = $computed(() => `${root}${path}`);
-
-function changePage(page) {
-	if (page == null) return;
-	if (page[symbols.PAGE_INFO]) {
-		pageInfo = page[symbols.PAGE_INFO];
-	}
-}
-
-function navigate(_path: string, record = true) {
-	if (record && path) history.push($$(path).value);
-	path = _path;
-	const resolved = resolve(path);
-	component = resolved.component;
-	props = resolved.props;
-}
-
-function back() {
-	const prev = history.pop();
-	if (prev) navigate(prev, false);
-}
-
-function close() {
-	path = null;
-	component = null;
-	props = {};
-}
-
-function onContextmenu(ev: MouseEvent) {
-	os.contextMenu([{
-		type: 'label',
-		text: path || '',
-	}, {
-		icon: 'fas fa-expand-alt',
-		text: i18n.ts.showInPage,
-		action: () => {
-			if (path) router.push(path);
-			close();
-		}
-	}, {
-		icon: 'fas fa-window-maximize',
-		text: i18n.ts.openInWindow,
-		action: () => {
-			if (path) os.pageWindow(path);
-			close();
-		}
-	}, null, {
-		icon: 'fas fa-external-link-alt',
-		text: i18n.ts.openInNewTab,
-		action: () => {
-			window.open(url, '_blank');
-			close();
-		}
-	}, {
-		icon: 'fas fa-link',
-		text: i18n.ts.copyLink,
-		action: () => {
-			copyToClipboard(url);
-		}
-	}], ev);
-}
-
-defineExpose({
-	navigate,
-	back,
-	close,
-});
-</script>
-
-<style lang="scss" scoped>
-.qvzfzxam {
-	$header-height: 58px; // TODO: どこかに集約したい
-
-	--root-margin: 16px;
-	--margin: var(--marginHalf);
-
-	> .container {
-		position: fixed;
-		width: 370px;
-		height: 100vh;
-		overflow: auto;
-		box-sizing: border-box;
-
-		> .header {
-			display: flex;
-			position: sticky;
-			z-index: 1000;
-			top: 0;
-			height: $header-height;
-			width: 100%;
-			line-height: $header-height;
-			text-align: center;
-			font-weight: bold;
-			//background-color: var(--panel);
-			-webkit-backdrop-filter: var(--blur, blur(32px));
-			backdrop-filter: var(--blur, blur(32px));
-			background-color: var(--header);
-
-			> ._button {
-				height: $header-height;
-				width: $header-height;
-
-				&:hover {
-					color: var(--fgHighlighted);
-				}
-			}
-
-			> .title {
-				flex: 1;
-				position: relative;
-			}
-		}
-	}
-}
-</style>
-
diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue
index c61cbc433e..f5fa8f336a 100644
--- a/packages/client/src/ui/classic.vue
+++ b/packages/client/src/ui/classic.vue
@@ -7,31 +7,23 @@
 			<XSidebar/>
 		</div>
 		<div v-else ref="widgetsLeft" class="widgets left">
-			<XWidgets :place="'left'" @mounted="attachSticky('widgetsLeft')"/>
+			<XWidgets :place="'left'" @mounted="attachSticky(widgetsLeft)"/>
 		</div>
 
-		<main class="main" :style="{ background: pageInfo?.bg }" @contextmenu.stop="onContextmenu">
+		<main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
 			<div class="content">
-				<MkStickyContainer>
-					<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-					<router-view v-slot="{ Component }">
-						<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-							<keep-alive :include="['MkTimelinePage']">
-								<component :is="Component" :ref="changePage"/>
-							</keep-alive>
-						</transition>
-					</router-view>
-				</MkStickyContainer>
+				<RouterView/>
 			</div>
 		</main>
 
 		<div v-if="isDesktop" ref="widgetsRight" class="widgets right">
-			<XWidgets :place="null" @mounted="attachSticky('widgetsRight')"/>
+			<XWidgets :place="null" @mounted="attachSticky(widgetsRight)"/>
 		</div>
 	</div>
 
 	<transition :name="$store.state.animation ? 'tray-back' : ''">
-		<div v-if="widgetsShowing"
+		<div
+			v-if="widgetsShowing"
 			class="tray-back _modalBg"
 			@click="widgetsShowing = false"
 			@touchstart.passive="widgetsShowing = false"
@@ -48,157 +40,134 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, markRaw } from 'vue';
-import { instanceName } from '@/config';
-import { StickySidebar } from '@/scripts/sticky-sidebar';
+<script lang="ts" setup>
+import { defineAsyncComponent, markRaw, ComputedRef, ref, onMounted, provide } from 'vue';
 import XSidebar from './classic.sidebar.vue';
 import XCommon from './_common_/common.vue';
+import { instanceName } from '@/config';
+import { StickySidebar } from '@/scripts/sticky-sidebar';
 import * as os from '@/os';
 import { menuDef } from '@/menu';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue'));
+const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue'));
 
 const DESKTOP_THRESHOLD = 1100;
 
-export default defineComponent({
-	components: {
-		XCommon,
-		XSidebar,
-		XHeaderMenu: defineAsyncComponent(() => import('./classic.header.vue')),
-		XWidgets: defineAsyncComponent(() => import('./classic.widgets.vue')),
-	},
+const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
 
-	provide() {
-		return {
-			shouldHeaderThin: this.showMenuOnTop,
-			shouldSpacerMin: true,
-		};
-	},
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+const widgetsShowing = $ref(false);
+const fullView = $ref(false);
+let globalHeaderHeight = $ref(0);
+const wallpaper = localStorage.getItem('wallpaper') != null;
+const showMenuOnTop = $computed(() => defaultStore.state.menuDisplay === 'top');
+let live2d = $ref<HTMLIFrameElement>();
+let widgetsLeft = $ref();
+let widgetsRight = $ref();
 
-	data() {
-		return {
-			pageInfo: null,
-			menuDef: menuDef,
-			globalHeaderHeight: 0,
-			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
-			widgetsShowing: false,
-			fullView: false,
-			wallpaper: localStorage.getItem('wallpaper') != null,
-		};
-	},
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
+provide('shouldHeaderThin', showMenuOnTop);
+provide('shouldSpacerMin', true);
 
-	computed: {
-		showMenuOnTop(): boolean {
-			return this.$store.state.menuDisplay === 'top';
+function attachSticky(el) {
+	const sticky = new StickySidebar(el, defaultStore.state.menuDisplay === 'top' ? 0 : 16, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
+	window.addEventListener('scroll', () => {
+		sticky.calc(window.scrollY);
+	}, { passive: true });
+}
+
+function top() {
+	window.scroll({ top: 0, behavior: 'smooth' });
+}
+
+function onContextmenu(ev: MouseEvent) {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
 		}
-	},
+	};
+	if (isLink(ev.target)) return;
+	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
+	if (window.getSelection().toString() !== '') return;
+	const path = mainRouter.getCurrentPath();
+	os.contextMenu([{
+		type: 'label',
+		text: path,
+	}, {
+		icon: fullView ? 'fas fa-compress' : 'fas fa-expand',
+		text: fullView ? i18n.ts.quitFullView : i18n.ts.fullView,
+		action: () => {
+			fullView = !fullView;
+		},
+	}, {
+		icon: 'fas fa-window-maximize',
+		text: i18n.ts.openInWindow,
+		action: () => {
+			os.pageWindow(path);
+		},
+	}], ev);
+}
 
-	created() {
-		if (window.innerWidth < 1024) {
-			localStorage.setItem('ui', 'default');
-			location.reload();
-		}
+function onAiClick(ev) {
+	//if (this.live2d) this.live2d.click(ev);
+}
 
-		document.documentElement.style.overflowY = 'scroll';
+if (window.innerWidth < 1024) {
+	localStorage.setItem('ui', 'default');
+	location.reload();
+}
 
-		if (this.$store.state.widgets.length === 0) {
-			this.$store.set('widgets', [{
-				name: 'calendar',
-				id: 'a', place: null, data: {}
-			}, {
-				name: 'notifications',
-				id: 'b', place: null, data: {}
-			}, {
-				name: 'trends',
-				id: 'c', place: null, data: {}
-			}]);
-		}
-	},
+document.documentElement.style.overflowY = 'scroll';
 
-	mounted() {
-		window.addEventListener('resize', () => {
-			this.isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD);
+if (defaultStore.state.widgets.length === 0) {
+	defaultStore.set('widgets', [{
+		name: 'calendar',
+		id: 'a', place: null, data: {},
+	}, {
+		name: 'notifications',
+		id: 'b', place: null, data: {},
+	}, {
+		name: 'trends',
+		id: 'c', place: null, data: {},
+	}]);
+}
+
+onMounted(() => {
+	window.addEventListener('resize', () => {
+		isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD);
+	}, { passive: true });
+
+	if (defaultStore.state.aiChanMode) {
+		const iframeRect = live2d.getBoundingClientRect();
+		window.addEventListener('mousemove', ev => {
+			live2d.contentWindow.postMessage({
+				type: 'moveCursor',
+				body: {
+					x: ev.clientX - iframeRect.left,
+					y: ev.clientY - iframeRect.top,
+				},
+			}, '*');
+		}, { passive: true });
+		window.addEventListener('touchmove', ev => {
+			live2d.contentWindow.postMessage({
+				type: 'moveCursor',
+				body: {
+					x: ev.touches[0].clientX - iframeRect.left,
+					y: ev.touches[0].clientY - iframeRect.top,
+				},
+			}, '*');
 		}, { passive: true });
-
-		if (this.$store.state.aiChanMode) {
-			const iframeRect = this.$refs.live2d.getBoundingClientRect();
-			window.addEventListener('mousemove', ev => {
-				this.$refs.live2d.contentWindow.postMessage({
-					type: 'moveCursor',
-					body: {
-						x: ev.clientX - iframeRect.left,
-						y: ev.clientY - iframeRect.top,
-					}
-				}, '*');
-			}, { passive: true });
-			window.addEventListener('touchmove', ev => {
-				this.$refs.live2d.contentWindow.postMessage({
-					type: 'moveCursor',
-					body: {
-						x: ev.touches[0].clientX - iframeRect.left,
-						y: ev.touches[0].clientY - iframeRect.top,
-					}
-				}, '*');
-			}, { passive: true });
-		}
-	},
-
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-				document.title = `${this.pageInfo.title} | ${instanceName}`;
-			}
-		},
-
-		attachSticky(ref) {
-			const sticky = new StickySidebar(this.$refs[ref], this.$store.state.menuDisplay === 'top' ? 0 : 16, this.$store.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
-			window.addEventListener('scroll', () => {
-				sticky.calc(window.scrollY);
-			}, { passive: true });
-		},
-
-		top() {
-			window.scroll({ top: 0, behavior: 'smooth' });
-		},
-
-		onTransition() {
-			if (window._scroll) window._scroll();
-		},
-
-		onContextmenu(ev: MouseEvent) {
-			const isLink = (el: HTMLElement) => {
-				if (el.tagName === 'A') return true;
-				if (el.parentElement) {
-					return isLink(el.parentElement);
-				}
-			};
-			if (isLink(ev.target)) return;
-			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
-			if (window.getSelection().toString() !== '') return;
-			const path = this.$route.path;
-			os.contextMenu([{
-				type: 'label',
-				text: path,
-			}, {
-				icon: this.fullView ? 'fas fa-compress' : 'fas fa-expand',
-				text: this.fullView ? this.$ts.quitFullView : this.$ts.fullView,
-				action: () => {
-					this.fullView = !this.fullView;
-				}
-			}, {
-				icon: 'fas fa-window-maximize',
-				text: this.$ts.openInWindow,
-				action: () => {
-					os.pageWindow(path);
-				}
-			}], ev);
-		},
-
-		onAiClick(ev) {
-			//if (this.live2d) this.live2d.click(ev);
-		}
 	}
 });
 </script>
diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue
index e538a93f06..7433264794 100644
--- a/packages/client/src/ui/deck.vue
+++ b/packages/client/src/ui/deck.vue
@@ -1,18 +1,21 @@
 <template>
-<div class="mk-deck" :class="[{ isMobile }, `${deckStore.reactiveState.columnAlign.value}`]" :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }"
+<div
+	class="mk-deck" :class="[{ isMobile }, `${deckStore.reactiveState.columnAlign.value}`]" :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }"
 	@contextmenu.self.prevent="onContextmenu"
 >
 	<XSidebar v-if="!isMobile"/>
 
 	<template v-for="ids in layout">
 		<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
-		<section v-if="ids.length > 1"
+		<section
+			v-if="ids.length > 1"
 			class="folder column"
 			:style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
 		>
 			<DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
 		</section>
-		<DeckColumnCore v-else
+		<DeckColumnCore
+			v-else
 			:ref="ids[0]"
 			:key="ids[0]"
 			class="column"
@@ -25,13 +28,14 @@
 
 	<div v-if="isMobile" class="buttons">
 		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
-		<button class="button home _button" @click="$router.push('/')"><i class="fas fa-home"></i></button>
-		<button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
+		<button class="button home _button" @click="mainRouter.push('/')"><i class="fas fa-home"></i></button>
+		<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
 		<button class="button post _button" @click="os.post()"><i class="fas fa-pencil-alt"></i></button>
 	</div>
 
 	<transition :name="$store.state.animation ? 'menu-back' : ''">
-		<div v-if="drawerMenuShowing"
+		<div
+			v-if="drawerMenuShowing"
 			class="menu-back _modalBg"
 			@click="drawerMenuShowing = false"
 			@touchstart.passive="drawerMenuShowing = false"
@@ -49,17 +53,17 @@
 <script lang="ts" setup>
 import { computed, provide, ref, watch } from 'vue';
 import { v4 as uuid } from 'uuid';
+import XCommon from './_common_/common.vue';
+import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store';
 import DeckColumnCore from '@/ui/deck/column-core.vue';
 import XSidebar from '@/ui/_common_/sidebar.vue';
 import XDrawerMenu from '@/ui/_common_/sidebar-for-mobile.vue';
 import { getScrollContainer } from '@/scripts/scroll';
 import * as os from '@/os';
 import { menuDef } from '@/menu';
-import XCommon from './_common_/common.vue';
-import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store';
-import { useRoute } from 'vue-router';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { mainRouter } from '@/router';
 
 const isMobile = ref(window.innerWidth <= 500);
 window.addEventListener('resize', () => {
@@ -68,7 +72,7 @@ window.addEventListener('resize', () => {
 
 const drawerMenuShowing = ref(false);
 
-const route = useRoute();
+const route = 'TODO';
 watch(route, () => {
 	drawerMenuShowing.value = false;
 });
@@ -98,8 +102,8 @@ const addColumn = async (ev) => {
 	const { canceled, result: column } = await os.select({
 		title: i18n.ts._deck.addColumn,
 		items: columns.map(column => ({
-			value: column, text: i18n.t('_deck._columns.' + column)
-		}))
+			value: column, text: i18n.t('_deck._columns.' + column),
+		})),
 	});
 	if (canceled) return;
 
@@ -119,11 +123,6 @@ const onContextmenu = (ev) => {
 };
 
 provide('shouldSpacerMin', true);
-if (deckStore.state.navWindow) {
-	provide('navHook', (url) => {
-		os.pageWindow(url);
-	});
-}
 
 document.documentElement.style.overflowY = 'hidden';
 document.documentElement.style.scrollBehavior = 'auto';
diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue
index 3c97cd4867..670b4a212b 100644
--- a/packages/client/src/ui/deck/main-column.vue
+++ b/packages/client/src/ui/deck/main-column.vue
@@ -1,33 +1,24 @@
 <template>
-<XColumn v-if="deckStore.state.alwaysShowMainColumn || $route.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
 	<template #header>
-		<template v-if="pageInfo">
-			<i :class="pageInfo.icon"></i>
-			{{ pageInfo.title }}
+		<template v-if="pageMetadata?.value">
+			<i :class="pageMetadata?.value.icon"></i>
+			{{ pageMetadata?.value.title }}
 		</template>
 	</template>
 
-	<MkStickyContainer>
-		<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-		<router-view v-slot="{ Component }">
-			<transition>
-				<keep-alive :include="['MkTimelinePage']">
-					<component :is="Component" :ref="changePage" @contextmenu.stop="onContextmenu"/>
-				</keep-alive>
-			</transition>
-		</router-view>
-	</MkStickyContainer>
+	<RouterView @contextmenu.stop="onContextmenu"/>
 </XColumn>
 </template>
 
 <script lang="ts" setup>
-import { } from 'vue';
+import { ComputedRef, provide } from 'vue';
 import XColumn from './column.vue';
 import { deckStore, Column } from '@/ui/deck/deck-store';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
 defineProps<{
 	column: Column;
@@ -38,14 +29,13 @@ const emit = defineEmits<{
 	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
 }>();
 
-let pageInfo = $ref<Record<string, any> | null>(null);
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
 
-function changePage(page) {
-	if (page == null) return;
-	if (page[symbols.PAGE_INFO]) {
-		pageInfo = page[symbols.PAGE_INFO];
-	}
-}
 /*
 function back() {
 	history.back();
@@ -72,7 +62,7 @@ function onContextmenu(ev: MouseEvent) {
 		text: i18n.ts.openInWindow,
 		action: () => {
 			os.pageWindow(path);
-		}
+		},
 	}], ev);
 }
 </script>
diff --git a/packages/client/src/ui/desktop.vue b/packages/client/src/ui/desktop.vue
deleted file mode 100644
index 17783c58e3..0000000000
--- a/packages/client/src/ui/desktop.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<template>
-<div class="mk-app" :class="{ wallpaper }" @contextmenu.prevent="() => {}">
-	<XSidebar ref="nav" class="sidebar"/>
-
-	<XCommon/>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { host } from '@/config';
-import { search } from '@/scripts/search';
-import XCommon from './_common_/common.vue';
-import * as os from '@/os';
-import XSidebar from '@/ui/_common_/sidebar.vue';
-import { menuDef } from '@/menu';
-import { ColdDeviceStorage } from '@/store';
-
-export default defineComponent({
-	components: {
-		XCommon,
-		XSidebar
-	},
-
-	provide() {
-		return {
-			navHook: (url) => {
-				os.pageWindow(url);
-			}
-		};
-	},
-
-	data() {
-		return {
-			host: host,
-			menuDef: menuDef,
-			wallpaper: localStorage.getItem('wallpaper') != null,
-		};
-	},
-
-	computed: {
-		menu(): string[] {
-			return this.$store.state.menu;
-		},
-	},
-
-	created() {
-		if (window.innerWidth < 1024) {
-			localStorage.setItem('ui', 'default');
-			location.reload();
-		}
-	},
-
-	methods: {
-		help() {
-			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
-		},
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-app {
-	height: 100vh;
-	width: 100vw;
-}
-</style>
-
-<style lang="scss">
-</style>
diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue
index a5ff7a6255..41d59342bd 100644
--- a/packages/client/src/ui/universal.vue
+++ b/packages/client/src/ui/universal.vue
@@ -2,26 +2,15 @@
 <div class="dkgtipfy" :class="{ wallpaper }">
 	<XSidebar v-if="!isMobile" class="sidebar"/>
 
-	<div class="contents" :style="{ background: pageInfo?.bg }" @contextmenu.stop="onContextmenu">
+	<div class="contents" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
 		<main>
 			<div class="content">
-				<MkStickyContainer>
-					<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-					<router-view v-slot="{ Component }">
-						<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-							<keep-alive :include="['MkTimelinePage']">
-								<component :is="Component" :ref="changePage"/>
-							</keep-alive>
-						</transition>
-					</router-view>
-				</MkStickyContainer>
+				<RouterView/>
 			</div>
 			<div class="spacer"></div>
 		</main>
 	</div>
 
-	<XSideView v-if="isDesktop" ref="sideEl" class="side"/>
-
 	<div v-if="isDesktop" ref="widgetsEl" class="widgets">
 		<XWidgets @mounted="attachSticky"/>
 	</div>
@@ -30,14 +19,15 @@
 
 	<div v-if="isMobile" class="buttons">
 		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
-		<button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button>
-		<button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
+		<button class="button home _button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i class="fas fa-home"></i></button>
+		<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
 		<button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button>
 		<button class="button post _button" @click="os.post()"><i class="fas fa-pencil-alt"></i></button>
 	</div>
 
 	<transition :name="$store.state.animation ? 'menuDrawer-back' : ''">
-		<div v-if="drawerMenuShowing"
+		<div
+			v-if="drawerMenuShowing"
 			class="menuDrawer-back _modalBg"
 			@click="drawerMenuShowing = false"
 			@touchstart.passive="drawerMenuShowing = false"
@@ -49,7 +39,8 @@
 	</transition>
 
 	<transition :name="$store.state.animation ? 'widgetsDrawer-back' : ''">
-		<div v-if="widgetsShowing"
+		<div
+			v-if="widgetsShowing"
 			class="widgetsDrawer-back _modalBg"
 			@click="widgetsShowing = false"
 			@touchstart.passive="widgetsShowing = false"
@@ -65,19 +56,19 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, provide, onMounted, computed, ref, watch } from 'vue';
+import { defineAsyncComponent, provide, onMounted, computed, ref, watch, ComputedRef } from 'vue';
+import XCommon from './_common_/common.vue';
 import { instanceName } from '@/config';
 import { StickySidebar } from '@/scripts/sticky-sidebar';
 import XDrawerMenu from '@/ui/_common_/sidebar-for-mobile.vue';
-import XCommon from './_common_/common.vue';
-import XSideView from './classic.side.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { defaultStore } from '@/store';
 import { menuDef } from '@/menu';
-import { useRoute } from 'vue-router';
 import { i18n } from '@/i18n';
 import { $i } from '@/account';
+import { Router } from '@/nirax';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
 const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/sidebar.vue'));
 
@@ -90,15 +81,18 @@ window.addEventListener('resize', () => {
 	isMobile.value = window.innerWidth <= MOBILE_THRESHOLD;
 });
 
-const pageInfo = ref();
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
 const widgetsEl = $ref<HTMLElement>();
-const widgetsShowing = ref(false);
+const widgetsShowing = $ref(false);
 
-let sideEl = $ref<InstanceType<typeof XSideView>>();
-
-provide('sideViewHook', isDesktop.value ? (url) => {
-	sideEl.navigate(url);
-} : null);
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	console.log(info);
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
 
 const menuIndicated = computed(() => {
 	for (const def in menuDef) {
@@ -110,8 +104,7 @@ const menuIndicated = computed(() => {
 
 const drawerMenuShowing = ref(false);
 
-const route = useRoute();
-watch(route, () => {
+mainRouter.on('change', () => {
 	drawerMenuShowing.value = false;
 });
 
@@ -120,13 +113,13 @@ document.documentElement.style.overflowY = 'scroll';
 if (defaultStore.state.widgets.length === 0) {
 	defaultStore.set('widgets', [{
 		name: 'calendar',
-		id: 'a', place: 'right', data: {}
+		id: 'a', place: 'right', data: {},
 	}, {
 		name: 'notifications',
-		id: 'b', place: 'right', data: {}
+		id: 'b', place: 'right', data: {},
 	}, {
 		name: 'trends',
-		id: 'c', place: 'right', data: {}
+		id: 'c', place: 'right', data: {},
 	}]);
 }
 
@@ -138,14 +131,6 @@ onMounted(() => {
 	}
 });
 
-const changePage = (page) => {
-	if (page == null) return;
-	if (page[symbols.PAGE_INFO]) {
-		pageInfo.value = page[symbols.PAGE_INFO];
-		document.title = `${pageInfo.value.title} | ${instanceName}`;
-	}
-};
-
 const onContextmenu = (ev) => {
 	const isLink = (el: HTMLElement) => {
 		if (el.tagName === 'A') return true;
@@ -156,22 +141,16 @@ const onContextmenu = (ev) => {
 	if (isLink(ev.target)) return;
 	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
 	if (window.getSelection()?.toString() !== '') return;
-	const path = route.path;
+	const path = mainRouter.getCurrentPath();
 	os.contextMenu([{
 		type: 'label',
 		text: path,
-	}, {
-		icon: 'fas fa-columns',
-		text: i18n.ts.openInSideView,
-		action: () => {
-			sideEl.navigate(path);
-		}
 	}, {
 		icon: 'fas fa-window-maximize',
 		text: i18n.ts.openInWindow,
 		action: () => {
 			os.pageWindow(path);
-		}
+		},
 	}], ev);
 };
 
@@ -186,10 +165,6 @@ function top() {
 	window.scroll({ top: 0, behavior: 'smooth' });
 }
 
-function onTransition() {
-	if (window._scroll) window._scroll();
-}
-
 const wallpaper = localStorage.getItem('wallpaper') != null;
 </script>
 
@@ -274,12 +249,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null;
 		}
 	}
 
-	> .side {
-		min-width: 370px;
-		max-width: 370px;
-		border-left: solid 0.5px var(--divider);
-	}
-
 	> .widgets {
 		padding: 0 var(--margin);
 		border-left: solid 0.5px var(--divider);
diff --git a/packages/client/src/ui/visitor/a.vue b/packages/client/src/ui/visitor/a.vue
index af77f9e970..e98247cbb1 100644
--- a/packages/client/src/ui/visitor/a.vue
+++ b/packages/client/src/ui/visitor/a.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="mk-app">
-	<div v-if="$route.path === '/'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+	<div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
 		<div>
 			<h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
 			<div v-if="meta" class="about">
@@ -20,15 +20,11 @@
 
 	<div class="main">
 		<div ref="contents" class="contents" :class="{ wallpaper }">
-			<header v-show="$route.path !== '/'" ref="header" class="header">
+			<header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header">
 				<XHeader :info="pageInfo"/>
 			</header>
 			<main ref="main">
-				<router-view v-slot="{ Component }">
-					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-						<component :is="Component" :ref="changePage"/>
-					</transition>
-				</router-view>
+				<RouterView/>
 			</main>
 			<div class="powered-by">
 				<b><MkA to="/">{{ host }}</MkA></b>
@@ -41,14 +37,14 @@
 
 <script lang="ts">
 import { defineComponent, defineAsyncComponent } from 'vue';
+import XHeader from './header.vue';
 import { host, instanceName } from '@/config';
 import { search } from '@/scripts/search';
 import * as os from '@/os';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
-import XHeader from './header.vue';
 import { ColdDeviceStorage } from '@/store';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
 
 const DESKTOP_THRESHOLD = 1100;
 
@@ -70,6 +66,7 @@ export default defineComponent({
 				endpoint: 'announcements',
 				limit: 10,
 			},
+			mainRouter,
 			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 		};
 	},
@@ -82,7 +79,7 @@ export default defineComponent({
 					this.$store.set('darkMode', !this.$store.state.darkMode);
 				},
 				's': search,
-				'h|/': this.help
+				'h|/': this.help,
 			};
 		},
 	},
@@ -120,13 +117,9 @@ export default defineComponent({
 		},
 
 		help() {
-			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
+			window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank');
 		},
-
-		onTransition() {
-			if (window._scroll) window._scroll();
-		},
-	}
+	},
 });
 </script>
 
diff --git a/packages/client/src/ui/visitor/b.vue b/packages/client/src/ui/visitor/b.vue
index c9c0a1f72e..28933f272a 100644
--- a/packages/client/src/ui/visitor/b.vue
+++ b/packages/client/src/ui/visitor/b.vue
@@ -12,11 +12,7 @@
 		<div class="contents">
 			<XHeader v-if="!root" class="header" :info="pageInfo"/>
 			<main>
-				<router-view v-slot="{ Component }">
-					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-						<component :is="Component" :ref="changePage"/>
-					</transition>
-				</router-view>
+				<RouterView/>
 			</main>
 			<div v-if="!root" class="powered-by">
 				<b><MkA to="/">{{ host }}</MkA></b>
@@ -26,7 +22,8 @@
 	</div>
 
 	<transition :name="$store.state.animation ? 'tray-back' : ''">
-		<div v-if="showMenu"
+		<div
+			v-if="showMenu"
 			class="menu-back _modalBg"
 			@click="showMenu = false"
 			@touchstart.passive="showMenu = false"
@@ -48,8 +45,10 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
+<script lang="ts" setup>
+import { ComputedRef, onMounted, provide } from 'vue';
+import XHeader from './header.vue';
+import XKanban from './kanban.vue';
 import { host, instanceName } from '@/config';
 import { search } from '@/scripts/search';
 import * as os from '@/os';
@@ -57,101 +56,64 @@ import MkPagination from '@/components/ui/pagination.vue';
 import XSigninDialog from '@/components/signin-dialog.vue';
 import XSignupDialog from '@/components/signup-dialog.vue';
 import MkButton from '@/components/ui/button.vue';
-import XHeader from './header.vue';
-import XKanban from './kanban.vue';
-import { ColdDeviceStorage } from '@/store';
-import * as symbols from '@/symbols';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
 const DESKTOP_THRESHOLD = 1100;
 
-export default defineComponent({
-	components: {
-		XHeader,
-		XKanban,
-		MkPagination,
-		MkButton,
-	},
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
 
-	data() {
-		return {
-			host,
-			instanceName,
-			pageInfo: null,
-			meta: null,
-			showMenu: false,
-			narrow: window.innerWidth < 1280,
-			announcements: {
-				endpoint: 'announcements',
-				limit: 10,
-			},
-			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
-		};
-	},
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
 
-	computed: {
-		keymap(): any {
-			return {
-				'd': () => {
-					if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
-					this.$store.set('darkMode', !this.$store.state.darkMode);
-				},
-				's': search,
-				'h|/': this.help
-			};
+const announcements = {
+	endpoint: 'announcements',
+	limit: 10,
+};
+let showMenu = $ref(false);
+let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
+let narrow = $ref(window.innerWidth < 1280);
+let meta = $ref();
+
+const keymap = $computed(() => {
+	return {
+		'd': () => {
+			if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
+			defaultStore.set('darkMode', !defaultStore.state.darkMode);
 		},
+		's': search,
+	};
+});
 
-		root(): boolean {
-			return this.$route.path === '/';
-		},
-	},
+const root = $computed(() => mainRouter.currentRoute.value.name === 'index');
 
-	created() {
-		//document.documentElement.style.overflowY = 'scroll';
+os.api('meta', { detail: true }).then(res => {
+	meta = res;
+});
 
-		os.api('meta', { detail: true }).then(meta => {
-			this.meta = meta;
-		});
-	},
+function signin() {
+	os.popup(XSigninDialog, {
+		autoSet: true,
+	}, {}, 'closed');
+}
 
-	mounted() {
-		if (!this.isDesktop) {
-			window.addEventListener('resize', () => {
-				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
-			}, { passive: true });
-		}
-	},
+function signup() {
+	os.popup(XSignupDialog, {
+		autoSet: true,
+	}, {}, 'closed');
+}
 
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
-		},
-
-		top() {
-			window.scroll({ top: 0, behavior: 'smooth' });
-		},
-
-		help() {
-			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
-		},
-
-		onTransition() {
-			if (window._scroll) window._scroll();
-		},
-
-		signin() {
-			os.popup(XSigninDialog, {
-				autoSet: true
-			}, {}, 'closed');
-		},
-
-		signup() {
-			os.popup(XSignupDialog, {
-				autoSet: true
-			}, {}, 'closed');
-		}
+onMounted(() => {
+	if (!isDesktop) {
+		window.addEventListener('resize', () => {
+			if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true;
+		}, { passive: true });
 	}
 });
 </script>
diff --git a/packages/client/src/ui/zen.vue b/packages/client/src/ui/zen.vue
index a7234f729b..c915f82428 100644
--- a/packages/client/src/ui/zen.vue
+++ b/packages/client/src/ui/zen.vue
@@ -1,106 +1,35 @@
 <template>
 <div class="mk-app">
-	<div class="contents">
-		<header class="header">
-			<MkHeader :info="pageInfo"/>
-		</header>
-		<main ref="main">
-			<div class="content">
-				<router-view v-slot="{ Component }">
-					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-						<keep-alive :include="['MkTimelinePage']">
-							<component :is="Component" :ref="changePage"/>
-						</keep-alive>
-					</transition>
-				</router-view>
-			</div>
-		</main>
-	</div>
+	<RouterView/>
 
 	<XCommon/>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import { host } from '@/config';
+<script lang="ts" setup>
+import { provide, ComputedRef } from 'vue';
 import XCommon from './_common_/common.vue';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { instanceName } from '@/config';
 
-export default defineComponent({
-	components: {
-		XCommon,
-	},
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
 
-	data() {
-		return {
-			host: host,
-			pageInfo: null,
-		};
-	},
-
-	created() {
-		document.documentElement.style.overflowY = 'scroll';
-	},
-
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
-		},
-
-		top() {
-			window.scroll({ top: 0, behavior: 'smooth' });
-		},
-
-		help() {
-			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
-		},
-
-		onTransition() {
-			if (window._scroll) window._scroll();
-		},
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
 	}
 });
+
+document.documentElement.style.overflowY = 'scroll';
 </script>
 
 <style lang="scss" scoped>
 .mk-app {
-	$header-height: 52px;
-	$ui-font-size: 1em; // TODO: どこかに集約したい
-
 	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 	min-height: calc(var(--vh, 1vh) * 100);
 	box-sizing: border-box;
-
-	> .contents {
-		padding-top: $header-height;
-
-		> .header {
-			position: fixed;
-			z-index: 1000;
-			top: 0;
-			height: $header-height;
-			width: 100%;
-			line-height: $header-height;
-			text-align: center;
-			//background-color: var(--panel);
-			-webkit-backdrop-filter: var(--blur, blur(32px));
-			backdrop-filter: var(--blur, blur(32px));
-			background-color: var(--header);
-			border-bottom: solid 0.5px var(--divider);
-		}
-
-		> main {
-			> .content {
-				> * {
-					// ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
-					min-height: calc((var(--vh, 1vh) * 100) - #{$header-height});
-				}
-			}
-		}
-	}
 }
 </style>
diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json
index f7320a7251..86109f600a 100644
--- a/packages/client/tsconfig.json
+++ b/packages/client/tsconfig.json
@@ -35,7 +35,8 @@
 		"lib": [
 			"esnext",
 			"dom"
-		]
+		],
+		"jsx": "preserve"
 	},
 	"compileOnSave": false,
 	"include": [
diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts
index af13e646c6..5800cf5021 100644
--- a/packages/client/vite.config.ts
+++ b/packages/client/vite.config.ts
@@ -1,10 +1,10 @@
 import * as fs from 'fs';
 import pluginVue from '@vitejs/plugin-vue';
-import pluginJson5 from './vite.json5';
 import { defineConfig } from 'vite';
 
 import locales from '../../locales';
 import meta from '../../package.json';
+import pluginJson5 from './vite.json5';
 
 const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
 
@@ -57,7 +57,7 @@ export default defineConfig(({ command, mode }) => {
 				},
 				output: {
 					manualChunks: {
-						vue: ['vue', 'vue-router'],
+						vue: ['vue'],
 					},
 				},
 			},
@@ -68,5 +68,5 @@ export default defineConfig(({ command, mode }) => {
 			sourcemap: process.env.NODE_ENV !== 'production',
 			reportCompressedSize: false,
 		},
-	}
+	};
 });
diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock
index 796c72304a..0dc898800d 100644
--- a/packages/client/yarn.lock
+++ b/packages/client/yarn.lock
@@ -647,11 +647,6 @@
     "@vue/compiler-dom" "3.2.37"
     "@vue/shared" "3.2.37"
 
-"@vue/devtools-api@^6.0.0":
-  version "6.0.12"
-  resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.0.12.tgz#7b57cce215ae9f37a86984633b3aa3d595aa5b46"
-  integrity sha512-iO/4FIezHKXhiDBdKySCvJVh8/mZPxHpiQrTy+PXVqJZgpTPTdHy4q8GXulaY+UKEagdkBb0onxNQZ0LNiqVhw==
-
 "@vue/reactivity-transform@3.2.37":
   version "3.2.37"
   resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.37.tgz#0caa47c4344df4ae59f5a05dde2a8758829f8eca"
@@ -4232,13 +4227,6 @@ vue-prism-editor@2.0.0-alpha.2:
   resolved "https://registry.yarnpkg.com/vue-prism-editor/-/vue-prism-editor-2.0.0-alpha.2.tgz#aa53a88efaaed628027cbb282c2b1d37fc7c5c69"
   integrity sha512-Gu42ba9nosrE+gJpnAEuEkDMqG9zSUysIR8SdXUw8MQKDjBnnNR9lHC18uOr/ICz7yrA/5c7jHJr9lpElODC7w==
 
-vue-router@4.0.16:
-  version "4.0.16"
-  resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.0.16.tgz#9477beeeef36e80e04d041a1738801a55e6e862e"
-  integrity sha512-JcO7cb8QJLBWE+DfxGUL3xUDOae/8nhM1KVdnudadTAORbuxIC/xAydC5Zr/VLHUDQi1ppuTF5/rjBGzgzrJNA==
-  dependencies:
-    "@vue/devtools-api" "^6.0.0"
-
 vue@3.2.37:
   version "3.2.37"
   resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.37.tgz#da220ccb618d78579d25b06c7c21498ca4e5452e"