diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b43319614..605b8af92c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@
 - Feat: 絵文字の詳細ダイアログを追加
 - Feat: 枠線をつけるMFM`$[border.width=1,style=solid,color=fff,radius=0 ...]`を追加
   - デフォルトで枠線からはみ出る部分が隠されるようにしました。初期と同じ挙動にするには`$[border.noclip`が必要です
+- Feat: スワイプでタブを切り替えられるように
 - Enhance: MFM等のコードブロックに全文コピー用のボタンを追加
 - Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
 - Enhance: チャンネルノートのピン留めをノートのメニューからできるように
diff --git a/locales/generateDTS.js b/locales/generateDTS.js
index d3afdd6e15..6eb5bd630d 100644
--- a/locales/generateDTS.js
+++ b/locales/generateDTS.js
@@ -6,54 +6,171 @@ import ts from 'typescript';
 
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = dirname(__filename);
+const parameterRegExp = /\{(\w+)\}/g;
+
+function createMemberType(item) {
+	if (typeof item !== 'string') {
+		return ts.factory.createTypeLiteralNode(createMembers(item));
+	}
+	const parameters = Array.from(
+		item.matchAll(parameterRegExp),
+		([, parameter]) => parameter,
+	);
+	if (!parameters.length) {
+		return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
+	}
+	return ts.factory.createTypeReferenceNode(
+		ts.factory.createIdentifier('ParameterizedString'),
+		[
+			ts.factory.createUnionTypeNode(
+				parameters.map((parameter) =>
+					ts.factory.createLiteralTypeNode(
+						ts.factory.createStringLiteral(parameter),
+					),
+				),
+			),
+		],
+	);
+}
 
 function createMembers(record) {
-	return Object.entries(record)
-		.map(([k, v]) => ts.factory.createPropertySignature(
+	return Object.entries(record).map(([k, v]) =>
+		ts.factory.createPropertySignature(
 			undefined,
 			ts.factory.createStringLiteral(k),
 			undefined,
-			typeof v === 'string'
-				? ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)
-				: ts.factory.createTypeLiteralNode(createMembers(v)),
-		));
+			createMemberType(v),
+		),
+	);
 }
 
 export default function generateDTS() {
 	const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8'));
 	const members = createMembers(locale);
 	const elements = [
+		ts.factory.createVariableStatement(
+			[ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)],
+			ts.factory.createVariableDeclarationList(
+				[
+					ts.factory.createVariableDeclaration(
+						ts.factory.createIdentifier('kParameters'),
+						undefined,
+						ts.factory.createTypeOperatorNode(
+							ts.SyntaxKind.UniqueKeyword,
+							ts.factory.createKeywordTypeNode(ts.SyntaxKind.SymbolKeyword),
+						),
+						undefined,
+					),
+				],
+				ts.NodeFlags.Const,
+			),
+		),
+		ts.factory.createInterfaceDeclaration(
+			[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
+			ts.factory.createIdentifier('ParameterizedString'),
+			[
+				ts.factory.createTypeParameterDeclaration(
+					undefined,
+					ts.factory.createIdentifier('T'),
+					ts.factory.createTypeReferenceNode(
+						ts.factory.createIdentifier('string'),
+						undefined,
+					),
+				),
+			],
+			undefined,
+			[
+				ts.factory.createPropertySignature(
+					undefined,
+					ts.factory.createComputedPropertyName(
+						ts.factory.createIdentifier('kParameters'),
+					),
+					undefined,
+					ts.factory.createTypeReferenceNode(
+						ts.factory.createIdentifier('T'),
+						undefined,
+					),
+				),
+			],
+		),
+		ts.factory.createInterfaceDeclaration(
+			[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
+			ts.factory.createIdentifier('ILocale'),
+			undefined,
+			undefined,
+			[
+				ts.factory.createIndexSignature(
+					undefined,
+					[
+						ts.factory.createParameterDeclaration(
+							undefined,
+							undefined,
+							ts.factory.createIdentifier('_'),
+							undefined,
+							ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
+							undefined,
+						),
+					],
+					ts.factory.createUnionTypeNode([
+						ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
+						ts.factory.createTypeReferenceNode(
+							ts.factory.createIdentifier('ParameterizedString'),
+							[ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)],
+						),
+						ts.factory.createTypeReferenceNode(
+							ts.factory.createIdentifier('ILocale'),
+							undefined,
+						),
+					]),
+				),
+			],
+		),
 		ts.factory.createInterfaceDeclaration(
 			[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
 			ts.factory.createIdentifier('Locale'),
 			undefined,
-			undefined,
+			[
+				ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
+					ts.factory.createExpressionWithTypeArguments(
+						ts.factory.createIdentifier('ILocale'),
+						undefined,
+					),
+				]),
+			],
 			members,
 		),
 		ts.factory.createVariableStatement(
 			[ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)],
 			ts.factory.createVariableDeclarationList(
-				[ts.factory.createVariableDeclaration(
-					ts.factory.createIdentifier('locales'),
-					undefined,
-					ts.factory.createTypeLiteralNode([ts.factory.createIndexSignature(
+				[
+					ts.factory.createVariableDeclaration(
+						ts.factory.createIdentifier('locales'),
 						undefined,
-						[ts.factory.createParameterDeclaration(
-							undefined,
-							undefined,
-							ts.factory.createIdentifier('lang'),
-							undefined,
-							ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
-							undefined,
-						)],
-						ts.factory.createTypeReferenceNode(
-							ts.factory.createIdentifier('Locale'),
-							undefined,
-						),
-					)]),
-					undefined,
-				)],
-				ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags,
+						ts.factory.createTypeLiteralNode([
+							ts.factory.createIndexSignature(
+								undefined,
+								[
+									ts.factory.createParameterDeclaration(
+										undefined,
+										undefined,
+										ts.factory.createIdentifier('lang'),
+										undefined,
+										ts.factory.createKeywordTypeNode(
+											ts.SyntaxKind.StringKeyword,
+										),
+										undefined,
+									),
+								],
+								ts.factory.createTypeReferenceNode(
+									ts.factory.createIdentifier('Locale'),
+									undefined,
+								),
+							),
+						]),
+						undefined,
+					),
+				],
+				ts.NodeFlags.Const,
 			),
 		),
 		ts.factory.createFunctionDeclaration(
@@ -70,16 +187,28 @@ export default function generateDTS() {
 		),
 		ts.factory.createExportDefault(ts.factory.createIdentifier('locales')),
 	];
-	const printed = ts.createPrinter({
-		newLine: ts.NewLineKind.LineFeed,
-	}).printList(
-		ts.ListFormat.MultiLine,
-		ts.factory.createNodeArray(elements),
-		ts.createSourceFile('index.d.ts', '', ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS),
-	);
+	const printed = ts
+		.createPrinter({
+			newLine: ts.NewLineKind.LineFeed,
+		})
+		.printList(
+			ts.ListFormat.MultiLine,
+			ts.factory.createNodeArray(elements),
+			ts.createSourceFile(
+				'index.d.ts',
+				'',
+				ts.ScriptTarget.ESNext,
+				true,
+				ts.ScriptKind.TS,
+			),
+		);
 
-	fs.writeFileSync(`${__dirname}/index.d.ts`, `/* eslint-disable */
+	fs.writeFileSync(
+		`${__dirname}/index.d.ts`,
+		`/* eslint-disable */
 // This file is generated by locales/generateDTS.js
 // Do not edit this file directly.
-${printed}`, 'utf-8');
+${printed}`,
+		'utf-8',
+	);
 }
diff --git a/locales/index.d.ts b/locales/index.d.ts
index a827925518..b7d4c74f33 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1,12 +1,19 @@
 /* eslint-disable */
 // This file is generated by locales/generateDTS.js
 // Do not edit this file directly.
-export interface Locale {
+declare const kParameters: unique symbol;
+export interface ParameterizedString<T extends string> {
+    [kParameters]: T;
+}
+export interface ILocale {
+    [_: string]: string | ParameterizedString<string> | ILocale;
+}
+export interface Locale extends ILocale {
     "_lang_": string;
     "headlineMisskey": string;
     "introMisskey": string;
-    "poweredByMisskeyDescription": string;
-    "monthAndDay": string;
+    "poweredByMisskeyDescription": ParameterizedString<"name">;
+    "monthAndDay": ParameterizedString<"month" | "day">;
     "search": string;
     "notifications": string;
     "username": string;
@@ -18,7 +25,7 @@ export interface Locale {
     "cancel": string;
     "noThankYou": string;
     "enterUsername": string;
-    "renotedBy": string;
+    "renotedBy": ParameterizedString<"user">;
     "noNotes": string;
     "noNotifications": string;
     "instance": string;
@@ -78,8 +85,8 @@ export interface Locale {
     "export": string;
     "files": string;
     "download": string;
-    "driveFileDeleteConfirm": string;
-    "unfollowConfirm": string;
+    "driveFileDeleteConfirm": ParameterizedString<"name">;
+    "unfollowConfirm": ParameterizedString<"name">;
     "exportRequested": string;
     "importRequested": string;
     "lists": string;
@@ -183,9 +190,9 @@ export interface Locale {
     "wallpaper": string;
     "setWallpaper": string;
     "removeWallpaper": string;
-    "searchWith": string;
+    "searchWith": ParameterizedString<"q">;
     "youHaveNoLists": string;
-    "followConfirm": string;
+    "followConfirm": ParameterizedString<"name">;
     "proxyAccount": string;
     "proxyAccountDescription": string;
     "host": string;
@@ -208,7 +215,7 @@ export interface Locale {
     "software": string;
     "version": string;
     "metadata": string;
-    "withNFiles": string;
+    "withNFiles": ParameterizedString<"n">;
     "monitor": string;
     "jobQueue": string;
     "cpuAndMemory": string;
@@ -237,7 +244,7 @@ export interface Locale {
     "processing": string;
     "preview": string;
     "default": string;
-    "defaultValueIs": string;
+    "defaultValueIs": ParameterizedString<"value">;
     "noCustomEmojis": string;
     "noJobs": string;
     "federating": string;
@@ -266,8 +273,8 @@ export interface Locale {
     "imageUrl": string;
     "remove": string;
     "removed": string;
-    "removeAreYouSure": string;
-    "deleteAreYouSure": string;
+    "removeAreYouSure": ParameterizedString<"x">;
+    "deleteAreYouSure": ParameterizedString<"x">;
     "resetAreYouSure": string;
     "areYouSure": string;
     "saved": string;
@@ -285,8 +292,8 @@ export interface Locale {
     "messageRead": string;
     "noMoreHistory": string;
     "startMessaging": string;
-    "nUsersRead": string;
-    "agreeTo": string;
+    "nUsersRead": ParameterizedString<"n">;
+    "agreeTo": ParameterizedString<"0">;
     "agree": string;
     "agreeBelow": string;
     "basicNotesBeforeCreateAccount": string;
@@ -298,7 +305,7 @@ export interface Locale {
     "images": string;
     "image": string;
     "birthday": string;
-    "yearsOld": string;
+    "yearsOld": ParameterizedString<"age">;
     "registeredDate": string;
     "location": string;
     "theme": string;
@@ -353,9 +360,9 @@ export interface Locale {
     "thisYear": string;
     "thisMonth": string;
     "today": string;
-    "dayX": string;
-    "monthX": string;
-    "yearX": string;
+    "dayX": ParameterizedString<"day">;
+    "monthX": ParameterizedString<"month">;
+    "yearX": ParameterizedString<"year">;
     "pages": string;
     "integration": string;
     "connectService": string;
@@ -420,7 +427,7 @@ export interface Locale {
     "recentlyUpdatedUsers": string;
     "recentlyRegisteredUsers": string;
     "recentlyDiscoveredUsers": string;
-    "exploreUsersCount": string;
+    "exploreUsersCount": ParameterizedString<"count">;
     "exploreFediverse": string;
     "popularTags": string;
     "userList": string;
@@ -437,16 +444,16 @@ export interface Locale {
     "moderationNote": string;
     "addModerationNote": string;
     "moderationLogs": string;
-    "nUsersMentioned": string;
+    "nUsersMentioned": ParameterizedString<"n">;
     "securityKeyAndPasskey": string;
     "securityKey": string;
     "lastUsed": string;
-    "lastUsedAt": string;
+    "lastUsedAt": ParameterizedString<"t">;
     "unregister": string;
     "passwordLessLogin": string;
     "passwordLessLoginDescription": string;
     "resetPassword": string;
-    "newPasswordIs": string;
+    "newPasswordIs": ParameterizedString<"password">;
     "reduceUiAnimation": string;
     "share": string;
     "notFound": string;
@@ -466,7 +473,7 @@ export interface Locale {
     "enable": string;
     "next": string;
     "retype": string;
-    "noteOf": string;
+    "noteOf": ParameterizedString<"user">;
     "quoteAttached": string;
     "quoteQuestion": string;
     "noMessagesYet": string;
@@ -486,12 +493,12 @@ export interface Locale {
     "strongPassword": string;
     "passwordMatched": string;
     "passwordNotMatched": string;
-    "signinWith": string;
+    "signinWith": ParameterizedString<"x">;
     "signinFailed": string;
     "or": string;
     "language": string;
     "uiLanguage": string;
-    "aboutX": string;
+    "aboutX": ParameterizedString<"x">;
     "emojiStyle": string;
     "native": string;
     "disableDrawer": string;
@@ -509,7 +516,7 @@ export interface Locale {
     "regenerate": string;
     "fontSize": string;
     "mediaListWithOneImageAppearance": string;
-    "limitTo": string;
+    "limitTo": ParameterizedString<"x">;
     "noFollowRequests": string;
     "openImageInNewTab": string;
     "dashboard": string;
@@ -587,7 +594,7 @@ export interface Locale {
     "deleteAllFiles": string;
     "deleteAllFilesConfirm": string;
     "removeAllFollowing": string;
-    "removeAllFollowingDescription": string;
+    "removeAllFollowingDescription": ParameterizedString<"host">;
     "userSuspended": string;
     "userSilenced": string;
     "yourAccountSuspendedTitle": string;
@@ -658,9 +665,9 @@ export interface Locale {
     "wordMute": string;
     "hardWordMute": string;
     "regexpError": string;
-    "regexpErrorDescription": string;
+    "regexpErrorDescription": ParameterizedString<"tab" | "line">;
     "instanceMute": string;
-    "userSaysSomething": string;
+    "userSaysSomething": ParameterizedString<"name">;
     "makeActive": string;
     "display": string;
     "copy": string;
@@ -686,7 +693,7 @@ export interface Locale {
     "abuseReports": string;
     "reportAbuse": string;
     "reportAbuseRenote": string;
-    "reportAbuseOf": string;
+    "reportAbuseOf": ParameterizedString<"name">;
     "fillAbuseReportDescription": string;
     "abuseReported": string;
     "reporter": string;
@@ -701,7 +708,7 @@ export interface Locale {
     "defaultNavigationBehaviour": string;
     "editTheseSettingsMayBreakAccount": string;
     "instanceTicker": string;
-    "waitingFor": string;
+    "waitingFor": ParameterizedString<"x">;
     "random": string;
     "system": string;
     "switchUi": string;
@@ -711,10 +718,10 @@ export interface Locale {
     "optional": string;
     "createNewClip": string;
     "unclip": string;
-    "confirmToUnclipAlreadyClippedNote": string;
+    "confirmToUnclipAlreadyClippedNote": ParameterizedString<"name">;
     "public": string;
     "private": string;
-    "i18nInfo": string;
+    "i18nInfo": ParameterizedString<"link">;
     "manageAccessTokens": string;
     "accountInfo": string;
     "notesCount": string;
@@ -764,9 +771,9 @@ export interface Locale {
     "needReloadToApply": string;
     "showTitlebar": string;
     "clearCache": string;
-    "onlineUsersCount": string;
-    "nUsers": string;
-    "nNotes": string;
+    "onlineUsersCount": ParameterizedString<"n">;
+    "nUsers": ParameterizedString<"n">;
+    "nNotes": ParameterizedString<"n">;
     "sendErrorReports": string;
     "sendErrorReportsDescription": string;
     "myTheme": string;
@@ -798,7 +805,7 @@ export interface Locale {
     "publish": string;
     "inChannelSearch": string;
     "useReactionPickerForContextMenu": string;
-    "typingUsers": string;
+    "typingUsers": ParameterizedString<"users">;
     "jumpToSpecifiedDate": string;
     "showingPastTimeline": string;
     "clear": string;
@@ -865,7 +872,7 @@ export interface Locale {
     "misskeyUpdated": string;
     "whatIsNew": string;
     "translate": string;
-    "translatedFrom": string;
+    "translatedFrom": ParameterizedString<"x">;
     "accountDeletionInProgress": string;
     "usernameInfo": string;
     "aiChanMode": string;
@@ -896,11 +903,11 @@ export interface Locale {
     "continueThread": string;
     "deleteAccountConfirm": string;
     "incorrectPassword": string;
-    "voteConfirm": string;
+    "voteConfirm": ParameterizedString<"choice">;
     "hide": string;
     "useDrawerReactionPickerForMobile": string;
-    "welcomeBackWithName": string;
-    "clickToFinishEmailVerification": string;
+    "welcomeBackWithName": ParameterizedString<"name">;
+    "clickToFinishEmailVerification": ParameterizedString<"ok">;
     "overridedDeviceKind": string;
     "smartphone": string;
     "tablet": string;
@@ -928,8 +935,8 @@ export interface Locale {
     "cropYes": string;
     "cropNo": string;
     "file": string;
-    "recentNHours": string;
-    "recentNDays": string;
+    "recentNHours": ParameterizedString<"n">;
+    "recentNDays": ParameterizedString<"n">;
     "noEmailServerWarning": string;
     "thereIsUnresolvedAbuseReportWarning": string;
     "recommended": string;
@@ -938,7 +945,7 @@ export interface Locale {
     "driveCapOverrideCaption": string;
     "requireAdminForView": string;
     "isSystemAccount": string;
-    "typeToConfirm": string;
+    "typeToConfirm": ParameterizedString<"x">;
     "deleteAccount": string;
     "document": string;
     "numberOfPageCache": string;
@@ -992,7 +999,7 @@ export interface Locale {
     "neverShow": string;
     "remindMeLater": string;
     "didYouLikeMisskey": string;
-    "pleaseDonate": string;
+    "pleaseDonate": ParameterizedString<"host">;
     "roles": string;
     "role": string;
     "noRole": string;
@@ -1090,7 +1097,7 @@ export interface Locale {
     "preservedUsernamesDescription": string;
     "createNoteFromTheFile": string;
     "archive": string;
-    "channelArchiveConfirmTitle": string;
+    "channelArchiveConfirmTitle": ParameterizedString<"name">;
     "channelArchiveConfirmDescription": string;
     "thisChannelArchived": string;
     "displayOfNote": string;
@@ -1120,8 +1127,8 @@ export interface Locale {
     "createCount": string;
     "inviteCodeCreated": string;
     "inviteLimitExceeded": string;
-    "createLimitRemaining": string;
-    "inviteLimitResetCycle": string;
+    "createLimitRemaining": ParameterizedString<"limit">;
+    "inviteLimitResetCycle": ParameterizedString<"time" | "limit">;
     "expirationDate": string;
     "noExpirationDate": string;
     "inviteCodeUsedAt": string;
@@ -1134,7 +1141,7 @@ export interface Locale {
     "expired": string;
     "doYouAgree": string;
     "beSureToReadThisAsItIsImportant": string;
-    "iHaveReadXCarefullyAndAgree": string;
+    "iHaveReadXCarefullyAndAgree": ParameterizedString<"x">;
     "dialog": string;
     "icon": string;
     "forYou": string;
@@ -1189,7 +1196,7 @@ export interface Locale {
     "doReaction": string;
     "code": string;
     "reloadRequiredToApplySettings": string;
-    "remainingN": string;
+    "remainingN": ParameterizedString<"n">;
     "overwriteContentConfirm": string;
     "seasonalScreenEffect": string;
     "decorate": string;
@@ -1202,8 +1209,9 @@ export interface Locale {
     "replay": string;
     "replaying": string;
     "ranking": string;
-    "lastNDays": string;
+    "lastNDays": ParameterizedString<"n">;
     "backToTitle": string;
+    "enableHorizontalSwipe": string;
     "_bubbleGame": {
         "howToPlay": string;
         "_howToPlay": {
@@ -1220,7 +1228,7 @@ export interface Locale {
         "end": string;
         "tooManyActiveAnnouncementDescription": string;
         "readConfirmTitle": string;
-        "readConfirmText": string;
+        "readConfirmText": ParameterizedString<"title">;
         "shouldNotBeUsedToPresentPermanentInfo": string;
         "dialogAnnouncementUxWarn": string;
         "silence": string;
@@ -1235,10 +1243,10 @@ export interface Locale {
         "theseSettingsCanEditLater": string;
         "youCanEditMoreSettingsInSettingsPageLater": string;
         "followUsers": string;
-        "pushNotificationDescription": string;
+        "pushNotificationDescription": ParameterizedString<"name">;
         "initialAccountSettingCompleted": string;
-        "haveFun": string;
-        "youCanContinueTutorial": string;
+        "haveFun": ParameterizedString<"name">;
+        "youCanContinueTutorial": ParameterizedString<"name">;
         "startTutorial": string;
         "skipAreYouSure": string;
         "laterAreYouSure": string;
@@ -1276,7 +1284,7 @@ export interface Locale {
             "social": string;
             "global": string;
             "description2": string;
-            "description3": string;
+            "description3": ParameterizedString<"link">;
         };
         "_postNote": {
             "title": string;
@@ -1314,7 +1322,7 @@ export interface Locale {
         };
         "_done": {
             "title": string;
-            "description": string;
+            "description": ParameterizedString<"link">;
         };
     };
     "_timelineDescription": {
@@ -1328,10 +1336,10 @@ export interface Locale {
     };
     "_serverSettings": {
         "iconUrl": string;
-        "appIconDescription": string;
+        "appIconDescription": ParameterizedString<"host">;
         "appIconUsageExample": string;
         "appIconStyleRecommendation": string;
-        "appIconResolutionMustBe": string;
+        "appIconResolutionMustBe": ParameterizedString<"resolution">;
         "manifestJsonOverride": string;
         "shortName": string;
         "shortNameDescription": string;
@@ -1342,7 +1350,7 @@ export interface Locale {
     "_accountMigration": {
         "moveFrom": string;
         "moveFromSub": string;
-        "moveFromLabel": string;
+        "moveFromLabel": ParameterizedString<"n">;
         "moveFromDescription": string;
         "moveTo": string;
         "moveToLabel": string;
@@ -1350,7 +1358,7 @@ export interface Locale {
         "moveAccountDescription": string;
         "moveAccountHowTo": string;
         "startMigration": string;
-        "migrationConfirm": string;
+        "migrationConfirm": ParameterizedString<"account">;
         "movedAndCannotBeUndone": string;
         "postMigrationNote": string;
         "movedTo": string;
@@ -1792,7 +1800,7 @@ export interface Locale {
     "_signup": {
         "almostThere": string;
         "emailAddressInfo": string;
-        "emailSent": string;
+        "emailSent": ParameterizedString<"email">;
     };
     "_accountDelete": {
         "accountDelete": string;
@@ -1845,14 +1853,14 @@ export interface Locale {
         "save": string;
         "inputName": string;
         "cannotSave": string;
-        "nameAlreadyExists": string;
-        "applyConfirm": string;
-        "saveConfirm": string;
-        "deleteConfirm": string;
-        "renameConfirm": string;
+        "nameAlreadyExists": ParameterizedString<"name">;
+        "applyConfirm": ParameterizedString<"name">;
+        "saveConfirm": ParameterizedString<"name">;
+        "deleteConfirm": ParameterizedString<"name">;
+        "renameConfirm": ParameterizedString<"old" | "new">;
         "noBackups": string;
-        "createdAt": string;
-        "updatedAt": string;
+        "createdAt": ParameterizedString<"date" | "time">;
+        "updatedAt": ParameterizedString<"date" | "time">;
         "cannotLoad": string;
         "invalidFile": string;
     };
@@ -1897,8 +1905,8 @@ export interface Locale {
         "featured": string;
         "owned": string;
         "following": string;
-        "usersCount": string;
-        "notesCount": string;
+        "usersCount": ParameterizedString<"n">;
+        "notesCount": ParameterizedString<"n">;
         "nameAndDescription": string;
         "nameOnly": string;
         "allowRenoteToExternal": string;
@@ -1926,7 +1934,7 @@ export interface Locale {
         "manage": string;
         "code": string;
         "description": string;
-        "installed": string;
+        "installed": ParameterizedString<"name">;
         "installedThemes": string;
         "builtinThemes": string;
         "alreadyInstalled": string;
@@ -1949,7 +1957,7 @@ export interface Locale {
         "lighten": string;
         "inputConstantName": string;
         "importInfo": string;
-        "deleteConstantConfirm": string;
+        "deleteConstantConfirm": ParameterizedString<"const">;
         "keys": {
             "accent": string;
             "bg": string;
@@ -2012,23 +2020,23 @@ export interface Locale {
     "_ago": {
         "future": string;
         "justNow": string;
-        "secondsAgo": string;
-        "minutesAgo": string;
-        "hoursAgo": string;
-        "daysAgo": string;
-        "weeksAgo": string;
-        "monthsAgo": string;
-        "yearsAgo": string;
+        "secondsAgo": ParameterizedString<"n">;
+        "minutesAgo": ParameterizedString<"n">;
+        "hoursAgo": ParameterizedString<"n">;
+        "daysAgo": ParameterizedString<"n">;
+        "weeksAgo": ParameterizedString<"n">;
+        "monthsAgo": ParameterizedString<"n">;
+        "yearsAgo": ParameterizedString<"n">;
         "invalid": string;
     };
     "_timeIn": {
-        "seconds": string;
-        "minutes": string;
-        "hours": string;
-        "days": string;
-        "weeks": string;
-        "months": string;
-        "years": string;
+        "seconds": ParameterizedString<"n">;
+        "minutes": ParameterizedString<"n">;
+        "hours": ParameterizedString<"n">;
+        "days": ParameterizedString<"n">;
+        "weeks": ParameterizedString<"n">;
+        "months": ParameterizedString<"n">;
+        "years": ParameterizedString<"n">;
     };
     "_time": {
         "second": string;
@@ -2039,7 +2047,7 @@ export interface Locale {
     "_2fa": {
         "alreadyRegistered": string;
         "registerTOTP": string;
-        "step1": string;
+        "step1": ParameterizedString<"a" | "b">;
         "step2": string;
         "step2Click": string;
         "step2Uri": string;
@@ -2054,7 +2062,7 @@ export interface Locale {
         "securityKeyName": string;
         "tapSecurityKey": string;
         "removeKey": string;
-        "removeKeyConfirm": string;
+        "removeKeyConfirm": ParameterizedString<"name">;
         "whyTOTPOnlyRenew": string;
         "renewTOTP": string;
         "renewTOTPConfirm": string;
@@ -2155,9 +2163,9 @@ export interface Locale {
     };
     "_auth": {
         "shareAccessTitle": string;
-        "shareAccess": string;
+        "shareAccess": ParameterizedString<"name">;
         "shareAccessAsk": string;
-        "permission": string;
+        "permission": ParameterizedString<"name">;
         "permissionAsk": string;
         "pleaseGoBack": string;
         "callback": string;
@@ -2216,12 +2224,12 @@ export interface Locale {
     "_cw": {
         "hide": string;
         "show": string;
-        "chars": string;
-        "files": string;
+        "chars": ParameterizedString<"count">;
+        "files": ParameterizedString<"count">;
     };
     "_poll": {
         "noOnlyOneChoice": string;
-        "choiceN": string;
+        "choiceN": ParameterizedString<"n">;
         "noMore": string;
         "canMultipleVote": string;
         "expiration": string;
@@ -2231,16 +2239,16 @@ export interface Locale {
         "deadlineDate": string;
         "deadlineTime": string;
         "duration": string;
-        "votesCount": string;
-        "totalVotes": string;
+        "votesCount": ParameterizedString<"n">;
+        "totalVotes": ParameterizedString<"n">;
         "vote": string;
         "showResult": string;
         "voted": string;
         "closed": string;
-        "remainingDays": string;
-        "remainingHours": string;
-        "remainingMinutes": string;
-        "remainingSeconds": string;
+        "remainingDays": ParameterizedString<"d" | "h">;
+        "remainingHours": ParameterizedString<"h" | "m">;
+        "remainingMinutes": ParameterizedString<"m" | "s">;
+        "remainingSeconds": ParameterizedString<"s">;
     };
     "_visibility": {
         "public": string;
@@ -2280,7 +2288,7 @@ export interface Locale {
         "changeAvatar": string;
         "changeBanner": string;
         "verifiedLinkDescription": string;
-        "avatarDecorationMax": string;
+        "avatarDecorationMax": ParameterizedString<"max">;
     };
     "_exportOrImport": {
         "allNotes": string;
@@ -2403,16 +2411,16 @@ export interface Locale {
     };
     "_notification": {
         "fileUploaded": string;
-        "youGotMention": string;
-        "youGotReply": string;
-        "youGotQuote": string;
-        "youRenoted": string;
+        "youGotMention": ParameterizedString<"name">;
+        "youGotReply": ParameterizedString<"name">;
+        "youGotQuote": ParameterizedString<"name">;
+        "youRenoted": ParameterizedString<"name">;
         "youWereFollowed": string;
         "youReceivedFollowRequest": string;
         "yourFollowRequestAccepted": string;
         "pollEnded": string;
         "newNote": string;
-        "unreadAntennaNote": string;
+        "unreadAntennaNote": ParameterizedString<"name">;
         "roleAssigned": string;
         "emptyPushNotificationMessage": string;
         "achievementEarned": string;
@@ -2420,9 +2428,9 @@ export interface Locale {
         "checkNotificationBehavior": string;
         "sendTestNotification": string;
         "notificationWillBeDisplayedLikeThis": string;
-        "reactedBySomeUsers": string;
-        "renotedBySomeUsers": string;
-        "followedBySomeUsers": string;
+        "reactedBySomeUsers": ParameterizedString<"n">;
+        "renotedBySomeUsers": ParameterizedString<"n">;
+        "followedBySomeUsers": ParameterizedString<"n">;
         "_types": {
             "all": string;
             "note": string;
@@ -2479,8 +2487,8 @@ export interface Locale {
         };
     };
     "_dialog": {
-        "charactersExceeded": string;
-        "charactersBelow": string;
+        "charactersExceeded": ParameterizedString<"current" | "max">;
+        "charactersBelow": ParameterizedString<"current" | "min">;
     };
     "_disabledTimeline": {
         "title": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index f805cbf107..7efb400f34 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1201,6 +1201,7 @@ replaying: "リプレイ中"
 ranking: "ランキング"
 lastNDays: "直近{n}日"
 backToTitle: "タイトルへ"
+enableHorizontalSwipe: "スワイプしてタブを切り替える"
 
 _bubbleGame:
   howToPlay: "遊び方"
diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue
index b81afe2920..251e6ade00 100644
--- a/packages/frontend/src/components/MkCode.vue
+++ b/packages/frontend/src/components/MkCode.vue
@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code>
-<div v-else :class="$style.codeBlockRoot">
+<div :class="$style.codeBlockRoot">
 	<button :class="$style.codeBlockCopyButton" class="_button" @click="copy">
 		<i class="ti ti-copy"></i>
 	</button>
@@ -36,7 +35,6 @@ import copyToClipboard from '@/scripts/copy-to-clipboard.js';
 const props = defineProps<{
 	code: string;
 	lang?: string;
-	inline?: boolean;
 }>();
 
 const show = ref(!defaultStore.state.dataSaver.code);
@@ -66,16 +64,6 @@ function copy() {
 	}
 }
 
-.codeInlineRoot {
-	display: inline-block;
-	font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
-	overflow-wrap: anywhere;
-	color: #D4D4D4;
-	background: #1E1E1E;
-	padding: .1em;
-	border-radius: .3em;
-}
-
 .codeBlockFallbackRoot {
 	display: block;
 	overflow-wrap: anywhere;
diff --git a/packages/frontend/src/components/MkCodeInline.vue b/packages/frontend/src/components/MkCodeInline.vue
new file mode 100644
index 0000000000..5340c1fd5f
--- /dev/null
+++ b/packages/frontend/src/components/MkCodeInline.vue
@@ -0,0 +1,26 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<code :class="$style.root">{{ code }}</code>
+</template>
+
+<script lang="ts" setup>
+const props = defineProps<{
+	code: string;
+}>();
+</script>
+
+<style module lang="scss">
+.root {
+	display: inline-block;
+	font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
+	overflow-wrap: anywhere;
+	color: #D4D4D4;
+	background: #1E1E1E;
+	padding: .1em;
+	border-radius: .3em;
+}
+</style>
diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue
new file mode 100644
index 0000000000..2c62aadbf4
--- /dev/null
+++ b/packages/frontend/src/components/MkHorizontalSwipe.vue
@@ -0,0 +1,209 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+	ref="rootEl"
+	:class="[$style.transitionRoot, (defaultStore.state.animation && $style.enableAnimation)]"
+	@touchstart="touchStart"
+	@touchmove="touchMove"
+	@touchend="touchEnd"
+>
+	<Transition
+		:class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]"
+		:enterActiveClass="$style.swipeAnimation_enterActive"
+		:leaveActiveClass="$style.swipeAnimation_leaveActive"
+		:enterFromClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_enterFrom : $style.swipeAnimationRight_enterFrom"
+		:leaveToClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_leaveTo : $style.swipeAnimationRight_leaveTo"
+		:style="`--swipe: ${pullDistance}px;`"
+	>
+		<!-- 【注意】slot内の最上位要素に動的にkeyを設定すること -->
+		<!-- 各最上位要素にユニークなkeyの指定がないとTransitionがうまく動きません -->
+		<slot></slot>
+	</Transition>
+</div>
+</template>
+<script lang="ts" setup>
+import { ref, shallowRef, computed, nextTick, watch } from 'vue';
+import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
+import { defaultStore } from '@/store.js';
+
+const rootEl = shallowRef<HTMLDivElement>();
+
+// eslint-disable-next-line no-undef
+const tabModel = defineModel<string>('tab');
+
+const props = defineProps<{
+	tabs: Tab[];
+}>();
+
+const emit = defineEmits<{
+	(ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
+}>();
+
+// ▼ しきい値 ▼ //
+
+// スワイプと判定される最小の距離
+const MIN_SWIPE_DISTANCE = 50;
+
+// スワイプ時の動作を発火する最小の距離
+const SWIPE_DISTANCE_THRESHOLD = 125;
+
+// スワイプを中断するY方向の移動距離
+const SWIPE_ABORT_Y_THRESHOLD = 75;
+
+// スワイプできる最大の距離
+const MAX_SWIPE_DISTANCE = 150;
+
+// ▲ しきい値 ▲ //
+
+let startScreenX: number | null = null;
+let startScreenY: number | null = null;
+
+const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value));
+
+const pullDistance = ref(0);
+const isSwiping = ref(false);
+const isSwipingForClass = ref(false);
+let swipeAborted = false;
+
+function touchStart(event: TouchEvent) {
+	if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+
+	if (event.touches.length !== 1) return;
+
+	startScreenX = event.touches[0].screenX;
+	startScreenY = event.touches[0].screenY;
+}
+
+function touchMove(event: TouchEvent) {
+	if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+
+	if (event.touches.length !== 1) return;
+
+	if (startScreenX == null || startScreenY == null) return;
+
+	if (swipeAborted) return;
+
+	let distanceX = event.touches[0].screenX - startScreenX;
+	let distanceY = event.touches[0].screenY - startScreenY;
+
+	if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) {
+		swipeAborted = true;
+
+		pullDistance.value = 0;
+		isSwiping.value = false;
+		setTimeout(() => {
+			isSwipingForClass.value = false;
+		}, 400);
+
+		return;
+	}
+
+	if (Math.abs(distanceX) < MIN_SWIPE_DISTANCE) return;
+	if (Math.abs(distanceX) > MAX_SWIPE_DISTANCE) return;
+
+	if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) {
+		distanceX = Math.min(distanceX, 0);
+	}
+	if (currentTabIndex.value === props.tabs.length - 1 || props.tabs[currentTabIndex.value + 1].onClick) {
+		distanceX = Math.max(distanceX, 0);
+	}
+	if (distanceX === 0) return;
+
+	isSwiping.value = true;
+	isSwipingForClass.value = true;
+	nextTick(() => {
+		// グリッチを控えるため、1.5px以上の差がないと更新しない
+		if (Math.abs(distanceX - pullDistance.value) < 1.5) return;
+		pullDistance.value = distanceX;
+	});
+}
+
+function touchEnd(event: TouchEvent) {
+	if (swipeAborted) {
+		swipeAborted = false;
+		return;
+	}
+
+	if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
+
+	if (event.touches.length !== 0) return;
+
+	if (startScreenX == null) return;
+
+	if (!isSwiping.value) return;
+
+	const distance = event.changedTouches[0].screenX - startScreenX;
+
+	if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) {
+		if (distance > 0) {
+			if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) {
+				tabModel.value = props.tabs[currentTabIndex.value - 1].key;
+				emit('swiped', props.tabs[currentTabIndex.value - 1].key, 'right');
+			}
+		} else {
+			if (props.tabs[currentTabIndex.value + 1] && !props.tabs[currentTabIndex.value + 1].onClick) {
+				tabModel.value = props.tabs[currentTabIndex.value + 1].key;
+				emit('swiped', props.tabs[currentTabIndex.value + 1].key, 'left');
+			}
+		}
+	}
+
+	pullDistance.value = 0;
+	isSwiping.value = false;
+	setTimeout(() => {
+		isSwipingForClass.value = false;
+	}, 400);
+}
+
+const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined);
+
+watch(tabModel, (newTab, oldTab) => {
+	const newIndex = props.tabs.findIndex(tab => tab.key === newTab);
+	const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab);
+
+	if (oldIndex >= 0 && newIndex && oldIndex < newIndex) {
+		transitionName.value = 'swipeAnimationLeft';
+	} else {
+		transitionName.value = 'swipeAnimationRight';
+	}
+
+	window.setTimeout(() => {
+		transitionName.value = undefined;
+	}, 400);
+});
+</script>
+
+<style lang="scss" module>
+.transitionRoot.enableAnimation {
+	display: grid;
+	overflow: clip;
+
+	.transitionChildren {
+		grid-area: 1 / 1 / 2 / 2;
+		transform: translateX(var(--swipe));
+
+		&.swipeAnimation_enterActive,
+		&.swipeAnimation_leaveActive {
+			transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
+		}
+
+		&.swipeAnimationRight_leaveTo,
+		&.swipeAnimationLeft_enterFrom {
+			transform: translateX(calc(100% + 24px));
+		}
+
+		&.swipeAnimationRight_enterFrom,
+		&.swipeAnimationLeft_leaveTo {
+			transform: translateX(calc(-100% - 24px));
+		}
+	}
+}
+
+.swiping {
+	transition: transform .2s ease-out;
+}
+</style>
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index 75b31b9a49..53fd3b2d55 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -138,7 +138,7 @@ const rangePercent = computed({
 		audioEl.value.currentTime = to * durationMs.value / 1000;
 	},
 });
-const volume = ref(.5);
+const volume = ref(.25);
 const bufferedEnd = ref(0);
 const bufferedDataRatio = computed(() => {
 	if (!audioEl.value) return 0;
@@ -161,7 +161,7 @@ function togglePlayPause() {
 
 function toggleMute() {
 	if (volume.value === 0) {
-		volume.value = .5;
+		volume.value = .25;
 	} else {
 		volume.value = 0;
 	}
@@ -207,7 +207,7 @@ function init() {
 				isActuallyPlaying.value = false;
 				isPlaying.value = false;
 			});
-			
+
 			durationMs.value = audioEl.value.duration * 1000;
 			audioEl.value.addEventListener('durationchange', () => {
 				if (audioEl.value) {
diff --git a/packages/frontend/src/components/MkMediaRange.vue b/packages/frontend/src/components/MkMediaRange.vue
index e6303a5c41..a150ae9843 100644
--- a/packages/frontend/src/components/MkMediaRange.vue
+++ b/packages/frontend/src/components/MkMediaRange.vue
@@ -5,9 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <!-- Media系専用のinput range -->
 <template>
-<div :class="$style.controlsSeekbar" :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--scrollbarHandle);'">
-	<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
-	<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
+<div :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--scrollbarHandle);'">
+	<div :class="$style.controlsSeekbar">
+		<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
+		<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
+	</div>
 </div>
 </template>
 
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 977c9020c7..0a113458a1 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -176,7 +176,7 @@ const rangePercent = computed({
 		videoEl.value.currentTime = to * durationMs.value / 1000;
 	},
 });
-const volume = ref(.5);
+const volume = ref(.25);
 const bufferedEnd = ref(0);
 const bufferedDataRatio = computed(() => {
 	if (!videoEl.value) return 0;
@@ -236,7 +236,7 @@ function toggleFullscreen() {
 
 function toggleMute() {
 	if (volume.value === 0) {
-		volume.value = .5;
+		volume.value = .25;
 	} else {
 		volume.value = 0;
 	}
@@ -535,6 +535,9 @@ onDeactivated(() => {
 
 .seekbarRoot {
 	grid-area: seekbar;
+	/* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */
+	margin: -10px;
+	padding: 10px;
 }
 
 @container (min-width: 500px) {
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
index b7190f6335..9eab855004 100644
--- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
@@ -13,6 +13,7 @@ import MkMention from '@/components/MkMention.vue';
 import MkEmoji from '@/components/global/MkEmoji.vue';
 import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
 import MkCode from '@/components/MkCode.vue';
+import MkCodeInline from '@/components/MkCodeInline.vue';
 import MkGoogle from '@/components/MkGoogle.vue';
 import MkSparkle from '@/components/MkSparkle.vue';
 import MkA from '@/components/global/MkA.vue';
@@ -373,10 +374,9 @@ export default function(props: MfmProps, context: SetupContext<MfmEvents>) {
 			}
 
 			case 'inlineCode': {
-				return [h(MkCode, {
+				return [h(MkCodeInline, {
 					key: Math.random(),
 					code: token.props.code,
-					inline: true,
 				})];
 			}
 
diff --git a/packages/frontend/src/filters/hms.ts b/packages/frontend/src/filters/hms.ts
index 7b5da965ff..73db7becc2 100644
--- a/packages/frontend/src/filters/hms.ts
+++ b/packages/frontend/src/filters/hms.ts
@@ -5,7 +5,7 @@
 
 import { i18n } from '@/i18n.js';
 
-export function hms(ms: number, options: {
+export function hms(ms: number, options?: {
 	textFormat?: 'colon' | 'locale';
 	enableSeconds?: boolean;
 	enableMs?: boolean;
diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts
index 858db74dac..c5c4ccf820 100644
--- a/packages/frontend/src/i18n.ts
+++ b/packages/frontend/src/i18n.ts
@@ -10,6 +10,7 @@ import { I18n } from '@/scripts/i18n.js';
 
 export const i18n = markRaw(new I18n<Locale>(locale));
 
-export function updateI18n(newLocale) {
-	i18n.ts = newLocale;
+export function updateI18n(newLocale: Locale) {
+	// @ts-expect-error -- private field
+	i18n.locale = newLocale;
 }
diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue
index f402b26ad8..4ba1b6da76 100644
--- a/packages/frontend/src/pages/about.vue
+++ b/packages/frontend/src/pages/about.vue
@@ -6,98 +6,100 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
-		<div class="_gaps_m">
-			<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
-				<div style="overflow: clip;">
-					<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
-					<div :class="$style.bannerName">
-						<b>{{ instance.name ?? host }}</b>
+	<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+		<MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
+			<div class="_gaps_m">
+				<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
+					<div style="overflow: clip;">
+						<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
+						<div :class="$style.bannerName">
+							<b>{{ instance.name ?? host }}</b>
+						</div>
 					</div>
 				</div>
-			</div>
 
-			<MkKeyValue>
-				<template #key>{{ i18n.ts.description }}</template>
-				<template #value><div v-html="instance.description"></div></template>
-			</MkKeyValue>
+				<MkKeyValue>
+					<template #key>{{ i18n.ts.description }}</template>
+					<template #value><div v-html="instance.description"></div></template>
+				</MkKeyValue>
 
-			<FormSection>
-				<div class="_gaps_m">
-					<MkKeyValue :copy="version">
-						<template #key>Misskey</template>
-						<template #value>{{ version }}</template>
-					</MkKeyValue>
-					<div v-html="i18n.t('poweredByMisskeyDescription', { name: instance.name ?? host })">
-					</div>
-					<FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink>
-				</div>
-			</FormSection>
-
-			<FormSection>
-				<div class="_gaps_m">
-					<FormSplit>
-						<MkKeyValue>
-							<template #key>{{ i18n.ts.administrator }}</template>
-							<template #value>{{ instance.maintainerName }}</template>
-						</MkKeyValue>
-						<MkKeyValue>
-							<template #key>{{ i18n.ts.contact }}</template>
-							<template #value>{{ instance.maintainerEmail }}</template>
-						</MkKeyValue>
-					</FormSplit>
-					<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>{{ i18n.ts.impressum }}</FormLink>
-					<div class="_gaps_s">
-						<MkFolder v-if="instance.serverRules.length > 0">
-							<template #label>{{ i18n.ts.serverRules }}</template>
-
-							<ol class="_gaps_s" :class="$style.rules">
-								<li v-for="item, index in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
-							</ol>
-						</MkFolder>
-						<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
-						<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>{{ i18n.ts.privacyPolicy }}</FormLink>
-					</div>
-				</div>
-			</FormSection>
-
-			<FormSuspense :p="initStats">
 				<FormSection>
-					<template #label>{{ i18n.ts.statistics }}</template>
-					<FormSplit>
-						<MkKeyValue>
-							<template #key>{{ i18n.ts.users }}</template>
-							<template #value>{{ number(stats.originalUsersCount) }}</template>
+					<div class="_gaps_m">
+						<MkKeyValue :copy="version">
+							<template #key>Misskey</template>
+							<template #value>{{ version }}</template>
 						</MkKeyValue>
-						<MkKeyValue>
-							<template #key>{{ i18n.ts.notes }}</template>
-							<template #value>{{ number(stats.originalNotesCount) }}</template>
-						</MkKeyValue>
-					</FormSplit>
+						<div v-html="i18n.t('poweredByMisskeyDescription', { name: instance.name ?? host })">
+						</div>
+						<FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink>
+					</div>
 				</FormSection>
-			</FormSuspense>
 
-			<FormSection>
-				<template #label>Well-known resources</template>
-				<div class="_gaps_s">
-					<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 === 'emojis'" :contentMax="1000" :marginMin="20">
-		<XEmojis/>
-	</MkSpacer>
-	<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
-		<XFederation/>
-	</MkSpacer>
-	<MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20">
-		<MkInstanceStats/>
-	</MkSpacer>
+				<FormSection>
+					<div class="_gaps_m">
+						<FormSplit>
+							<MkKeyValue>
+								<template #key>{{ i18n.ts.administrator }}</template>
+								<template #value>{{ instance.maintainerName }}</template>
+							</MkKeyValue>
+							<MkKeyValue>
+								<template #key>{{ i18n.ts.contact }}</template>
+								<template #value>{{ instance.maintainerEmail }}</template>
+							</MkKeyValue>
+						</FormSplit>
+						<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>{{ i18n.ts.impressum }}</FormLink>
+						<div class="_gaps_s">
+							<MkFolder v-if="instance.serverRules.length > 0">
+								<template #label>{{ i18n.ts.serverRules }}</template>
+
+								<ol class="_gaps_s" :class="$style.rules">
+									<li v-for="item, index in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
+								</ol>
+							</MkFolder>
+							<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
+							<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>{{ i18n.ts.privacyPolicy }}</FormLink>
+						</div>
+					</div>
+				</FormSection>
+
+				<FormSuspense :p="initStats">
+					<FormSection>
+						<template #label>{{ i18n.ts.statistics }}</template>
+						<FormSplit>
+							<MkKeyValue>
+								<template #key>{{ i18n.ts.users }}</template>
+								<template #value>{{ number(stats.originalUsersCount) }}</template>
+							</MkKeyValue>
+							<MkKeyValue>
+								<template #key>{{ i18n.ts.notes }}</template>
+								<template #value>{{ number(stats.originalNotesCount) }}</template>
+							</MkKeyValue>
+						</FormSplit>
+					</FormSection>
+				</FormSuspense>
+
+				<FormSection>
+					<template #label>Well-known resources</template>
+					<div class="_gaps_s">
+						<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 === 'emojis'" :contentMax="1000" :marginMin="20">
+			<XEmojis/>
+		</MkSpacer>
+		<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
+			<XFederation/>
+		</MkSpacer>
+		<MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20">
+			<MkInstanceStats/>
+		</MkSpacer>
+	</MkHorizontalSwipe>
 </MkStickyContainer>
 </template>
 
@@ -114,6 +116,7 @@ import FormSplit from '@/components/form/split.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkInstanceStats from '@/components/MkInstanceStats.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import number from '@/filters/number.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue
index 5632bf7caf..c31c6d0903 100644
--- a/packages/frontend/src/pages/announcements.vue
+++ b/packages/frontend/src/pages/announcements.vue
@@ -7,34 +7,36 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="800">
-		<div class="_gaps">
-			<MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo>
-			<MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps">
-				<section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement">
-					<div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div>
-					<div :class="$style.header">
-						<span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span>
-						<span style="margin-right: 0.5em;">
-							<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
-							<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
-							<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
-							<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
-						</span>
-						<span>{{ announcement.title }}</span>
-					</div>
-					<div :class="$style.content">
-						<Mfm :text="announcement.text"/>
-						<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
-						<div style="opacity: 0.7; font-size: 85%;">
-							<MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/>
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div :key="tab" class="_gaps">
+				<MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo>
+				<MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps">
+					<section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement">
+						<div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div>
+						<div :class="$style.header">
+							<span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span>
+							<span style="margin-right: 0.5em;">
+								<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
+								<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
+								<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
+								<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
+							</span>
+							<span>{{ announcement.title }}</span>
 						</div>
-					</div>
-					<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer">
-						<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
-					</div>
-				</section>
-			</MkPagination>
-		</div>
+						<div :class="$style.content">
+							<Mfm :text="announcement.text"/>
+							<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+							<div style="opacity: 0.7; font-size: 85%;">
+								<MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/>
+							</div>
+						</div>
+						<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer">
+							<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
+						</div>
+					</section>
+				</MkPagination>
+			</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -44,6 +46,7 @@ import { ref, computed } from 'vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 971eca8cae..4cdf2eea7d 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -7,53 +7,55 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="700" :class="$style.main">
-		<div v-if="channel && tab === 'overview'" class="_gaps">
-			<div class="_panel" :class="$style.bannerContainer">
-				<XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
-				<MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton>
-				<MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ti ti-star"></i></MkButton>
-				<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : undefined }" :class="$style.banner">
-					<div :class="$style.bannerStatus">
-						<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
-						<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div v-if="channel && tab === 'overview'" key="overview" class="_gaps">
+				<div class="_panel" :class="$style.bannerContainer">
+					<XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
+					<MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton>
+					<MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ti ti-star"></i></MkButton>
+					<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : undefined }" :class="$style.banner">
+						<div :class="$style.bannerStatus">
+							<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
+							<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+						</div>
+						<div v-if="channel.isSensitive" :class="$style.sensitiveIndicator">{{ i18n.ts.sensitive }}</div>
+						<div :class="$style.bannerFade"></div>
+					</div>
+					<div v-if="channel.description" :class="$style.description">
+						<Mfm :text="channel.description" :isNote="false"/>
 					</div>
-					<div v-if="channel.isSensitive" :class="$style.sensitiveIndicator">{{ i18n.ts.sensitive }}</div>
-					<div :class="$style.bannerFade"></div>
 				</div>
-				<div v-if="channel.description" :class="$style.description">
-					<Mfm :text="channel.description" :isNote="false"/>
+
+				<MkFoldableSection>
+					<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template>
+					<div v-if="channel.pinnedNotes && channel.pinnedNotes.length > 0" class="_gaps">
+						<MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/>
+					</div>
+				</MkFoldableSection>
+			</div>
+			<div v-if="channel && tab === 'timeline'" key="timeline" class="_gaps">
+				<MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo>
+
+				<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
+				<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
+
+				<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
+			</div>
+			<div v-else-if="tab === 'featured'" key="featured">
+				<MkNotes :pagination="featuredPagination"/>
+			</div>
+			<div v-else-if="tab === 'search'" key="search">
+				<div class="_gaps">
+					<div>
+						<MkInput v-model="searchQuery" @enter="search()">
+							<template #prefix><i class="ti ti-search"></i></template>
+						</MkInput>
+						<MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton>
+					</div>
+					<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
 				</div>
 			</div>
-
-			<MkFoldableSection>
-				<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template>
-				<div v-if="channel.pinnedNotes && channel.pinnedNotes.length > 0" class="_gaps">
-					<MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/>
-				</div>
-			</MkFoldableSection>
-		</div>
-		<div v-if="channel && tab === 'timeline'" class="_gaps">
-			<MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo>
-
-			<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
-			<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
-
-			<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
-		</div>
-		<div v-else-if="tab === 'featured'">
-			<MkNotes :pagination="featuredPagination"/>
-		</div>
-		<div v-else-if="tab === 'search'">
-			<div class="_gaps">
-				<div>
-					<MkInput v-model="searchQuery" @enter="search()">
-						<template #prefix><i class="ti ti-search"></i></template>
-					</MkInput>
-					<MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton>
-				</div>
-				<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
-			</div>
-		</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 	<template #footer>
 		<div :class="$style.footer">
@@ -87,6 +89,7 @@ import { defaultStore } from '@/store.js';
 import MkNote from '@/components/MkNote.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { PageHeaderItem } from '@/types/page-header.js';
 import { isSupportShare } from '@/scripts/navigator.js';
 import copyToClipboard from '@/scripts/copy-to-clipboard.js';
@@ -100,6 +103,7 @@ const props = defineProps<{
 }>();
 
 const tab = ref('overview');
+
 const channel = ref<Misskey.entities.Channel | null>(null);
 const favorited = ref(false);
 const searchQuery = ref('');
diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue
index 80a401eee7..0b35ac0ece 100644
--- a/packages/frontend/src/pages/channels.vue
+++ b/packages/frontend/src/pages/channels.vue
@@ -7,44 +7,46 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="700">
-		<div v-if="tab === 'search'">
-			<div class="_gaps">
-				<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
-					<template #prefix><i class="ti ti-search"></i></template>
-				</MkInput>
-				<MkRadios v-model="searchType" @update:modelValue="search()">
-					<option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option>
-					<option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option>
-				</MkRadios>
-				<MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton>
-			</div>
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div v-if="tab === 'search'" key="search">
+				<div class="_gaps">
+					<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
+						<template #prefix><i class="ti ti-search"></i></template>
+					</MkInput>
+					<MkRadios v-model="searchType" @update:modelValue="search()">
+						<option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option>
+						<option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option>
+					</MkRadios>
+					<MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton>
+				</div>
 
-			<MkFoldableSection v-if="channelPagination">
-				<template #header>{{ i18n.ts.searchResult }}</template>
-				<MkChannelList :key="key" :pagination="channelPagination"/>
-			</MkFoldableSection>
-		</div>
-		<div v-if="tab === 'featured'">
-			<MkPagination v-slot="{items}" :pagination="featuredPagination">
-				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
-			</MkPagination>
-		</div>
-		<div v-else-if="tab === 'favorites'">
-			<MkPagination v-slot="{items}" :pagination="favoritesPagination">
-				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
-			</MkPagination>
-		</div>
-		<div v-else-if="tab === 'following'">
-			<MkPagination v-slot="{items}" :pagination="followingPagination">
-				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
-			</MkPagination>
-		</div>
-		<div v-else-if="tab === 'owned'">
-			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
-			<MkPagination v-slot="{items}" :pagination="ownedPagination">
-				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
-			</MkPagination>
-		</div>
+				<MkFoldableSection v-if="channelPagination">
+					<template #header>{{ i18n.ts.searchResult }}</template>
+					<MkChannelList :key="key" :pagination="channelPagination"/>
+				</MkFoldableSection>
+			</div>
+			<div v-if="tab === 'featured'" key="featured">
+				<MkPagination v-slot="{items}" :pagination="featuredPagination">
+					<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+				</MkPagination>
+			</div>
+			<div v-else-if="tab === 'favorites'" key="favorites">
+				<MkPagination v-slot="{items}" :pagination="favoritesPagination">
+					<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+				</MkPagination>
+			</div>
+			<div v-else-if="tab === 'following'" key="following">
+				<MkPagination v-slot="{items}" :pagination="followingPagination">
+					<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+				</MkPagination>
+			</div>
+			<div v-else-if="tab === 'owned'" key="owned">
+				<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
+				<MkPagination v-slot="{items}" :pagination="ownedPagination">
+					<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+				</MkPagination>
+			</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -58,6 +60,7 @@ import MkInput from '@/components/MkInput.vue';
 import MkRadios from '@/components/MkRadios.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/global/router/supplier.js';
diff --git a/packages/frontend/src/pages/drive.file.vue b/packages/frontend/src/pages/drive.file.vue
index 2c1e5d20a7..6a9e907963 100644
--- a/packages/frontend/src/pages/drive.file.vue
+++ b/packages/frontend/src/pages/drive.file.vue
@@ -9,13 +9,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
 	</template>
 
-	<MkSpacer v-if="tab === 'info'" :contentMax="800">
-		<XFileInfo :fileId="fileId"/>
-	</MkSpacer>
+	<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+		<MkSpacer v-if="tab === 'info'" key="info" :contentMax="800">
+			<XFileInfo :fileId="fileId"/>
+		</MkSpacer>
 
-	<MkSpacer v-else-if="tab === 'notes'" :contentMax="800">
-		<XNotes :fileId="fileId"/>
-	</MkSpacer>
+		<MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800">
+			<XNotes :fileId="fileId"/>
+		</MkSpacer>
+	</MkHorizontalSwipe>
 </MkStickyContainer>
 </template>
 
@@ -23,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, ref, defineAsyncComponent } from 'vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
 const props = defineProps<{
 	fileId: string;
diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue
index f068de8880..1b80014366 100644
--- a/packages/frontend/src/pages/explore.vue
+++ b/packages/frontend/src/pages/explore.vue
@@ -6,17 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
-	<div>
-		<div v-if="tab === 'featured'">
+	<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+		<div v-if="tab === 'featured'" key="featured">
 			<XFeatured/>
 		</div>
-		<div v-else-if="tab === 'users'">
+		<div v-else-if="tab === 'users'" key="users">
 			<XUsers/>
 		</div>
-		<div v-else-if="tab === 'roles'">
+		<div v-else-if="tab === 'roles'" key="roles">
 			<XRoles/>
 		</div>
-	</div>
+	</MkHorizontalSwipe>
 </MkStickyContainer>
 </template>
 
@@ -26,6 +26,7 @@ import XFeatured from './explore.featured.vue';
 import XUsers from './explore.users.vue';
 import XRoles from './explore.roles.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 
diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue
index 7852018894..53510ea232 100644
--- a/packages/frontend/src/pages/flash/flash-index.vue
+++ b/packages/frontend/src/pages/flash/flash-index.vue
@@ -7,32 +7,34 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="700">
-		<div v-if="tab === 'featured'">
-			<MkPagination v-slot="{items}" :pagination="featuredFlashsPagination">
-				<div class="_gaps_s">
-					<MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/>
-				</div>
-			</MkPagination>
-		</div>
-
-		<div v-else-if="tab === 'my'">
-			<div class="_gaps">
-				<MkButton gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton>
-				<MkPagination v-slot="{items}" :pagination="myFlashsPagination">
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div v-if="tab === 'featured'" key="featured">
+				<MkPagination v-slot="{items}" :pagination="featuredFlashsPagination">
 					<div class="_gaps_s">
 						<MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/>
 					</div>
 				</MkPagination>
 			</div>
-		</div>
 
-		<div v-else-if="tab === 'liked'">
-			<MkPagination v-slot="{items}" :pagination="likedFlashsPagination">
-				<div class="_gaps_s">
-					<MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/>
+			<div v-else-if="tab === 'my'" key="my">
+				<div class="_gaps">
+					<MkButton gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton>
+					<MkPagination v-slot="{items}" :pagination="myFlashsPagination">
+						<div class="_gaps_s">
+							<MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/>
+						</div>
+					</MkPagination>
 				</div>
-			</MkPagination>
-		</div>
+			</div>
+
+			<div v-else-if="tab === 'liked'" key="liked">
+				<MkPagination v-slot="{items}" :pagination="likedFlashsPagination">
+					<div class="_gaps_s">
+						<MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/>
+					</div>
+				</MkPagination>
+			</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -42,6 +44,7 @@ import { computed, ref } from 'vue';
 import MkFlashPreview from '@/components/MkFlashPreview.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { useRouter } from '@/global/router/supplier.js';
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 4318694d4f..fabbc1c05d 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<template #icon><i class="ti ti-code"></i></template>
 					<template #label>{{ i18n.ts._play.viewSource }}</template>
 
-					<MkCode :code="flash.script" lang="is" :inline="false" class="_monospace"/>
+					<MkCode :code="flash.script" lang="is" class="_monospace"/>
 				</MkFolder>
 				<div :class="$style.footer">
 					<Mfm :text="`By @${flash.user.username}`"/>
diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue
index 0198ab9700..9749888fe9 100644
--- a/packages/frontend/src/pages/gallery/index.vue
+++ b/packages/frontend/src/pages/gallery/index.vue
@@ -7,8 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="1400">
-		<div class="_root">
-			<div v-if="tab === 'explore'">
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div v-if="tab === 'explore'" key="explore">
 				<MkFoldableSection class="_margin">
 					<template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template>
 					<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disableAutoLoad="true">
@@ -26,14 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</MkPagination>
 				</MkFoldableSection>
 			</div>
-			<div v-else-if="tab === 'liked'">
+			<div v-else-if="tab === 'liked'" key="liked">
 				<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
 					<div :class="$style.items">
 						<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
 					</div>
 				</MkPagination>
 			</div>
-			<div v-else-if="tab === 'my'">
+			<div v-else-if="tab === 'my'" key="my">
 				<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA>
 				<MkPagination v-slot="{items}" :pagination="myPostsPagination">
 					<div :class="$style.items">
@@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</div>
 				</MkPagination>
 			</div>
-		</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -51,6 +51,7 @@ import { watch, ref, computed } from 'vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { useRouter } from '@/global/router/supplier.js';
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index c8a0eeeeaa..4211dc0d87 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -7,111 +7,113 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32">
-		<div v-if="tab === 'overview'" class="_gaps_m">
-			<div class="fnfelxur">
-				<img :src="faviconUrl" alt="" class="icon"/>
-				<span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span>
-			</div>
-			<div style="display: flex; flex-direction: column; gap: 1em;">
-				<MkKeyValue :copy="host" oneline>
-					<template #key>Host</template>
-					<template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template>
-				</MkKeyValue>
-				<MkKeyValue oneline>
-					<template #key>{{ i18n.ts.software }}</template>
-					<template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template>
-				</MkKeyValue>
-				<MkKeyValue oneline>
-					<template #key>{{ i18n.ts.administrator }}</template>
-					<template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template>
-				</MkKeyValue>
-			</div>
-			<MkKeyValue>
-				<template #key>{{ i18n.ts.description }}</template>
-				<template #value>{{ instance.description }}</template>
-			</MkKeyValue>
-
-			<FormSection v-if="iAmModerator">
-				<template #label>Moderation</template>
-				<div class="_gaps_s">
-					<MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
-					<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
-					<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
-					<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div v-if="tab === 'overview'" key="overview" class="_gaps_m">
+				<div class="fnfelxur">
+					<img :src="faviconUrl" alt="" class="icon"/>
+					<span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span>
 				</div>
-			</FormSection>
-
-			<FormSection>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.registeredAt }}</template>
-					<template #value><MkTime mode="detail" :time="instance.firstRetrievedAt"/></template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.updatedAt }}</template>
-					<template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>{{ i18n.ts.latestRequestReceivedAt }}</template>
-					<template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
-				</MkKeyValue>
-			</FormSection>
-
-			<FormSection>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>Following (Pub)</template>
-					<template #value>{{ number(instance.followingCount) }}</template>
-				</MkKeyValue>
-				<MkKeyValue oneline style="margin: 1em 0;">
-					<template #key>Followers (Sub)</template>
-					<template #value>{{ number(instance.followersCount) }}</template>
-				</MkKeyValue>
-			</FormSection>
-
-			<FormSection>
-				<template #label>Well-known resources</template>
-				<FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink>
-				<FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink>
-				<FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink>
-				<FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink>
-				<FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink>
-			</FormSection>
-		</div>
-		<div v-else-if="tab === 'chart'" class="_gaps_m">
-			<div class="cmhjzshl">
-				<div class="selects">
-					<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
-						<option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option>
-						<option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option>
-						<option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option>
-						<option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option>
-						<option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option>
-						<option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option>
-						<option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option>
-						<option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option>
-						<option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option>
-						<option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option>
-						<option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option>
-					</MkSelect>
+				<div style="display: flex; flex-direction: column; gap: 1em;">
+					<MkKeyValue :copy="host" oneline>
+						<template #key>Host</template>
+						<template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template>
+					</MkKeyValue>
+					<MkKeyValue oneline>
+						<template #key>{{ i18n.ts.software }}</template>
+						<template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template>
+					</MkKeyValue>
+					<MkKeyValue oneline>
+						<template #key>{{ i18n.ts.administrator }}</template>
+						<template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template>
+					</MkKeyValue>
 				</div>
-				<div class="charts">
-					<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
-					<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
-					<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
-					<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
+				<MkKeyValue>
+					<template #key>{{ i18n.ts.description }}</template>
+					<template #value>{{ instance.description }}</template>
+				</MkKeyValue>
+
+				<FormSection v-if="iAmModerator">
+					<template #label>Moderation</template>
+					<div class="_gaps_s">
+						<MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
+						<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
+						<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
+						<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
+					</div>
+				</FormSection>
+
+				<FormSection>
+					<MkKeyValue oneline style="margin: 1em 0;">
+						<template #key>{{ i18n.ts.registeredAt }}</template>
+						<template #value><MkTime mode="detail" :time="instance.firstRetrievedAt"/></template>
+					</MkKeyValue>
+					<MkKeyValue oneline style="margin: 1em 0;">
+						<template #key>{{ i18n.ts.updatedAt }}</template>
+						<template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template>
+					</MkKeyValue>
+					<MkKeyValue oneline style="margin: 1em 0;">
+						<template #key>{{ i18n.ts.latestRequestReceivedAt }}</template>
+						<template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
+					</MkKeyValue>
+				</FormSection>
+
+				<FormSection>
+					<MkKeyValue oneline style="margin: 1em 0;">
+						<template #key>Following (Pub)</template>
+						<template #value>{{ number(instance.followingCount) }}</template>
+					</MkKeyValue>
+					<MkKeyValue oneline style="margin: 1em 0;">
+						<template #key>Followers (Sub)</template>
+						<template #value>{{ number(instance.followersCount) }}</template>
+					</MkKeyValue>
+				</FormSection>
+
+				<FormSection>
+					<template #label>Well-known resources</template>
+					<FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink>
+					<FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink>
+					<FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink>
+					<FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink>
+					<FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink>
+				</FormSection>
+			</div>
+			<div v-else-if="tab === 'chart'" key="chart" class="_gaps_m">
+				<div class="cmhjzshl">
+					<div class="selects">
+						<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
+							<option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option>
+							<option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option>
+							<option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option>
+							<option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option>
+							<option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option>
+							<option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option>
+							<option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option>
+							<option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option>
+							<option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option>
+							<option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option>
+							<option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option>
+						</MkSelect>
+					</div>
+					<div class="charts">
+						<div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
+						<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
+						<div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
+						<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
+					</div>
 				</div>
 			</div>
-		</div>
-		<div v-else-if="tab === 'users'" class="_gaps_m">
-			<MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;">
-				<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`">
-					<MkUserCardMini :user="user"/>
-				</MkA>
-			</MkPagination>
-		</div>
-		<div v-else-if="tab === 'raw'" class="_gaps_m">
-			<MkObjectView tall :value="instance">
-			</MkObjectView>
-		</div>
+			<div v-else-if="tab === 'users'" key="users" class="_gaps_m">
+				<MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;">
+					<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`">
+						<MkUserCardMini :user="user"/>
+					</MkA>
+				</MkPagination>
+			</div>
+			<div v-else-if="tab === 'raw'" key="raw" class="_gaps_m">
+				<MkObjectView tall :value="instance">
+				</MkObjectView>
+			</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -136,6 +138,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkPagination from '@/components/MkPagination.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
 import { dateString } from '@/filters/date.js';
 
@@ -144,6 +147,7 @@ const props = defineProps<{
 }>();
 
 const tab = ref('overview');
+
 const chartSrc = ref('instance-requests');
 const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
 const instance = ref<Misskey.entities.FederationInstance | null>(null);
diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue
index 850222708e..468e46838b 100644
--- a/packages/frontend/src/pages/my-clips/index.vue
+++ b/packages/frontend/src/pages/my-clips/index.vue
@@ -7,20 +7,22 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="700">
-		<div v-if="tab === 'my'" class="_gaps">
-			<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div v-if="tab === 'my'" key="my" class="_gaps">
+				<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
 
-			<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="_gaps">
-				<MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`">
+				<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="_gaps">
+					<MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`">
+						<MkClipPreview :clip="item"/>
+					</MkA>
+				</MkPagination>
+			</div>
+			<div v-else-if="tab === 'favorites'" key="favorites" class="_gaps">
+				<MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`">
 					<MkClipPreview :clip="item"/>
 				</MkA>
-			</MkPagination>
-		</div>
-		<div v-else-if="tab === 'favorites'" class="_gaps">
-			<MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`">
-				<MkClipPreview :clip="item"/>
-			</MkA>
-		</div>
+			</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -36,6 +38,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { clipsCache } from '@/cache.js';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
 const pagination = {
 	endpoint: 'clips/list' as const,
@@ -44,6 +47,7 @@ const pagination = {
 };
 
 const tab = ref('my');
+
 const favorites = ref<Misskey.entities.Clip[] | null>(null);
 
 const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue
index d57bda41b5..8913a89adb 100644
--- a/packages/frontend/src/pages/notifications.vue
+++ b/packages/frontend/src/pages/notifications.vue
@@ -7,15 +7,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="800">
-		<div v-if="tab === 'all'">
-			<XNotifications class="notifications" :excludeTypes="excludeTypes"/>
-		</div>
-		<div v-else-if="tab === 'mentions'">
-			<MkNotes :pagination="mentionsPagination"/>
-		</div>
-		<div v-else-if="tab === 'directNotes'">
-			<MkNotes :pagination="directNotesPagination"/>
-		</div>
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div v-if="tab === 'all'" key="all">
+				<XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/>
+			</div>
+			<div v-else-if="tab === 'mentions'" key="mention">
+				<MkNotes :pagination="mentionsPagination"/>
+			</div>
+			<div v-else-if="tab === 'directNotes'" key="directNotes">
+				<MkNotes :pagination="directNotesPagination"/>
+			</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -24,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, ref } from 'vue';
 import XNotifications from '@/components/MkNotifications.vue';
 import MkNotes from '@/components/MkNotes.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
@@ -96,3 +99,10 @@ definePageMetadata(computed(() => ({
 	icon: 'ti ti-bell',
 })));
 </script>
+
+<style module lang="scss">
+.notifications {
+	border-radius: var(--radius);
+	overflow: clip;
+}
+</style>
diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue
index 22ab9ced09..8b57b1af9f 100644
--- a/packages/frontend/src/pages/pages.vue
+++ b/packages/frontend/src/pages/pages.vue
@@ -7,30 +7,32 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :contentMax="700">
-		<div v-if="tab === 'featured'">
-			<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
-				<div class="_gaps">
-					<MkPagePreview v-for="page in items" :key="page.id" :page="page"/>
-				</div>
-			</MkPagination>
-		</div>
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<div v-if="tab === 'featured'" key="featured">
+				<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
+					<div class="_gaps">
+						<MkPagePreview v-for="page in items" :key="page.id" :page="page"/>
+					</div>
+				</MkPagination>
+			</div>
 
-		<div v-else-if="tab === 'my'" class="_gaps">
-			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
-			<MkPagination v-slot="{items}" :pagination="myPagesPagination">
-				<div class="_gaps">
-					<MkPagePreview v-for="page in items" :key="page.id" :page="page"/>
-				</div>
-			</MkPagination>
-		</div>
+			<div v-else-if="tab === 'my'" key="my" class="_gaps">
+				<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
+				<MkPagination v-slot="{items}" :pagination="myPagesPagination">
+					<div class="_gaps">
+						<MkPagePreview v-for="page in items" :key="page.id" :page="page"/>
+					</div>
+				</MkPagination>
+			</div>
 
-		<div v-else-if="tab === 'liked'">
-			<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
-				<div class="_gaps">
-					<MkPagePreview v-for="like in items" :key="like.page.id" :page="like.page"/>
-				</div>
-			</MkPagination>
-		</div>
+			<div v-else-if="tab === 'liked'" key="liked">
+				<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
+					<div class="_gaps">
+						<MkPagePreview v-for="like in items" :key="like.page.id" :page="like.page"/>
+					</div>
+				</MkPagination>
+			</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -40,6 +42,7 @@ import { computed, ref } from 'vue';
 import MkPagePreview from '@/components/MkPagePreview.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { useRouter } from '@/global/router/supplier.js';
diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue
index 9d5e5697ce..b68de805cf 100644
--- a/packages/frontend/src/pages/search.vue
+++ b/packages/frontend/src/pages/search.vue
@@ -7,18 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 
-	<MkSpacer v-if="tab === 'note'" :contentMax="800">
-		<div v-if="notesSearchAvailable">
-			<XNote/>
-		</div>
-		<div v-else>
-			<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
-		</div>
-	</MkSpacer>
+	<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+		<MkSpacer v-if="tab === 'note'" key="note" :contentMax="800">
+			<div v-if="notesSearchAvailable">
+				<XNote/>
+			</div>
+			<div v-else>
+				<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
+			</div>
+		</MkSpacer>
 
-	<MkSpacer v-else-if="tab === 'user'" :contentMax="800">
-		<XUser/>
-	</MkSpacer>
+		<MkSpacer v-else-if="tab === 'user'" key="user" :contentMax="800">
+			<XUser/>
+		</MkSpacer>
+	</MkHorizontalSwipe>
 </MkStickyContainer>
 </template>
 
@@ -29,6 +31,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { $i } from '@/account.js';
 import { instance } from '@/instance.js';
 import MkInfo from '@/components/MkInfo.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
 const XNote = defineAsyncComponent(() => import('./search.note.vue'));
 const XUser = defineAsyncComponent(() => import('./search.user.vue'));
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 607aaec521..e52a5ee04f 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -155,6 +155,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch>
 				<MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch>
 				<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch>
+				<MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch>
 			</div>
 			<MkSelect v-model="serverDisconnectedBehavior">
 				<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@@ -296,6 +297,7 @@ const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
 const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline'));
 const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications'));
 const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect'));
+const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe'));
 
 watch(lang, () => {
 	miLocalStorage.setItem('lang', lang.value as string);
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 6fe8963f51..666a9968b2 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -7,27 +7,28 @@ SPDX-License-Identifier: AGPL-3.0-only
 <MkStickyContainer>
 	<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template>
 	<MkSpacer :contentMax="800">
-		<div ref="rootEl" v-hotkey.global="keymap">
-			<MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
-				{{ i18n.ts._timelineDescription[src] }}
-			</MkInfo>
-			<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
-
-			<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
-			<div :class="$style.tl">
-				<MkTimeline
-					ref="tlComponent"
-					:key="src + withRenotes + withReplies + onlyFiles"
-					:src="src.split(':')[0]"
-					:list="src.split(':')[1]"
-					:withRenotes="withRenotes"
-					:withReplies="withReplies"
-					:onlyFiles="onlyFiles"
-					:sound="true"
-					@queue="queueUpdated"
-				/>
+		<MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin">
+			<div :key="src + withRenotes + withReplies + onlyFiles" ref="rootEl" v-hotkey.global="keymap">
+				<MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
+					{{ i18n.ts._timelineDescription[src] }}
+				</MkInfo>
+				<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
+				<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
+				<div :class="$style.tl">
+					<MkTimeline
+						ref="tlComponent"
+						:key="src + withRenotes + withReplies + onlyFiles"
+						:src="src.split(':')[0]"
+						:list="src.split(':')[1]"
+						:withRenotes="withRenotes"
+						:withReplies="withReplies"
+						:onlyFiles="onlyFiles"
+						:sound="true"
+						@queue="queueUpdated"
+					/>
+				</div>
 			</div>
-		</div>
+		</MkHorizontalSwipe>
 	</MkSpacer>
 </MkStickyContainer>
 </template>
@@ -38,6 +39,7 @@ import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
 import MkTimeline from '@/components/MkTimeline.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 import { scroll } from '@/scripts/scroll.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
@@ -69,7 +71,9 @@ const withRenotes = ref(true);
 const withReplies = ref($i ? defaultStore.state.tlWithReplies : false);
 const onlyFiles = ref(false);
 
-watch(src, () => queue.value = 0);
+watch(src, () => {
+	queue.value = 0;
+});
 
 watch(withReplies, (x) => {
 	if ($i) defaultStore.set('tlWithReplies', x);
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index 95869e7b8c..603f1bef33 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -8,19 +8,21 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
 	<div>
 		<div v-if="user">
-			<XHome v-if="tab === 'home'" :user="user"/>
-			<MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0">
-				<XTimeline :user="user"/>
-			</MkSpacer>
-			<XActivity v-else-if="tab === 'activity'" :user="user"/>
-			<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
-			<XReactions v-else-if="tab === 'reactions'" :user="user"/>
-			<XClips v-else-if="tab === 'clips'" :user="user"/>
-			<XLists v-else-if="tab === 'lists'" :user="user"/>
-			<XPages v-else-if="tab === 'pages'" :user="user"/>
-			<XFlashs v-else-if="tab === 'flashs'" :user="user"/>
-			<XGallery v-else-if="tab === 'gallery'" :user="user"/>
-			<XRaw v-else-if="tab === 'raw'" :user="user"/>
+			<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+				<XHome v-if="tab === 'home'" key="home" :user="user"/>
+				<MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800" style="padding-top: 0">
+					<XTimeline :user="user"/>
+				</MkSpacer>
+				<XActivity v-else-if="tab === 'activity'" key="activity" :user="user"/>
+				<XAchievements v-else-if="tab === 'achievements'" key="achievements" :user="user"/>
+				<XReactions v-else-if="tab === 'reactions'" key="reactions" :user="user"/>
+				<XClips v-else-if="tab === 'clips'" key="clips" :user="user"/>
+				<XLists v-else-if="tab === 'lists'" key="lists" :user="user"/>
+				<XPages v-else-if="tab === 'pages'" key="pages" :user="user"/>
+				<XFlashs v-else-if="tab === 'flashs'" key="flashs" :user="user"/>
+				<XGallery v-else-if="tab === 'gallery'" key="gallery" :user="user"/>
+				<XRaw v-else-if="tab === 'raw'" key="raw" :user="user"/>
+			</MkHorizontalSwipe>
 		</div>
 		<MkError v-else-if="error" @retry="fetchUser()"/>
 		<MkLoading v-else/>
@@ -36,6 +38,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { i18n } from '@/i18n.js';
 import { $i } from '@/account.js';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
 const XHome = defineAsyncComponent(() => import('./home.vue'));
 const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
@@ -57,6 +60,7 @@ const props = withDefaults(defineProps<{
 });
 
 const tab = ref(props.page);
+
 const user = ref<null | Misskey.entities.UserDetailed>(null);
 const error = ref<any>(null);
 
diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend/src/scripts/i18n.ts
index 8e5f17f38a..55b5371950 100644
--- a/packages/frontend/src/scripts/i18n.ts
+++ b/packages/frontend/src/scripts/i18n.ts
@@ -2,33 +2,114 @@
  * SPDX-FileCopyrightText: syuilo and other misskey contributors
  * SPDX-License-Identifier: AGPL-3.0-only
  */
+import type { ILocale, ParameterizedString } from '../../../../locales/index.js';
 
-export class I18n<T extends Record<string, any>> {
-	public ts: T;
+type FlattenKeys<T extends ILocale, TPrediction> = keyof {
+	[K in keyof T as T[K] extends ILocale
+		? FlattenKeys<T[K], TPrediction> extends infer C extends string
+			? `${K & string}.${C}`
+			: never
+		: T[K] extends TPrediction
+			? K
+			: never]: T[K];
+};
 
-	constructor(locale: T) {
-		this.ts = locale;
+type ParametersOf<T extends ILocale, TKey extends FlattenKeys<T, ParameterizedString<string>>> = T extends ILocale
+	? TKey extends `${infer K}.${infer C}`
+		// @ts-expect-error -- C は明らかに FlattenKeys<T[K], ParameterizedString<string>> になるが、型システムはここでは TKey がドット区切りであることのコンテキストを持たないので、型システムに合法にて示すことはできない。
+		? ParametersOf<T[K], C>
+		: TKey extends keyof T
+			? T[TKey] extends ParameterizedString<infer P>
+				? P
+				: never
+			: never
+	: never;
 
+type Ts<T extends ILocale> = {
+	readonly [K in keyof T as T[K] extends ParameterizedString<string> ? never : K]: T[K] extends ILocale ? Ts<T[K]> : string;
+};
+
+export class I18n<T extends ILocale> {
+	constructor(private locale: T) {
 		//#region BIND
 		this.t = this.t.bind(this);
 		//#endregion
 	}
 
-	// string にしているのは、ドット区切りでのパス指定を許可するため
-	// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
-	public t(key: string, args?: Record<string, string | number>): string {
-		try {
-			let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
+	public get ts(): Ts<T> {
+		if (_DEV_) {
+			class Handler<TTarget extends object> implements ProxyHandler<TTarget> {
+				get(target: TTarget, p: string | symbol): unknown {
+					const value = target[p as keyof TTarget];
 
-			if (args) {
-				for (const [k, v] of Object.entries(args)) {
-					str = str.replace(`{${k}}`, v.toString());
+					if (typeof value === 'object') {
+						// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 実際には null がくることはないので。
+						return new Proxy(value!, new Handler<TTarget[keyof TTarget] & object>());
+					}
+
+					if (typeof value === 'string') {
+						const parameters = Array.from(value.matchAll(/\{(\w+)\}/g)).map(([, parameter]) => parameter);
+
+						if (parameters.length) {
+							console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`);
+						}
+
+						return value;
+					}
+
+					console.error(`Unexpected locale key: ${String(p)}`);
+
+					return p;
 				}
 			}
-			return str;
-		} catch (err) {
-			console.warn(`missing localization '${key}'`);
-			return key;
+
+			return new Proxy(this.locale, new Handler()) as Ts<T>;
 		}
+
+		return this.locale as Ts<T>;
+	}
+
+	/**
+	 * @deprecated なるべくこのメソッド使うよりも locale 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも
+	 */
+	public t<TKey extends FlattenKeys<T, string>>(key: TKey): string;
+	public t<TKey extends FlattenKeys<T, ParameterizedString<string>>>(key: TKey, args: { readonly [_ in ParametersOf<T, TKey>]: string | number }): string;
+	public t(key: string, args?: { readonly [_: string]: string | number }) {
+		let str: string | ParameterizedString<string> | ILocale = this.locale;
+
+		for (const k of key.split('.')) {
+			str = str[k];
+
+			if (_DEV_) {
+				if (typeof str === 'undefined') {
+					console.error(`Unexpected locale key: ${key}`);
+					return key;
+				}
+			}
+		}
+
+		if (args) {
+			if (_DEV_) {
+				const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter));
+
+				if (missing.length) {
+					console.error(`Missing locale parameters: ${missing.join(', ')} at ${key}`);
+				}
+			}
+
+			for (const [k, v] of Object.entries(args)) {
+				const search = `{${k}}`;
+
+				if (_DEV_) {
+					if (!(str as string).includes(search)) {
+						console.error(`Unexpected locale parameter: ${k} at ${key}`);
+					}
+				}
+
+				str = (str as string).replace(search, v.toString());
+			}
+		}
+
+		return str;
 	}
 }
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index e3a85377d8..21b796caa1 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -427,6 +427,10 @@ export const defaultStore = markRaw(new Storage('base', {
 			sfxVolume: 1,
 		},
 	},
+	enableHorizontalSwipe: {
+		where: 'device',
+		default: true,
+	},
 
 	sound_masterVolume: {
 		where: 'device',